From 36d58915725bd3766df50be19e265ac1a0abb7e1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 19 Jun 2026 20:39:09 +0300 Subject: [PATCH] fix(layers): freeze toggle in ribbon dropdown + sortable manager columns Ribbon layer dropdown rows were a single button, so a click on the visible/freeze/lock icon fell through to the make-active handler instead of toggling that state. Split the row: each icon is its own toggle button, only the swatch+label selects the active layer. Toggle handlers now resync the ribbon so the icon updates while the dropdown stays open. Layer Manager columns are now sortable: clicking a header sorts by that column, clicking again flips direction (SVG arrow indicator). Sort is reapplied on every panel sync so it survives edits, preserves the selection by name, and keeps the ribbon dropdown order in step. Fixes #133 Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 2 + src/app/update.rs | 12 +++ src/ui/layers.rs | 176 +++++++++++++++++++++++++++++++++++-------- src/ui/ribbon/mod.rs | 63 ++++++++++++---- 4 files changed, 205 insertions(+), 48 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index ec4b6972f..ac9f5624e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -889,6 +889,8 @@ pub enum Message { LayerToggleVisible(usize), LayerToggleLock(usize), LayerToggleFreeze(usize), + /// Sort the Layer Manager table by a clicked column header. + LayerSort(crate::ui::layers::LayerSortCol), /// Toggle per-viewport freeze: (layer_index, vp_col_index) LayerToggleVpFreeze(usize, usize), LayerNew, diff --git a/src/app/update.rs b/src/app/update.rs index 13fe2ceb1..1019e58cd 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1894,10 +1894,20 @@ impl OpenCADStudio { name, if on { "on" } else { "off" } )); + self.sync_ribbon_layers(); } Task::none() } + Message::LayerSort(col) => { + let i = self.active_tab; + self.tabs[i].layers.sort_by(col); + // Keep the ribbon dropdown's order (and its toggle indices) in + // step with the re-sorted manager table. + self.sync_ribbon_layers(); + Task::none() + } + Message::LayerToggleLock(idx) => { let i = self.active_tab; if idx < self.tabs[i].layers.layers.len() { @@ -1912,6 +1922,7 @@ impl OpenCADStudio { name, if locked { "locked" } else { "unlocked" } )); + self.sync_ribbon_layers(); } Task::none() } @@ -1933,6 +1944,7 @@ impl OpenCADStudio { } self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; + self.sync_ribbon_layers(); } Task::none() } diff --git a/src/ui/layers.rs b/src/ui/layers.rs index 0e03c49e9..26d6c63e2 100644 --- a/src/ui/layers.rs +++ b/src/ui/layers.rs @@ -22,6 +22,20 @@ pub struct VpCol { pub label: String, } +/// Sortable column in the Layer Manager table. Clicking a header sorts by +/// that column; clicking the active header again flips the direction (#133). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LayerSortCol { + Name, + On, + Freeze, + Lock, + Color, + Linetype, + Lineweight, + Transparency, +} + // ── Row-height-derived constants ───────────────────────────────────────── /// SVG icon size inside a layer-table cell. const ICON_SZ: f32 = ROW_H * 0.62; // ≈16 px at ROW_H=26 @@ -80,6 +94,10 @@ pub struct LayerPanel { pub lw_combo: combo_box::State, /// Per-viewport columns (only populated when in a paper layout with viewports). pub vp_cols: Vec, + /// Active sort column, or `None` for document order. + pub sort_col: Option, + /// Sort direction; `true` = ascending. + pub sort_asc: bool, } impl Default for LayerPanel { @@ -103,6 +121,8 @@ impl Default for LayerPanel { }]), lw_combo: combo_box::State::new(lw_options()), vp_cols: vec![], + sort_col: None, + sort_asc: true, } } } @@ -148,6 +168,63 @@ impl LayerPanel { } }) .collect(); + + self.apply_sort(); + } + + /// Set/flip the sort column from a header click, then re-sort. + pub fn sort_by(&mut self, col: LayerSortCol) { + if self.sort_col == Some(col) { + self.sort_asc = !self.sort_asc; + } else { + self.sort_col = Some(col); + self.sort_asc = true; + } + self.apply_sort(); + } + + /// Reorder `self.layers` by the active sort column, preserving the current + /// selection by name. No-op in document order (`sort_col == None`). + fn apply_sort(&mut self) { + let Some(col) = self.sort_col else { + return; + }; + let asc = self.sort_asc; + let sel_name = self + .selected + .and_then(|i| self.layers.get(i)) + .map(|l| l.name.clone()); + + use std::cmp::Ordering; + self.layers.sort_by(|a, b| { + let ord = match col { + LayerSortCol::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + LayerSortCol::On => a.visible.cmp(&b.visible), + LayerSortCol::Freeze => a.frozen.cmp(&b.frozen), + LayerSortCol::Lock => a.locked.cmp(&b.locked), + LayerSortCol::Color => color_sort_key(a.color).cmp(&color_sort_key(b.color)), + LayerSortCol::Linetype => { + a.linetype.to_lowercase().cmp(&b.linetype.to_lowercase()) + } + LayerSortCol::Lineweight => a.lineweight.value().cmp(&b.lineweight.value()), + LayerSortCol::Transparency => a.transparency.cmp(&b.transparency), + }; + // Stable tie-break by name so equal keys keep a predictable order. + let ord = ord.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + if asc { + ord + } else { + match ord { + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + Ordering::Equal => Ordering::Equal, + } + } + }); + + if let Some(n) = sel_name { + self.selected = self.layers.iter().position(|l| l.name == n); + } } pub fn sync_linetypes(&mut self, items: Vec) { @@ -194,40 +271,24 @@ impl LayerPanel { .padding([4, 8]); // ── Column header ───────────────────────────────────────────────── + let sc = self.sort_col; + let sa = self.sort_asc; let mut header_row = row![ text("Status").size(10).color(DIM).width(50), - text("Name") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_NAME)), - text("On") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_ICON)), - text("Freeze") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_ICON)), - text("Lock") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_ICON)), - text("Color") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_COLOR)), - text("Linetype") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_LT)), - text("Lineweight") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_LW)), - text("Transparency") - .size(10) - .color(DIM) - .width(Length::Fixed(COL_TRANS)), + sortable_header("Name", LayerSortCol::Name, COL_NAME, sc, sa), + sortable_header("On", LayerSortCol::On, COL_ICON, sc, sa), + sortable_header("Freeze", LayerSortCol::Freeze, COL_ICON, sc, sa), + sortable_header("Lock", LayerSortCol::Lock, COL_ICON, sc, sa), + sortable_header("Color", LayerSortCol::Color, COL_COLOR, sc, sa), + sortable_header("Linetype", LayerSortCol::Linetype, COL_LT, sc, sa), + sortable_header("Lineweight", LayerSortCol::Lineweight, COL_LW, sc, sa), + sortable_header( + "Transparency", + LayerSortCol::Transparency, + COL_TRANS, + sc, + sa + ), ] .spacing(4) .align_y(iced::Center); @@ -297,6 +358,57 @@ impl LayerPanel { } } +// ── Sorting helpers ───────────────────────────────────────────────────────── + +/// Packed RGB key for ordering colours deterministically by hue-ish bytes. +fn color_sort_key(c: Color) -> u32 { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32; + (q(c.r) << 16) | (q(c.g) << 8) | q(c.b) +} + +/// A clickable column header that sorts the table by `col`. Shows an up/down +/// SVG arrow when it is the active sort column (#133). +fn sortable_header<'a>( + label: &'a str, + col: LayerSortCol, + width: f32, + active: Option, + asc: bool, +) -> Element<'a, Message> { + let mut content = row![text(label).size(10).color(DIM)] + .spacing(3) + .align_y(iced::Center); + if active == Some(col) { + content = content.push(if asc { + crate::ui::icons::arrow_up(8.0, DIM) + } else { + crate::ui::icons::arrow_down(8.0, DIM) + }); + } + button(content) + .on_press(Message::LayerSort(col)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.30, + g: 0.30, + b: 0.30, + a: 1.0, + }, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 2.0, + right: 2.0, + }) + .width(Length::Fixed(width)) + .into() +} + // ── Toolbar buttons ─────────────────────────────────────────────────────── fn toolbar_btn<'a>(icon: &'static [u8], label: &'a str, msg: Message) -> Element<'a, Message> { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d46d44a56..ced0d5908 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -677,10 +677,27 @@ impl Ribbon { } fn layer_combo_overlay(&self) -> Option> { + // A toggle icon (visible / freeze / lock) is its own button so a click + // on it flips that state instead of bubbling up to the row's + // make-active handler (#133). + let icon_btn = |bytes: &'static [u8], msg: Message| -> Element<'_, Message> { + button(crate::ui::icons::raw(bytes, 14.0)) + .on_press(msg) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .padding([2, 4]) + .into() + }; let rows: Vec> = self .layer_infos .iter() - .map(|info| { + .enumerate() + .map(|(index, info)| { let is_active = info.name == self.active_layer; let lc = info.color; let lv = info.visible; @@ -706,9 +723,18 @@ impl Ribbon { .width(12) .height(12); - let vis = crate::ui::icons::raw(crate::ui::icons::layer_visible(lv), 14.0); - let freeze = crate::ui::icons::raw(crate::ui::icons::layer_freeze(lf), 14.0); - let lock = crate::ui::icons::raw(crate::ui::icons::layer_lock(ll), 14.0); + let vis = icon_btn( + crate::ui::icons::layer_visible(lv), + Message::LayerToggleVisible(index), + ); + let freeze = icon_btn( + crate::ui::icons::layer_freeze(lf), + Message::LayerToggleFreeze(index), + ); + let lock = icon_btn( + crate::ui::icons::layer_lock(ll), + Message::LayerToggleLock(index), + ); let checkmark: Element<'_, Message> = container(if is_active { crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) } else { @@ -721,21 +747,26 @@ impl Ribbon { .size(11) .color(if is_active { LABEL_ON } else { LABEL_OFF }); - button( - row![checkmark, vis, freeze, lock, swatch, label] + // The swatch + label area selects the layer as active; the + // icon buttons above handle their own toggles. + let select = button(row![swatch, label].spacing(5).align_y(iced::Center)) + .on_press(Message::RibbonLayerChanged(name)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .width(Fill) + .padding([4, 4]); + + container( + row![checkmark, vis, freeze, lock, select] .spacing(5) .align_y(iced::Center), ) - .on_press(Message::RibbonLayerChanged(name)) - .style(|_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered | button::Status::Pressed => ROW_HOVER, - _ => Color::TRANSPARENT, - })), - ..Default::default() - }) - .width(Fill) - .padding([4, 8]) + .padding([0, 4]) .into() }) .collect();