diff --git a/src/app/mod.rs b/src/app/mod.rs index 48cb2fd26..2b311596e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -868,6 +868,8 @@ pub(super) struct OpenCADStudio { pub(crate) dock_float_resizing: Option<(crate::ui::dock::PanelId, bool)>, /// Docked panel whose title bar the pointer is over (shows pin / close). pub(crate) dock_title_hover: Option, + /// Slot (side, index) whose pallet menu is open. + pub(crate) dock_tab_menu: Option<(crate::app::config::DockSide, usize)>, /// Reference-table column currently being width-resized (column index), /// with the last pointer position. Mirrors the Layers Name-column drag. pub(crate) xref_col_drag: Option, @@ -4308,6 +4310,7 @@ impl OpenCADStudio { dock_split_drag: None, dock_float_resizing: None, dock_title_hover: None, + dock_tab_menu: None, xref_col_drag: None, xref_col_last: None, xref_split_drag: false, diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 1a07ae657..d8715a2e5 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -592,6 +592,33 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_title_hover = id; iced::Task::none() } + DockMsg::TabMenu(slot) => { + self.dock_tab_menu = slot; + iced::Task::none() + } + DockMsg::TabMenuToggle(side, gi, id) => { + if gi >= self.dock.groups(side).len() { + self.dock_tab_menu = None; + return iced::Task::none(); + } + if self.dock_group_visible_tabs(side, gi).contains(&id) { + // Unchecking hides the pallet; it keeps its tab, so it + // comes back here when opened again. + let task = self.on_dock(DockMsg::Close(id)); + if self.dock_group_shown(side, gi).is_none() { + self.dock_tab_menu = None; + } + self.save_config(); + return task; + } + self.dock.add_tab(id, side, gi, None); + // Joining may have removed an earlier slot on this side. + self.dock_tab_menu = self.dock.location(id); + let task = self.dock_open_panel(id); + self.dock.select_tab(id); + self.save_config(); + task + } DockMsg::FloatRaise(id) => { self.dock.raise_float(id); iced::Task::none() @@ -784,6 +811,35 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven } } + /// Show panel `id` where the layout has it, running its usual open path + /// (refreshes, ribbon highlight) when it was closed. + fn dock_open_panel(&mut self, id: crate::ui::dock::PanelId) -> iced::Task { + use crate::ui::dock::PanelId; + if self.dock_panel_visible(id) { + return iced::Task::none(); + } + match id { + PanelId::Properties => { + self.show_properties = true; + self.ribbon.set_properties(true); + } + PanelId::BlockPalette => self.open_blocks_palette(None), + PanelId::ExternalReferences => { + self.show_external_references = true; + self.refresh_xref_manager(); + } + PanelId::Browser => self.show_browser = true, + PanelId::NodeGraph => { + return self.on_graph(crate::ui::node_graph::GraphMsg::Toggle); + } + PanelId::PointCloudManager => self.pc_manager.show = true, + PanelId::Count => self.set_count_palette(true), + PanelId::SheetSetManager => self.show_sheet_set_manager(true), + } + self.dock_expanded = Some(id); + iced::Task::none() + } + /// Start a (not yet confirmed) drag of `id`: it only moves once the /// pointer travels past `DOCK_DRAG_THRESHOLD`. fn dock_begin_drag(&mut self, id: crate::ui::dock::PanelId) { @@ -980,13 +1036,16 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven }; // Over a tab strip: drop between its tabs (reorders a member). let tabs = self.dock_group_visible_tabs(side, gi); - if tabs.len() > 1 && p.y < top + crate::ui::dock::DOCK_TAB_H { + if self.dock_expanded_groups(side).contains(&gi) + && p.y < top + crate::ui::dock::DOCK_TAB_H + { let x0 = self.dock_column_x0(side) - + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 }; + + if side == DockSide::Right { crate::ui::dock::DOCK_DIVIDER_W } else { 0.0 } + + crate::ui::dock::DOCK_TAB_INSET; let visible_at = crate::ui::dock::tab_insert_index( p.x, x0, - self.dock_column_width(side), + crate::ui::dock::DOCK_TAB_CELL_W, tabs.len(), ); // Map the gap among visible tabs to a position in all tabs. @@ -2005,11 +2064,11 @@ mod tests { let mut app = dock_app(); app.dock.add_tab(PanelId::BlockPalette, DockSide::Left, 0, None); app.dock.right.clear(); - // Tabs: Properties | Blocks across the 250 px column. Drag the - // Properties tab past the end of the strip. + // Icon tabs: Properties | Blocks from the left. Drag the Properties + // tab past the Blocks tab. let _ = app.on_dock(DockMsg::SelectTab(PanelId::Properties)); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 12.0))); - let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(245.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 12.0))); + let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(70.0, 12.0))); assert_eq!( app.dock_drag_target, Some(DropTarget::Tab { @@ -2026,6 +2085,29 @@ mod tests { assert_eq!(app.dock.left[0].active, PanelId::Properties); } + #[test] + fn dock_tab_menu_adds_and_hides_pallets() { + use crate::app::config::DockSide; + use crate::ui::dock::{DockMsg, PanelId}; + let mut app = dock_app(); + app.show_browser = false; + let _ = app.on_dock(DockMsg::TabMenu(Some((DockSide::Left, 0)))); + // Checking a closed pallet opens it as a tab of this slot. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + assert!(app.show_browser); + assert_eq!(app.dock.left[0].tabs, vec![PanelId::Properties, PanelId::Browser]); + assert_eq!(app.dock.left[0].active, PanelId::Browser); + // Checking a pallet docked elsewhere moves it here. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::BlockPalette)); + assert!(app.dock.right.is_empty()); + assert_eq!(app.dock.left[0].tabs.len(), 3); + // Unchecking hides it but keeps its tab. + let _ = app.on_dock(DockMsg::TabMenuToggle(DockSide::Left, 0, PanelId::Browser)); + assert!(!app.show_browser); + assert!(app.dock.left[0].tabs.contains(&PanelId::Browser)); + assert_eq!(app.dock_tab_menu, Some((DockSide::Left, 0))); + } + #[test] fn dock_tab_group_resize_applies_to_every_tab() { use crate::app::config::DockSide; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 687f2ada3..08d373cea 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2953,13 +2953,15 @@ impl OpenCADStudio { let tabs = self.dock_group_visible_tabs(side, gi); let id = self.dock_group_shown(side, gi).expect("visible group"); let body = self.panel_body(id, width, false, tab); - let panel: Element<'_, Message> = if tabs.len() > 1 { - column![dock_tab_strip(&tabs, id, width), body] + let menu = (self.dock_tab_menu == Some((side, gi))).then(|| { + crate::ui::dock::PanelId::ALL.map(|p| { + (p, self.dock_panel_visible(p) && tabs.contains(&p)) + }) + }); + let panel: Element<'_, Message> = + column![dock_tab_strip(&tabs, id, width, side, gi, menu), body] .height(Fill) - .into() - } else { - body - }; + .into(); let divider = dock_divider(id); match side { crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(), @@ -3643,28 +3645,29 @@ fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { use crate::ui::dock::{DOCK_DIVIDER_W, DOCK_RAIL_W, DOCK_TAB_H}; -/// The tab row above a slot holding several panels. Pressing a tab shows its -/// panel; dragging it pulls the panel out of the slot. +/// The tab row above a docked slot: one icon tab per shown pallet, from the +/// left, and a menu button on the right listing every pallet. Pressing a tab +/// shows its pallet; dragging it reorders the tabs or pulls the pallet out. +/// `menu` holds each pallet with whether it shows in this slot while the +/// menu is open. fn dock_tab_strip( tabs: &[crate::ui::dock::PanelId], active: crate::ui::dock::PanelId, width: f32, + side: crate::app::config::DockSide, + gi: usize, + menu: Option<[(crate::ui::dock::PanelId, bool); 8]>, ) -> Element<'static, Message> { - let cells: Vec> = tabs + use crate::ui::dock::{DockMsg, DOCK_TAB_CELL_W, DOCK_TAB_INSET}; + const TAB_GAP: f32 = 2.0; + let mut cells: Vec> = tabs .iter() .map(|id| { let id = *id; let is_active = id == active; - let label = text(id.title()) - .size(11) - .wrapping(iced::widget::text::Wrapping::None) - .ellipsis(iced::advanced::text::Ellipsis::End); - let cell = container(label) - .width(Fill) - .height(Fill) - .padding([0, 8]) + let cell = container(crate::ui::icons::themed(id.icon(), 14.0)) + .center_x(Length::Fixed(DOCK_TAB_CELL_W - TAB_GAP)) .center_y(Fill) - .clip(true) .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { @@ -3673,11 +3676,6 @@ fn dock_tab_strip( } else { palette.background.weak.color })), - text_color: Some(if is_active { - palette.background.base.text - } else { - palette.background.weak.text.scale_alpha(0.75) - }), border: Border { color: if is_active { palette.primary.base.color @@ -3690,20 +3688,85 @@ fn dock_tab_strip( ..Default::default() } }); - mouse_area(cell) - .on_press(Message::Dock(crate::ui::dock::DockMsg::SelectTab(id))) - .interaction(iced::mouse::Interaction::Pointer) - .into() + let cell = mouse_area(cell) + .on_press(Message::Dock(DockMsg::SelectTab(id))) + .interaction(iced::mouse::Interaction::Pointer); + iced::widget::tooltip( + cell, + text(id.title()).size(10), + iced::widget::tooltip::Position::Bottom, + ) + .gap(4) + .into() }) .collect(); - container(row(cells).spacing(2).height(Fill)) + cells.push(Space::new().width(Fill).into()); + let menu_open = menu.is_some(); + let menu_button = button(crate::ui::icons::themed_secondary(crate::ui::icons::MENU, 12.0)) + .on_press(Message::Dock(DockMsg::TabMenu( + (!menu_open).then_some((side, gi)), + ))) + .style(button::subtle) + .padding([2, 5]); + let rows: Vec> = menu + .iter() + .flatten() + .map(|(id, checked)| { + let id = *id; + let check: Element<'static, Message> = if *checked { + crate::ui::icons::themed(crate::ui::icons::CHECK, 12.0) + } else { + Space::new().width(12).height(12).into() + }; + button( + row![ + check, + crate::ui::icons::themed(id.icon(), 14.0), + text(id.title()).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .on_press(Message::Dock(DockMsg::TabMenuToggle(side, gi, id))) + .style(button::subtle) + .padding([4, 8]) + .width(Fill) + .into() + }) + .collect(); + let popup: Element<'static, Message> = container(column(rows).spacing(1).padding(4)) + .width(Length::Fixed(220.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .into(); + cells.push( + iced_aw::DropDown::new(menu_button, popup, menu_open) + // Open away from the window edge the slot is docked against. + .alignment(match side { + crate::app::config::DockSide::Left => iced_aw::drop_down::Alignment::BottomEnd, + crate::app::config::DockSide::Right => { + iced_aw::drop_down::Alignment::BottomStart + } + }) + .offset(2.0) + .on_dismiss(Message::Dock(DockMsg::TabMenu(None))) + .into(), + ); + container(row(cells).spacing(TAB_GAP).height(Fill).align_y(iced::Center)) .width(Length::Fixed(width)) - .height(Length::Fixed(DOCK_TAB_H)) + .height(Length::Fixed(crate::ui::dock::DOCK_TAB_H)) .padding(iced::Padding { top: 3.0, - right: 4.0, + right: 2.0, bottom: 0.0, - left: 4.0, + left: DOCK_TAB_INSET, }) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.strong.color)), diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 502d5fd68..167ee0f16 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -43,6 +43,12 @@ pub enum DockMsg { FloatResizeGrab(PanelId, bool), /// The pointer entered (`Some`) or left (`None`) a docked title bar. TitleHover(Option), + /// Open (`Some`) the pallet menu of a slot (side, slot index), or close + /// it (`None`). + TabMenu(Option<(DockSide, usize)>), + /// Pallet menu pick: show `panel` as a tab of the slot, or hide it when + /// it already shows there. + TabMenuToggle(DockSide, usize, PanelId), /// Bring floating `panel` to the front. FloatRaise(PanelId), /// The pointer left the edge column; collapse any auto-collapsing panel. @@ -69,6 +75,18 @@ pub enum PanelId { } impl PanelId { + /// Every panel, in the order the pallet menu lists them. + pub const ALL: [PanelId; 8] = [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + PanelId::NodeGraph, + PanelId::PointCloudManager, + PanelId::Count, + PanelId::SheetSetManager, + ]; + /// Localized-friendly display name used by the collapsed/edge chrome. pub fn title(self) -> &'static str { match self { @@ -288,16 +306,7 @@ impl DockState { /// written by an older version (or edited by hand): a panel placed twice /// keeps only its first placement. pub fn ensure_settings(&mut self) { - for id in [ - PanelId::Properties, - PanelId::BlockPalette, - PanelId::ExternalReferences, - PanelId::Browser, - PanelId::NodeGraph, - PanelId::PointCloudManager, - PanelId::Count, - PanelId::SheetSetManager, - ] { + for id in PanelId::ALL { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } let mut seen = std::collections::BTreeSet::new(); @@ -571,8 +580,8 @@ pub enum DropTarget { } /// Which part of a docked slot the pointer is over while dragging: the top -/// and bottom quarters split the slot (dock above / below), the middle joins -/// it as a tab. +/// and bottom bands (35 % each) split the slot (dock above / below), the +/// middle 30 % joins it as a tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SlotZone { Above, @@ -583,7 +592,7 @@ pub enum SlotZone { /// Classify a pointer at `y` within a slot spanning `top..bottom`. pub fn slot_zone(y: f32, top: f32, bottom: f32) -> SlotZone { let h = (bottom - top).max(1.0); - let band = (h * 0.25).min(80.0); + let band = h * 0.35; if y < top + band { SlotZone::Above } else if y > bottom - band { @@ -629,13 +638,17 @@ pub const DOCK_DIVIDER_W: f32 = 5.0; /// Height of a docked slot's tab strip. pub const DOCK_TAB_H: f32 = 24.0; +/// Pitch of one icon tab in a tab strip (tab plus the gap after it). +pub const DOCK_TAB_CELL_W: f32 = 30.0; +/// Left inset of the first tab in a tab strip. +pub const DOCK_TAB_INSET: f32 = 4.0; + /// Insertion position (0..=`count`) for a tab dropped at `x` on a strip of -/// `count` equal tabs spanning `x0..x0 + width`. -pub fn tab_insert_index(x: f32, x0: f32, width: f32, count: usize) -> usize { - if count == 0 || width <= 0.0 { +/// `count` left-aligned tabs of pitch `cell` starting at `x0`. +pub fn tab_insert_index(x: f32, x0: f32, cell: f32, count: usize) -> usize { + if count == 0 || cell <= 0.0 { return 0; } - let cell = width / count as f32; (((x - x0) / cell).round().max(0.0) as usize).min(count) } @@ -955,10 +968,11 @@ mod tests { #[test] fn tab_insert_index_rounds_to_the_nearest_gap() { - assert_eq!(tab_insert_index(0.0, 0.0, 300.0, 3), 0); - assert_eq!(tab_insert_index(140.0, 0.0, 300.0, 3), 1); - assert_eq!(tab_insert_index(290.0, 0.0, 300.0, 3), 3); - assert_eq!(tab_insert_index(-20.0, 0.0, 300.0, 3), 0); + assert_eq!(tab_insert_index(0.0, 0.0, 30.0, 3), 0); + assert_eq!(tab_insert_index(40.0, 0.0, 30.0, 3), 1); + assert_eq!(tab_insert_index(85.0, 0.0, 30.0, 3), 3); + assert_eq!(tab_insert_index(250.0, 0.0, 30.0, 3), 3); + assert_eq!(tab_insert_index(-20.0, 0.0, 30.0, 3), 0); } #[test] @@ -1116,12 +1130,12 @@ mod tests { } #[test] - fn slot_zone_splits_quarters() { + fn slot_zone_splits_into_stacking_and_tab_bands() { assert_eq!(slot_zone(10.0, 0.0, 400.0), SlotZone::Above); + assert_eq!(slot_zone(130.0, 0.0, 400.0), SlotZone::Above); assert_eq!(slot_zone(200.0, 0.0, 400.0), SlotZone::Middle); + assert_eq!(slot_zone(270.0, 0.0, 400.0), SlotZone::Below); assert_eq!(slot_zone(390.0, 0.0, 400.0), SlotZone::Below); - // Tall slots cap the split bands so the tab zone stays generous. - assert_eq!(slot_zone(100.0, 0.0, 1000.0), SlotZone::Middle); } #[test]