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();