diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index f41150459..6089998e5 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -911,17 +911,7 @@ impl OpenCADStudio { if self.show_external_references { // Always open expanded so the panel is immediately usable; // dock it on the right if the user closed it from the layout. - if !self - .dock - .is_placed(crate::ui::dock::PanelId::ExternalReferences) - { - self.dock.dock( - crate::ui::dock::PanelId::ExternalReferences, - crate::app::config::DockSide::Right, - usize::MAX, - ); - } - self.dock_reveal(crate::ui::dock::PanelId::ExternalReferences); + self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } } diff --git a/src/app/commands/count.rs b/src/app/commands/count.rs index ff3bf4854..548b1a67d 100644 --- a/src/app/commands/count.rs +++ b/src/app/commands/count.rs @@ -236,12 +236,9 @@ impl OpenCADStudio { self.count_palette.show = open; self.ribbon.set_count_palette(open); if open { - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); - } else if self.dock_peek == Some(id) { - self.dock_peek = None; + self.dock_open_at_default(id); + } else { + self.dock_unpeek(id); } } diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 3c97b2ad3..a54dec828 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -679,15 +679,7 @@ impl OpenCADStudio { // rearrange themselves on upgrade for a panel they have // not asked for. let id = crate::ui::dock::PanelId::Browser; - if !self.dock.is_placed(id) { - // dock() clamps an out-of-range index to the end of - // the edge, so this appends below whatever is there. - self.dock - .dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - // Open expanded so it is usable straight away; the pin - // button still collapses it. - self.dock_reveal(id); + self.dock_open_at_default(id); self.command_line .push_output(crate::t!("Browser opened.").as_ref()); } else { diff --git a/src/app/commands/pdf_underlay.rs b/src/app/commands/pdf_underlay.rs index 67f584b14..43335f027 100644 --- a/src/app/commands/pdf_underlay.rs +++ b/src/app/commands/pdf_underlay.rs @@ -374,16 +374,11 @@ impl OpenCADStudio { "POINTCLOUDMANAGER" => { let id = crate::ui::dock::PanelId::PointCloudManager; self.pc_manager.show = true; - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); + self.dock_open_at_default(id); } "POINTCLOUDMANAGERCLOSE" => { self.pc_manager.show = false; - if self.dock_peek == Some(crate::ui::dock::PanelId::PointCloudManager) { - self.dock_peek = None; - } + self.dock_unpeek(crate::ui::dock::PanelId::PointCloudManager); } "_PCUNCROP" =>self.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()), "_PDFULUNCLIP" => { diff --git a/src/app/commands/sheet_set.rs b/src/app/commands/sheet_set.rs index 73fa949fa..21e2b3245 100644 --- a/src/app/commands/sheet_set.rs +++ b/src/app/commands/sheet_set.rs @@ -577,12 +577,9 @@ impl OpenCADStudio { self.sheet_set.show = on; self.ribbon.set_sheet_set(on); if on { - if !self.dock.is_placed(id) { - self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); - } else if self.dock_peek == Some(id) { - self.dock_peek = None; + self.dock_open_at_default(id); + } else { + self.dock_unpeek(id); } } diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index 3cac5f413..117376bef 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -335,14 +335,14 @@ fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() { assert!(!app.dock.edge_auto_hide(DockSide::Right)); assert!(!app.dock_edge_expanded(DockSide::Left)); // Hovering an icon flies its group out. - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Browser))); + let _ = app.on_dock(DockMsg::Hover(PanelId::Browser)); assert!(app.dock_edge_expanded(DockSide::Left)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); let _ = app.on_dock(DockMsg::HoverExit); assert!(!app.dock_edge_expanded(DockSide::Left)); // Without auto-hide, hovering does not switch groups (clicks do). let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties)); - let _ = app.on_dock(DockMsg::Hover(Some(PanelId::Properties))); + let _ = app.on_dock(DockMsg::Hover(PanelId::Properties)); assert_eq!(app.dock_shown_group(DockSide::Left), Some(1)); assert!(app.dock_edge_expanded(DockSide::Left)); } diff --git a/src/app/dock/update.rs b/src/app/dock/update.rs index 5478b03e8..2c791fd1d 100644 --- a/src/app/dock/update.rs +++ b/src/app/dock/update.rs @@ -55,8 +55,18 @@ impl OpenCADStudio { } self.save_config(); } - DockMsg::TitleHover(id) => self.dock_title_hover = id, - DockMsg::GripHover(grip) => self.dock_grip_hover = grip, + DockMsg::TitleHover(id) => self.dock_title_hover = Some(id), + DockMsg::TitleHoverEnd(id) => { + if self.dock_title_hover == Some(id) { + self.dock_title_hover = None; + } + } + DockMsg::GripHover(side, gi) => self.dock_grip_hover = Some((side, gi)), + DockMsg::GripHoverEnd(side, gi) => { + if self.dock_grip_hover == Some((side, gi)) { + self.dock_grip_hover = None; + } + } DockMsg::FloatRaise(id) => self.dock.raise_float(id), DockMsg::FloatOut(id) => { // Float beside the edge it was docked on, near the top. @@ -114,15 +124,18 @@ impl OpenCADStudio { } DockMsg::Close(id) => return self.dock_set_open(id, false), DockMsg::Hover(id) => { - self.dock_icon_hover = id; + self.dock_icon_hover = Some(id); // Only an auto-hiding edge (or floating pallet) reacts to // hover: it reveals the hovered pallet's group. Otherwise // groups switch on click. Ignored mid-drag, when the pointer // is over the preview rather than the strip. - if let Some(id) = id { - if self.dock_drag.is_none() && self.dock.auto_hides(id) { - self.dock_reveal(id); - } + if self.dock_drag.is_none() && self.dock.auto_hides(id) { + self.dock_reveal(id); + } + } + DockMsg::HoverEnd(id) => { + if self.dock_icon_hover == Some(id) { + self.dock_icon_hover = None; } } DockMsg::HoverExit => { @@ -218,23 +231,15 @@ impl OpenCADStudio { } else { grab }; - // Store the grab first: the drop target reads it. - self.dock_drag = Some(DockDrag::Move { - panel, - group, - origin: Some(origin), - target, - grab, - }); - if !started { - return; - } - let found = self.dock_drop_target(point); - // A whole group only moves between group positions: it - // cannot join another group or float. - let target = match (group, found) { - (Some(_), DropTarget::Edge { .. }) | (None, _) => Some(found), - (Some(_), _) => None, + let target = if started { + // A whole group only moves between group positions: it + // cannot join another group or float. + match (group, self.dock_drop_target(point, grab)) { + (Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found), + (Some(_), _) => None, + } + } else { + target }; self.dock_drag = Some(DockDrag::Move { panel, @@ -251,11 +256,14 @@ impl OpenCADStudio { lower, } => { let Some(last) = last else { return }; + let Some(weights) = self.dock.groups(side).get(group).map(|g| &g.weights) else { + return; + }; let (_, avail) = self.dock_workspace_size(); let total: f32 = self .dock_slot_spans(side) .iter() - .map(|(i, _, _)| self.dock.groups(side)[group].weights[*i]) + .filter_map(|(i, _, _)| weights.get(*i)) .sum(); if avail > 0.0 && total > 0.0 { let per_px = total / avail; @@ -334,50 +342,33 @@ impl OpenCADStudio { id: crate::ui::dock::PanelId, open: bool, ) -> iced::Task { - use crate::ui::dock::PanelId; + use crate::ui::dock::{DockDrag, PanelId}; + let mut task = iced::Task::none(); if open { - let task = if self.dock_panel_visible(id) { - iced::Task::none() - } else { + if !self.dock_panel_visible(id) { match id { PanelId::Properties => { self.show_properties = true; self.ribbon.set_properties(true); - iced::Task::none() - } - PanelId::BlockPalette => { - self.open_blocks_palette(None); - iced::Task::none() } + PanelId::BlockPalette => self.open_blocks_palette(None), PanelId::ExternalReferences => { self.show_external_references = true; self.refresh_xref_manager(); - iced::Task::none() } - PanelId::Browser => { - self.show_browser = true; - iced::Task::none() - } - PanelId::NodeGraph => self.on_graph(crate::ui::node_graph::GraphMsg::Toggle), - PanelId::PointCloudManager => { - self.pc_manager.show = true; - iced::Task::none() - } - PanelId::Count => { - self.set_count_palette(true); - iced::Task::none() - } - PanelId::SheetSetManager => { - self.show_sheet_set_manager(true); - iced::Task::none() + PanelId::Browser => self.show_browser = true, + PanelId::NodeGraph => { + task = 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), PanelId::Layers => { self.sync_ribbon_layers(); self.show_layers = true; - iced::Task::none() } } - }; + } self.dock_reveal(id); return task; } @@ -407,16 +398,32 @@ impl OpenCADStudio { self.ribbon.deactivate_tool_if("LAYERS"); } } + self.dock_unpeek(id); + // A closed pallet cannot stay held by the pointer. + let held = match self.dock_drag { + Some(DockDrag::Move { panel, .. } | DockDrag::Width(panel)) => panel == id, + _ => false, + }; + if held { + self.dock_drag = None; + } + task + } + + /// Show `id` where the layout has it, first docking it on the right edge + /// when it has no place yet (opened for the first time, or never moved). + pub(crate) fn dock_open_at_default(&mut self, id: crate::ui::dock::PanelId) { + if !self.dock.is_placed(id) { + self.dock.dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + self.dock_reveal(id); + } + + /// Stop revealing `id` (it closed). + pub(crate) fn dock_unpeek(&mut self, id: crate::ui::dock::PanelId) { if self.dock_peek == Some(id) { self.dock_peek = None; } - if self - .dock_drag - .is_some_and(|d| d.movement().is_some_and(|(p, _, _)| p == id) || d == crate::ui::dock::DockDrag::Width(id)) - { - self.dock_drag = None; - } - iced::Task::none() } /// Whether `id` is currently rendered (not closed, not on the start screen / @@ -457,10 +464,13 @@ impl OpenCADStudio { side: crate::app::config::DockSide, gi: usize, ) -> Vec { - self.dock.groups(side)[gi] - .panels - .iter() - .copied() + // A stale index (a message from before the layout changed) reads as + // an empty group rather than a crash. + self.dock + .groups(side) + .get(gi) + .into_iter() + .flat_map(|g| g.panels.iter().copied()) .filter(|id| self.dock_panel_visible(*id)) .collect() } @@ -563,8 +573,12 @@ impl OpenCADStudio { /// on an edge's icon strip it joins a group between its icons or starts a /// new group; over the shown group's pallets it stacks above / below the /// one under the pointer; on an empty edge it starts a group; anywhere - /// else it floats. - pub(crate) fn dock_drop_target(&self, p: iced::Point) -> crate::ui::dock::DropTarget { + /// else it floats, held at `grab` from its top-left corner. + pub(crate) fn dock_drop_target( + &self, + p: iced::Point, + grab: iced::Vector, + ) -> crate::ui::dock::DropTarget { use crate::app::config::DockSide; use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W}; let (ww, wh) = self.dock_workspace_size(); @@ -591,7 +605,7 @@ impl OpenCADStudio { side, index: at.unwrap_or(self.dock.groups(side).len()), }, - StripHit::Tab { group, index } => { + StripHit::Join { group, index } => { // Map the gap among open pallets to a position among // all of the group's pallets. let open = self.dock_group_visible(side, group); @@ -622,10 +636,6 @@ impl OpenCADStudio { } } } - let grab = match self.dock_drag { - Some(crate::ui::dock::DockDrag::Move { grab, .. }) => grab, - _ => iced::Vector::new(0.0, 0.0), - }; let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0)); let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0)); DropTarget::Float { x, y } diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 55972f488..ef513f202 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -34,7 +34,7 @@ impl OpenCADStudio { if let Some(side) = self.dock_edge_menu { if !self.dock_visible_groups(side).is_empty() { // Checked = open and docked on this edge. - let shown: Vec<_> = crate::ui::dock::PanelId::ALL + let checked: Vec<_> = crate::ui::dock::PanelId::ALL .iter() .map(|p| { let here = self.dock.location(*p).map(|(s, _)| s) == Some(side); @@ -55,7 +55,7 @@ impl OpenCADStudio { .on_right_press(Message::Dock(crate::ui::dock::DockMsg::EdgeMenu(None))) .into(), ); - layers.push(place_at(dock_edge_menu(side, &shown), x.max(0.0), plus_top)); + layers.push(place_at(dock_edge_menu(side, &checked), x.max(0.0), plus_top)); } } // While a panel is dragged, preview where it lands. @@ -173,10 +173,6 @@ impl OpenCADStudio { Some(DropTarget::Join { side: s, group, .. }) if s == side => Some(group), _ => None, }; - let tip_side = match side { - DockSide::Left => iced::widget::tooltip::Position::Right, - DockSide::Right => iced::widget::tooltip::Position::Left, - }; let mut items: Vec> = Vec::new(); for gi in self.dock_visible_groups(side) { let is_shown = shown == Some(gi); @@ -218,15 +214,13 @@ impl OpenCADStudio { .center_y(Length::Fixed(STRIP_CELL_H)), ) .on_press(Message::Dock(DockMsg::IconPress(id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(id)))) - .on_exit(Message::Dock(DockMsg::Hover(None))) + .on_enter(Message::Dock(DockMsg::Hover(id))) + .on_exit(Message::Dock(DockMsg::HoverEnd(id))) .interaction(iced::mouse::Interaction::Pointer); icons.push(cell.into()); } // The group grip: a band on the window side holding the edge bar. - // Explicit heights: the tooltip around the band sizes it to its - // content, so a `Fill` bar would stop short of the row's bottom. let bar_w = if lit { 5.0 } else { 3.0 }; let bar_inset = 5.0; let bar = container( @@ -275,15 +269,9 @@ impl OpenCADStudio { }), ) .on_press(Message::Dock(DockMsg::GroupGrab(side, gi))) - .on_enter(Message::Dock(DockMsg::GripHover(Some((side, gi))))) - .on_exit(Message::Dock(DockMsg::GripHover(None))) + .on_enter(Message::Dock(DockMsg::GripHover(side, gi))) + .on_exit(Message::Dock(DockMsg::GripHoverEnd(side, gi))) .interaction(iced::mouse::Interaction::Grab); - let band = iced::widget::tooltip( - band, - text(t!("Drag to move group")).size(10), - tip_side, - ) - .gap(4); let band = container(band) .width(Fill) .height(Length::Fixed(row_h)) @@ -292,7 +280,9 @@ impl OpenCADStudio { DockSide::Right => iced::alignment::Horizontal::Right, }); items.push( - container(stack![column(icons), band]) + // A stack is as tall as its first layer, so the icon column + // spans the whole row; the edge bar then centres on the row. + container(stack![column(icons).height(Length::Fixed(row_h)), band]) .width(Length::Fixed(DOCK_STRIP_W)) .height(Length::Fixed(row_h)) .style(move |theme: &Theme| container::Style { @@ -512,7 +502,7 @@ impl OpenCADStudio { // on its empty areas don't fall through to the drawing below. let panel = mouse_area(framed) .on_press(Message::Dock(DockMsg::FloatRaise(f.id))) - .on_enter(Message::Dock(DockMsg::Hover(Some(f.id)))) + .on_enter(Message::Dock(DockMsg::Hover(f.id))) .on_exit(Message::Dock(DockMsg::HoverExit)) .interaction(iced::mouse::Interaction::Idle); // A hidden panel keeps its title bar where it sits when shown. @@ -534,7 +524,7 @@ impl OpenCADStudio { ) -> Element<'_, Message> { use crate::app::config::DockSide; use crate::ui::dock::DropTarget; - let (ww, avail) = self.dock_workspace_size(); + let (ww, _) = self.dock_workspace_size(); let strip = crate::ui::dock::DOCK_STRIP_W; let tint = |side: DockSide, w: f32| -> Element<'_, Message> { let band = container(Space::new()) @@ -556,51 +546,20 @@ impl OpenCADStudio { .into() }; let mut layers: Vec> = Vec::new(); - let docked = match target { - DropTarget::Edge { side, index } => { - let mut after = self.dock.clone(); - after.dock(id, side, index); - Some((side, after)) - } - DropTarget::Join { side, group, index } => { - let mut after = self.dock.clone(); - after.join_group(id, side, group, index); - Some((side, after)) - } + let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); + let marks_side = match target { + DropTarget::Edge { side, .. } | DropTarget::Join { side, .. } => Some(side), DropTarget::Float { x, y } => { let (w, h) = self.dock_float_size(id); - layers.push(place_at( - dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), - x, - y, - )); + layers.push(place_at(dock_ghost(id, w + DOCK_FLOAT_BAR_W, h), x, y)); None } }; - // A whole group shows only the strip feedback and its ghost; the - // column keeps showing what is there. - let group_drag = self.dock_drag.and_then(|d| d.movement()).and_then(|(_, g, _)| g); - let docked = docked.map(|(side, after)| (side, group_drag.is_none().then_some(after))); - let marks_side = docked.as_ref().map(|(side, _)| *side); - if let Some((side, Some(after))) = docked { - // Lay the group out as it will be after the drop. - if let Some((_, gi)) = after.location(id) { - let group = &after.groups(side)[gi]; - let open: Vec = (0..group.panels.len()) - .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) - .collect(); - let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); - let spans = crate::ui::dock::slot_spans(&weights, avail); - let at = open - .iter() - .position(|i| group.panels[*i] == id) - .expect("dropped panel"); - let (top, bottom) = spans[at]; - let w = group - .panels - .iter() - .map(|p| after.width(*p, self.win_size.0)) - .fold(0.0, f32::max); + // A single pallet also gets a ghost where it lands in the column; a + // whole group shows only the strip feedback, as the column keeps + // showing what is there. + if let (Some(side), None) = (marks_side, group_drag) { + if let Some((top, bottom, w)) = self.dock_landing(id, target) { let x = match side { DockSide::Left => strip, DockSide::Right => (ww - w - strip).max(0.0), @@ -627,6 +586,37 @@ impl OpenCADStudio { .into() } + /// Where docked pallet `id` would sit after dropping on `target`: its + /// span (top, bottom) in its group as laid out after the drop, and the + /// group's width. `None` for a floating target. + fn dock_landing( + &self, + id: crate::ui::dock::PanelId, + target: crate::ui::dock::DropTarget, + ) -> Option<(f32, f32, f32)> { + use crate::ui::dock::DropTarget; + let mut after = self.dock.clone(); + match target { + DropTarget::Edge { side, index } => { + after.dock(id, side, index); + } + DropTarget::Join { side, group, index } => { + after.join_group(id, side, group, index); + } + DropTarget::Float { .. } => return None, + } + let (side, gi) = after.location(id)?; + let group = &after.groups(side)[gi]; + let open: Vec = (0..group.panels.len()) + .filter(|i| group.panels[*i] == id || self.dock_panel_visible(group.panels[*i])) + .collect(); + let weights: Vec = open.iter().map(|i| group.weights[*i]).collect(); + let at = open.iter().position(|i| group.panels[*i] == id)?; + let (_, avail) = self.dock_workspace_size(); + let (top, bottom) = *crate::ui::dock::slot_spans(&weights, avail).get(at)?; + Some((top, bottom, after.group_width_px(side, gi, self.win_size.0))) + } + /// The icon strip's drop marks for `target` on `side`: a short line with /// end caps between a group's icons (join that group there), or a /// full-width line on a divider with a "+ New group" badge. @@ -767,13 +757,10 @@ fn dock_icon_ghost(icons: &[crate::ui::dock::PanelId]) -> Element<'static, Messa .into() } -/// Canvas that draws a label rotated 90° (for a collapsed panel's bar). +/// Canvas that draws a floating title bar's label, reading upward from the +/// bar's bottom end. struct VBarLabel { text: String, - clockwise: bool, - /// Start the text at the reading-start end of the bar instead of - /// centring it. - from_start: bool, } impl canvas::Program for VBarLabel { @@ -790,27 +777,15 @@ impl canvas::Program for VBarLabel { let mut frame = canvas::Frame::new(renderer, bounds.size()); frame.with_save(|frame| { frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0)); - frame.rotate(iced::Radians(if self.clockwise { - std::f32::consts::FRAC_PI_2 - } else { - -std::f32::consts::FRAC_PI_2 - })); - // After the rotation the text runs along local +x, so the - // reading start sits at -height/2. - let (position, align_x) = if self.from_start { - ( - iced::Point::new(-bounds.height / 2.0, 0.0), - iced::advanced::text::Alignment::Left, - ) - } else { - (iced::Point::ORIGIN, iced::advanced::text::Alignment::Center) - }; + frame.rotate(iced::Radians(-std::f32::consts::FRAC_PI_2)); + // After the rotation the text runs along local +x (upward), so + // the bar's bottom end sits at -height/2. frame.fill_text(canvas::Text { content: self.text.clone(), - position, + position: iced::Point::new(-bounds.height / 2.0, 0.0), color: theme.palette().background.base.text.scale_alpha(0.72), size: iced::Pixels(13.0), - align_x, + align_x: iced::advanced::text::Alignment::Left, align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() @@ -840,9 +815,7 @@ fn floating_title_bar( let close = crate::ui::dock::close_button(id, tip); let pin = crate::ui::dock::pin_button(id, auto_collapse, tip); let title = canvas(VBarLabel { - text: id.title().to_string(), - clockwise: false, - from_start: true, + text: t!(id.title()).into_owned(), }) .width(Fill) .height(Fill); @@ -875,8 +848,8 @@ fn floating_title_bar( .into() } -/// Grabbable separator for a docked panel managed by the general dock. Same -/// visual as the previous per-panel divider but emits generic dock messages. +/// Grabbable separator between a docked pallet and the viewport: drag to +/// size its group, double-click to reset the width. fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> { let line = container(Space::new()) .width(Length::Fixed(DOCK_DIVIDER_W)) @@ -900,13 +873,13 @@ const DOCK_MENU_W: f32 = 230.0; /// The pallet menu of an edge's + button, styled like the right-click menu: /// one row per pallet with its icon, its name and a check mark when it is -/// open on this edge. `shown` holds each pallet with that state. +/// open on this edge. `checked` holds each pallet with that state. fn dock_edge_menu( side: crate::app::config::DockSide, - shown: &[(crate::ui::dock::PanelId, bool)], + checked: &[(crate::ui::dock::PanelId, bool)], ) -> Element<'static, Message> { use crate::ui::dock::DockMsg; - let rows: Vec> = shown + let rows: Vec> = checked .iter() .map(|(id, checked)| { let id = *id; @@ -918,7 +891,7 @@ fn dock_edge_menu( button( row![ crate::ui::icons::themed(id.icon(), 16.0), - text(id.title()).size(12).width(Fill), + text(t!(id.title())).size(12).width(Fill), check, ] .spacing(8) @@ -991,7 +964,7 @@ fn dock_splitter( /// its name as the header. fn dock_ghost(id: crate::ui::dock::PanelId, w: f32, h: f32) -> Element<'static, Message> { let header = container( - text(id.title()) + text(t!(id.title())) .size(12) .wrapping(iced::widget::text::Wrapping::None) .ellipsis(iced::advanced::text::Ellipsis::End), diff --git a/src/app/node_graph.rs b/src/app/node_graph.rs index 3af60f9d7..a915e8cbe 100644 --- a/src/app/node_graph.rs +++ b/src/app/node_graph.rs @@ -173,11 +173,7 @@ impl OpenCADStudio { // The node library docks on the right on first use; after // that it stays wherever the user moved it. let id = crate::ui::dock::PanelId::NodeGraph; - if !self.dock.is_placed(id) { - self.dock - .dock(id, crate::app::config::DockSide::Right, usize::MAX); - } - self.dock_reveal(id); + self.dock_open_at_default(id); return self.graph_run(i); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2da46524e..9ef3b5e9f 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2580,12 +2580,7 @@ impl OpenCADStudio { Message::ToggleXrefManager => { self.show_external_references ^= true; if self.show_external_references { - use crate::app::config::DockSide; - use crate::ui::dock::PanelId; - if !self.dock.is_placed(PanelId::ExternalReferences) { - self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); - } - self.dock_reveal(PanelId::ExternalReferences); + self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences); self.refresh_xref_manager(); } Task::none() diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 58ece0e82..be422776d 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -26,10 +26,12 @@ pub enum DockMsg { AutoCollapseToggle(PanelId), /// Close / hide `panel`. Close(PanelId), - /// The pointer entered (`Some`) or left (`None`) a pallet's strip icon or - /// floating frame: highlights the icon and, where the pallet auto-hides, - /// reveals it. - Hover(Option), + /// The pointer entered a pallet's strip icon or floating frame: + /// highlights the icon and, where the pallet auto-hides, reveals it. + Hover(PanelId), + /// The pointer left that icon or frame. Carries the pallet so a late + /// "left" never clears the hover of the element just entered. + HoverEnd(PanelId), /// Pointer moved while a panel is dragging or resizing. DragMove(iced::Point), /// Pointer released after a drag / resize. @@ -45,8 +47,10 @@ pub enum DockMsg { /// Begin resizing floating `panel` from its corner grip; `true` for the /// bottom-left grip (a panel whose title bar is on the right). FloatResizeGrab(PanelId, bool), - /// The pointer entered (`Some`) or left (`None`) a docked title bar. - TitleHover(Option), + /// The pointer entered a docked title bar (shows its pin and close). + TitleHover(PanelId), + /// The pointer left that title bar. + TitleHoverEnd(PanelId), /// Open (`Some`) the pallet menu of an edge's icon strip (its + button), /// or close it (`None`). EdgeMenu(Option), @@ -55,9 +59,10 @@ pub enum DockMsg { EdgeMenuToggle(DockSide, PanelId), /// Begin dragging a whole group by its edge band in the icon strip. GroupGrab(DockSide, usize), - /// The pointer entered (`Some`) or left (`None`) a group's edge band. - GripHover(Option<(DockSide, usize)>), - + /// The pointer entered a group's edge band (side, group). + GripHover(DockSide, usize), + /// The pointer left that edge band. + GripHoverEnd(DockSide, usize), /// Double-click on a docked title bar: float the panel. FloatOut(PanelId), /// Double-click on a floating title bar: dock the panel on that side. @@ -305,6 +310,18 @@ pub struct FloatPanel { pub h: f32, } +impl FloatPanel { + /// Keep a placement read from disk usable: finite, on the workspace's + /// positive side, and at least the minimum size. + fn heal(&mut self) { + let finite_or = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + self.x = finite_or(self.x, 0.0).max(0.0); + self.y = finite_or(self.y, 0.0).max(0.0); + self.w = finite_or(self.w, self.id.default_width()).clamp(DOCK_MIN_W, self.id.max_width()); + self.h = finite_or(self.h, FLOAT_MIN_H).max(FLOAT_MIN_H); + } +} + /// Smallest floating panel height. pub const FLOAT_MIN_H: f32 = 160.0; /// Smallest on-screen height of a stacked pallet while dragging a splitter. @@ -407,11 +424,13 @@ impl DockState { let groups = self.groups_mut(side); for g in groups.iter_mut() { g.heal(); - let keep: Vec = g.panels.iter().map(|id| seen.insert(*id)).collect(); - let mut k = keep.iter(); - g.weights.retain(|_| *k.next().expect("parallel")); - let mut k = keep.iter(); - g.panels.retain(|_| *k.next().expect("parallel")); + (g.panels, g.weights) = g + .panels + .iter() + .zip(&g.weights) + .filter(|(id, _)| seen.insert(**id)) + .map(|(id, w)| (*id, *w)) + .unzip(); } groups.retain(|g| !g.panels.is_empty()); let len = groups.len(); @@ -419,6 +438,7 @@ impl DockState { self.show_group(side, shown); } self.floating.retain(|f| seen.insert(f.id)); + self.floating.iter_mut().for_each(FloatPanel::heal); } /// Where (if anywhere) a panel is docked: its side and group index. @@ -451,9 +471,7 @@ impl DockState { /// Docked width for `id`, clamped to sane bounds. pub fn width(&self, id: PanelId, win_w: f32) -> f32 { - self.settings(id) - .width - .clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W)) + on_screen_width(self.settings(id).width, id.max_width(), win_w) } /// The floating auto-hide flag of `id` (a docked pallet follows its @@ -509,9 +527,12 @@ impl DockState { } /// Group `group`'s docked width: its own once sized, else the widest of - /// its pallets' own widths. + /// its pallets' own widths. A group that no longer exists reads as the + /// minimum width. pub fn group_width(&self, side: DockSide, group: usize) -> f32 { - let g = &self.groups(side)[group]; + let Some(g) = self.groups(side).get(group) else { + return DOCK_MIN_W; + }; if g.width > 0.0 { return g.width; } @@ -523,9 +544,10 @@ impl DockState { /// The widest group `group` may be: the most any of its pallets allows. fn group_max_width(&self, side: DockSide, group: usize) -> f32 { - self.groups(side)[group] - .panels - .iter() + self.groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) .map(|id| id.max_width()) .fold(DOCK_MIN_W, f32::max) } @@ -533,8 +555,11 @@ impl DockState { /// Group `group`'s on-screen width in a `win_w` wide window, clamped like /// [`Self::width`]. pub fn group_width_px(&self, side: DockSide, group: usize, win_w: f32) -> f32 { - let max = self.group_max_width(side, group).min(win_w * 0.45).max(DOCK_MIN_W); - self.group_width(side, group).clamp(DOCK_MIN_W, max) + on_screen_width( + self.group_width(side, group), + self.group_max_width(side, group), + win_w, + ) } /// Size group `group` on `side`, clamped. @@ -547,9 +572,11 @@ impl DockState { /// Give group `group` the widest default width of its pallets. pub fn reset_group_width(&mut self, side: DockSide, group: usize) { - let width = self.groups(side)[group] - .panels - .iter() + let width = self + .groups(side) + .get(group) + .into_iter() + .flat_map(|g| g.panels.iter()) .map(|id| id.default_width()) .fold(DOCK_MIN_W, f32::max); self.set_group_width(side, group, width); @@ -566,12 +593,17 @@ impl DockState { return Some((side, gi, pos, weight, false)); } self.groups_mut(side).remove(gi); - // Keep showing the same group, or its neighbour when it went away. + self.after_group_removed(side, gi); + Some((side, gi, pos, weight, true)) + } + + /// Group `gi` was taken off `side`: keep showing the same group, or its + /// neighbour when the shown group itself went away. + fn after_group_removed(&mut self, side: DockSide, gi: usize) { let shown = self.shown(side); if shown > gi || (shown == gi && shown > 0 && shown >= self.groups(side).len()) { self.show_group(side, shown - 1); } - Some((side, gi, pos, weight, true)) } /// Dock `id` as a new group of its own on `side` at insertion `index` @@ -640,12 +672,10 @@ impl DockState { index -= 1; } } - let shown_here = self.shown(side); let g = self.groups_mut(side).remove(group); - if side != to && shown_here > group { - self.show_group(side, shown_here - 1); - } else if side != to && shown_here >= self.groups(side).len() && shown_here > 0 { - self.show_group(side, shown_here - 1); + // Moving within an edge shows the moved group below anyway. + if side != to { + self.after_group_removed(side, group); } let dest = self.groups_mut(to); let index = index.min(dest.len()); @@ -814,6 +844,12 @@ pub fn portion(weight: f32) -> u16 { (weight * 1000.0).round().clamp(1.0, u16::MAX as f32) as u16 } +/// `width` clamped to what fits on screen: at least [`DOCK_MIN_W`], at most +/// `max` and 45 % of a `win_w` wide window. +fn on_screen_width(width: f32, max: f32, win_w: f32) -> f32 { + width.clamp(DOCK_MIN_W, max.min(win_w * 0.45).max(DOCK_MIN_W)) +} + /// Smallest docked width a panel may be dragged or sized to. pub const DOCK_MIN_W: f32 = 200.0; /// Largest docked width a panel may be dragged or sized to. @@ -889,8 +925,8 @@ pub fn strip_layout(groups: &[(usize, usize)]) -> (Vec, f32) { pub enum StripHit { /// A new group before group `group`; `None` = after the last group. NewGroup(Option), - /// Group `group`, at position `index` among its open pallets. - Tab { group: usize, index: usize }, + /// Join group `group`, at position `index` among its open pallets. + Join { group: usize, index: usize }, } /// Classify a pointer at strip height `y`: on a divider (or the gap above @@ -904,7 +940,7 @@ pub fn strip_hit(y: f32, layout: &[StripGroup]) -> StripHit { if y < g.icons_bottom() - STRIP_NEW_GROUP_SLOP { let index = (((y - g.icons_top) / STRIP_CELL_H).round().max(0.0) as usize) .min(g.icons); - return StripHit::Tab { + return StripHit::Join { group: g.group, index, }; @@ -961,8 +997,8 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, ) .on_press(Message::Dock(DockMsg::DockGrab(id))) .on_double_click(Message::Dock(DockMsg::FloatOut(id))) - .on_enter(Message::Dock(DockMsg::TitleHover(Some(id)))) - .on_exit(Message::Dock(DockMsg::TitleHover(None))) + .on_enter(Message::Dock(DockMsg::TitleHover(id))) + .on_exit(Message::Dock(DockMsg::TitleHoverEnd(id))) .interaction(iced::mouse::Interaction::Grab) .into() } @@ -1355,17 +1391,17 @@ mod tests { // Top half of the first icon: into group 0 at the top. assert_eq!( strip_hit(g0.icons_top + 10.0, &layout), - StripHit::Tab { group: 0, index: 0 } + StripHit::Join { group: 0, index: 0 } ); // Between the first and second icon of group 0. assert_eq!( strip_hit(g0.icons_top + STRIP_CELL_H + 4.0, &layout), - StripHit::Tab { group: 0, index: 1 } + StripHit::Join { group: 0, index: 1 } ); // Lower half of the last icon: into group 0 at the end. assert_eq!( strip_hit(g0.icons_bottom() - 8.0, &layout), - StripHit::Tab { group: 0, index: 3 } + StripHit::Join { group: 0, index: 3 } ); // On the divider between the groups: a new group before group 2. assert_eq!(strip_hit(g0.bottom(), &layout), StripHit::NewGroup(Some(2)));