From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 3809E1FF0BA for ; Thu, 20 Aug 2026 10:18:09 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 0ABF821586; Thu, 20 Aug 2026 10:18:09 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Subject: [PATCH yew-widget-toolkit 1/3] widget: navigation drawer: fix first open for default collapsed menus Date: Thu, 20 Aug 2026 10:17:50 +0200 Message-ID: <20260820081803.991511-2-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260820081803.991511-1-d.csapak@proxmox.com> References: <20260820081803.991511-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.842 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: WF4VBK2HCUJHXB6CYV4QN55RGRQ4PUAR X-Message-ID-Hash: WF4VBK2HCUJHXB6CYV4QN55RGRQ4PUAR X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: A menu item can have it's default expanded state have set to collapsed. This was correctly displayed when the menu was created, but the first toggle falsely relied on the fact that non-existent entries in the `menu_states` HashMap should be interpreted as expanded. Fix this by introducing a `MenuStates` struct that abstracts these things away by first collecting the default states for all entries and then only saving those which changed from the default. This eliminates the multiple hardcoded default values and it keeps the saved data minimal, by only saving the keys that have a collapsed default (which should be in the minority) and only keeping the state of the ones that changed. For example when all menus are open by default and the user didn't collapse any, the struct contains just two empty HashSets. It also prepares us to save the data later, when we want to have the collapsed/expanded state persistent. Signed-off-by: Dominik Csapak --- src/widget/nav/navigation_drawer.rs | 96 ++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 21 deletions(-) diff --git a/src/widget/nav/navigation_drawer.rs b/src/widget/nav/navigation_drawer.rs index 87f0743..b2bcd1e 100644 --- a/src/widget/nav/navigation_drawer.rs +++ b/src/widget/nav/navigation_drawer.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::HashSet; use std::rc::Rc; use yew::html::{IntoEventCallback, IntoPropValue}; @@ -146,6 +146,67 @@ impl NavigationDrawer { } } +/// A struct that holds the current state of expanded/collapsed menu items +/// +/// Only the deviation from the menu's defaults is tracked, which keeps the state minimal and lets +/// a changed default still take effect for users that never toggled that item. +struct MenuStates { + default_collapsed: HashSet, // contains all keys that are collapsed by default + changed: HashSet, // contains all keys that deviate from their default +} + +impl MenuStates { + /// Initializes the state from the menu config (default_collapsed). + fn new(menu: &Menu) -> Self { + let mut default_collapsed = HashSet::new(); + Self::collect_default_collapse_states(&mut default_collapsed, menu); + + Self { + default_collapsed, + changed: HashSet::new(), + } + } + + // iterates over a menu and saves all submenus that are collapsed by default + fn collect_default_collapse_states(collapsed: &mut HashSet, menu: &Menu) { + for entry in menu.children.iter() { + if let MenuEntry::Item(item) = entry { + if let Some(key) = &item.key + && item.default_collapsed + { + collapsed.insert(key.to_string()); + } + if let Some(sub_menu) = &item.submenu { + Self::collect_default_collapse_states(collapsed, sub_menu); + } + } + } + } + + fn toggle(&mut self, key: &Key) { + self.set_state(key, !self.is_open(key)); + } + + fn is_open(&self, key: &Key) -> bool { + let key = key.to_string(); + let changed = self.changed.contains(&key); + let default_collapsed = self.default_collapsed.contains(&key); + // open means either collapsed by default and toggled, or open by default and untouched + changed == default_collapsed + } + + fn set_state(&mut self, key: &Key, open: bool) { + let key = key.to_string(); + let default_open = !self.default_collapsed.contains(&key); + // only record the keys that deviate from their default, so the set stays minimal + if open == default_open { + self.changed.remove(&key); + } else { + self.changed.insert(key); + } + } +} + pub enum Msg { Select(Option, bool, bool), SelectionChange(Selection), @@ -159,7 +220,7 @@ pub struct PwtNavigationDrawer { node_ref: NodeRef, active: Option, selection: Selection, - menu_states: HashMap, // true = open + menu_states: MenuStates, _nav_ctx_handle: Option>, } @@ -269,16 +330,8 @@ impl PwtNavigationDrawer { ) { match item { MenuEntry::Item(child) => { - // An absent state means the user has not toggled this submenu yet, so fall back - // to its default: open, unless the item opted into starting collapsed. A submenu - // on the path to the active item is force-opened by open_ancestors, so a - // deep-linked target stays visible even under default_collapsed. let open = match &child.key { - Some(key) => self - .menu_states - .get(key) - .copied() - .unwrap_or(!child.default_collapsed), + Some(key) => self.menu_states.is_open(key), None => false, }; @@ -373,7 +426,8 @@ impl PwtNavigationDrawer { if item.selectable { Some(entry) } else { - self.menu_states.insert(desired.clone(), true); + // the selection moves on to a child, so make sure the submenu is visible + self.menu_states.set_state(desired, true); find_first_key_recursive(&submenu.children) } } @@ -407,7 +461,7 @@ impl PwtNavigationDrawer { let mut path = Vec::new(); if collect(&props.menu.children, key, &mut path) { for key in path { - self.menu_states.insert(key, true); + self.menu_states.set_state(&key, true); } } } @@ -490,7 +544,7 @@ impl Component for PwtNavigationDrawer { node_ref: NodeRef::default(), active: active.clone(), selection, - menu_states: HashMap::new(), + menu_states: MenuStates::new(&props.menu), _nav_ctx_handle, }; @@ -562,9 +616,7 @@ impl Component for PwtNavigationDrawer { self.emit_item_activate(&key, ctx); } if toggle { - let entry = - *self.menu_states.entry(key.clone()).or_insert_with(|| true); - self.menu_states.insert(key, !entry); + self.menu_states.toggle(&key); } } return true; @@ -592,16 +644,15 @@ impl Component for PwtNavigationDrawer { true } Msg::MenuToggle(key) => { - let entry = *self.menu_states.entry(key.clone()).or_insert_with(|| true); - self.menu_states.insert(key, !entry); + self.menu_states.toggle(&key); true } Msg::MenuClose(key) => { - self.menu_states.insert(key, false); + self.menu_states.set_state(&key, false); true } Msg::MenuOpen(key) => { - self.menu_states.insert(key, true); + self.menu_states.set_state(&key, true); true } } @@ -612,6 +663,9 @@ impl Component for PwtNavigationDrawer { if props.selection != old_props.selection { self.selection = Self::init_selection(ctx, props.selection.clone(), &self.active); } + if props.menu != old_props.menu { + self.menu_states = MenuStates::new(&props.menu); + } true } -- 2.47.3