From f16403a8517f1fb8b1f37a367e3c6eca15b2d83b Mon Sep 17 00:00:00 2001 From: BFlat Date: Wed, 7 Oct 2026 21:09:56 +0700 Subject: [PATCH] Fix #293: the dock's double arrow collapses its panels to icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The » at the top of the right dock was only painted. Its header strip is now a click target (hover highlight, "Collapse to Icons" tooltip) that runs the new window.collapseDock UI command: the Properties | Layers | Libraries group hides and its three panels become icons at the top of the icon column, under a « strip ("Expand Panels") that expands them again. While collapsed, those icons and window.panel pop the panel out next to the column like the other icon panels (dock-width flyout, Layers and Properties given a height that keeps them on screen); expanding with one popped out shows its tab. The flyout now follows the icon column's real left edge (recorded each frame) instead of assuming a 300-point dock, so it also tracks a resized or collapsed dock. The collapsed state is kept with the UI preferences and in user workspaces; the built-in workspaces expand the dock. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dock.rs | 410 +++++++++++++++++++++++----- crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/menus.rs | 51 ++-- crates/ui-egui/src/state.rs | 26 +- crates/ui-egui/src/workspaces.rs | 4 + docs/control-protocol.md | 7 + 9 files changed, 414 insertions(+), 94 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d329c038..61c4bb9b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -95,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. + - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index db7e3567..232f3b4f 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,109 +1,200 @@ //! The right dock: Properties | Layers | Libraries tabs, plus the collapsed icon-panel column -//! whose panels pop out to the left. +//! whose panels pop out to the left. The double arrow at the top of the dock collapses the tabbed +//! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column +//! then expands it again. -use egui::{CornerRadius, Sense, Stroke, Ui, vec2}; +use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2}; +use serde_json::json; use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS}; use crate::theme::{self, Tokens}; use crate::{VectorcraftApp, icons, panels, widgets}; const ICON_COL: f32 = 38.0; +/// Height of the strip along the top of a dock column that carries its double arrow. +const HEADER: f32 = 14.0; +/// The tabbed group's width until the dock has been laid out (its default size). +const DOCK_WIDTH: f32 = 300.0; +/// Top of a popped-out panel. +const FLYOUT_TOP: f32 = 110.0; + +/// Where the icon column's left edge was drawn last (egui temp memory, one frame old). +fn column_left_id() -> egui::Id { + egui::Id::new("dock-icon-column-left") +} + +/// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while +/// one of its panels is popped out shows that panel's tab instead. +pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { + app.ui.dock_collapsed = collapsed; + app.ui.dock = true; + if !collapsed && let Some(tab) = app.ui.open_panel.as_deref().and_then(DockTab::from_id) { + app.ui.dock_tab = tab; + app.ui.open_panel = None; + } +} + +/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons, +/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked. +fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click()); + ui.painter().rect_filled(hdr, 0.0, t.tab_strip); + let arrow = Rect::from_min_size(hdr.right_top() + vec2(-16.0, 1.0), vec2(12.0, 12.0)); + if resp.hovered() { + ui.painter().rect_filled(arrow, CornerRadius::same(2), t.hover); + } + let icon = if collapsed { "chevrons-left" } else { "chevrons-right" }; + icons::paint(ui, icon, arrow.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text }); + resp.on_hover_text(if collapsed { tl!("Expand Panels") } else { tl!("Collapse to Icons") }).clicked() +} + +/// Run `window.collapseDock` for a click on a double arrow. +fn toggle(app: &mut VectorcraftApp, collapsed: bool) { + // It only fails on bad params, which this never sends; show it all the same. + if let Err(e) = app.run("window.collapseDock", json!({ "collapsed": collapsed })) { + app.ui.status = e; + } +} + +/// One icon of the column: a click pops its panel out (or puts it away). +fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, label: &str, icon: &str) { + let open = app.ui.open_panel.as_deref() == Some(id); + if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } +} pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - // Main tabbed group. - egui::Panel::right("dock") - .resizable(true) - .default_size(300.0) - .size_range(230.0..=520.0) - .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) - .show(ui, |ui| { - // Tab strip. - let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); - ui.painter().rect_filled(hdr, 0.0, t.tab_strip); - icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text); - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.tab_strip); - ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); - let mut x = strip.left(); - for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] { - let active = app.ui.dock_tab == tab; - let galley = - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim }); - let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0)); - let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click()); - if active { - ui.painter().rect_filled(r, 0.0, t.panel); + // Main tabbed group, unless it is collapsed to icons. + if !app.ui.dock_collapsed { + egui::Panel::right("dock") + .resizable(true) + .default_size(DOCK_WIDTH) + .size_range(230.0..=520.0) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) + .show(ui, |ui| { + if collapse_header(ui, false) { + toggle(app, true); } - ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - if resp.clicked() { - app.ui.dock_tab = tab; + // Tab strip. + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); + ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); + let mut x = strip.left(); + for tab in DockTab::ALL { + let (_, label, _) = tab.info(); + let active = app.ui.dock_tab == tab; + let galley = + ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim }); + let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0)); + let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click()); + if active { + ui.painter().rect_filled(r, 0.0, t.panel); + } + ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); + if resp.clicked() { + app.ui.dock_tab = tab; + } + x = r.right(); } - x = r.right(); - } - let menu_id = match app.ui.dock_tab { - DockTab::Properties => "properties", - DockTab::Layers => "layers", - DockTab::Libraries => "libraries", - }; - panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0))); - egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { - DockTab::Properties => { - egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); - } - DockTab::Layers => panels::layers::show(app, ui), - DockTab::Libraries => panels::libraries(app, ui), + let menu_id = app.ui.dock_tab.info().0; + panels::panel_menu(app, ui, menu_id, egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0))); + egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab { + DockTab::Properties => { + egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui)); + } + DockTab::Layers => panels::layers::show(app, ui), + DockTab::Libraries => panels::libraries(app, ui), + }); }); - }); - // Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). - egui::Panel::right("icon_column") + } + // Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A + // collapsed group's panels head it, under the « that expands them again. + let collapsed = app.ui.dock_collapsed; + let column = egui::Panel::right("icon_column") .resizable(false) .exact_size(ICON_COL) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border))) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border))) .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - // The icons scroll in a window too short for them. - widgets::strip_scroll(ui, "icon_column", |ui| { - for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { - if gi > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); - } - for id in group.iter() { - let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; - let open = app.ui.open_panel.as_deref() == Some(*id); - if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + if collapsed && collapse_header(ui, true) { + toggle(app, false); + } + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + // The icons scroll in a window too short for them. + widgets::strip_scroll(ui, "icon_column", |ui| { + if collapsed { + for tab in DockTab::ALL { + let (id, label, icon) = tab.info(); + panel_icon(app, ui, id, label, icon); } } - } + for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { + if gi > 0 || collapsed { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter() + .line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); + } + for id in group.iter() { + let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; + panel_icon(app, ui, id, label, icon); + } + } + }); }); }); + let left = column.response.rect.left(); + ui.ctx().data_mut(|d| d.insert_temp(column_left_id(), left)); } -/// An icon panel popped out next to the icon column. +/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels +/// while the dock is collapsed. pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(id) = app.ui.open_panel.clone() else { return }; - let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return }; + let tab = DockTab::from_id(&id); + let label = match tab { + Some(tab) if app.ui.dock_collapsed => tab.info().1, + // Expanded, the group shows its panels in the dock itself. + Some(_) => return, + None => match ICON_PANELS.iter().find(|p| p.0 == id) { + Some((_, label, _)) => *label, + None => return, + }, + }; let t = Tokens::get(ctx); let screen = ctx.content_rect(); - // Pinned by its right edge, 6 points left of the icon column: a panel wider than its 256 - // points grows towards the canvas rather than over the panel icons. - let right = screen.right() - ICON_COL - 300.0 - 6.0; + // Pinned by its right edge, 6 points left of the icon column wherever the dock's width (or its + // collapsed state) put it: content wider than the flyout grows it towards the canvas rather + // than over the panel icons. Without a dock on screen it keeps to the window's right edge. + let column = if app.ui.dock && app.ui.screen_mode < 3 { + ctx.data(|d| d.get_temp::(column_left_id())) + .filter(|x| x.is_finite() && *x > screen.left() && *x <= screen.right()) + .unwrap_or_else(|| screen.right() - ICON_COL - if app.ui.dock_collapsed { 0.0 } else { DOCK_WIDTH }) + } else { + screen.right() + }; + let right = column - 6.0; + // The tabbed group's panels fill their height (Layers) or scroll (Properties): give them one + // that keeps the flyout on screen. + // The tabbed group's panels keep the width the dock gives them; icon panels are 256 points. + let width = if tab.is_some() { DOCK_WIDTH } else { 256.0 }; + let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0); let mut open = true; let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP); - area.fixed_pos(egui::pos2(right, 110.0)).show(ctx, |ui| { + area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { - ui.set_width(256.0); - let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); + ui.set_width(width); + let (strip, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker); - let tab = egui::Rect::from_min_size( + let title = egui::Rect::from_min_size( strip.min, vec2(ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0), ); - ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); - ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text); + ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); + ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, tl!(label), theme::semibold(12.0), t.text); let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click()); icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim }); @@ -113,8 +204,26 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { let menu_r = egui::Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0)); panels::panel_menu(app, ui, &id, menu_r); egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - ui.set_width(236.0); - panels::show_icon_panel(app, ui, &id); + ui.set_width(width - 20.0); + match tab { + Some(DockTab::Properties) => { + // At least `tall` when the sections are taller: the flyout's area keeps the + // size its content needs, and egui sizes a new area at most 400 points high, + // so a scroll area that only took the room it was given never grew past it. + egui::ScrollArea::vertical() + .id_salt("props-flyout") + .max_height(tall) + .min_scrolled_height(tall) + .auto_shrink([false, true]) + .show(ui, |ui| panels::properties::show(app, ui)); + } + Some(DockTab::Layers) => { + ui.set_max_height(tall); + panels::layers::show(app, ui); + } + Some(DockTab::Libraries) => panels::libraries(app, ui), + None => panels::show_icon_panel(app, ui, &id), + } }); }); }); @@ -179,4 +288,157 @@ mod tests { } assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last()); } + + const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0); + + /// A frame of the dock and its flyout on a 1600 × 1000 window. + struct Harness { + app: VectorcraftApp, + ctx: egui::Context, + time: f64, + } + + impl Harness { + fn new() -> Self { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut h = Self { app, ctx, time: 0.0 }; + h.settle(); + h + } + + fn frame(&mut self, events: Vec) { + self.time += 0.05; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, SCREEN); + let input = egui::RawInput { time: Some(self.time), events, screen_rect: Some(screen), ..Default::default() }; + let app = &mut self.app; + self.ctx + .run_ui(input, |ui| { + show(app, ui); + floating_panel(app, ui.ctx()); + }) + .textures_delta + .clear(); + } + + fn settle(&mut self) { + for _ in 0..3 { + self.frame(vec![]); + } + } + + fn click(&mut self, at: egui::Pos2) { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + self.frame(vec![egui::Event::PointerMoved(at), button(true)]); + self.frame(vec![button(false)]); + self.settle(); + } + + /// The icon column's left edge as last drawn. + fn column_left(&self) -> f32 { + self.ctx.data(|d| d.get_temp::(column_left_id())).unwrap() + } + + /// The panel icons of the column, top to bottom. + fn icons(&self) -> Vec { + widget_rects(&self.ctx, vec2(30.0, 30.0)) + } + + /// The double arrow at the top right of the window (the dock's, or the collapsed column's). + fn arrow(&self) -> egui::Pos2 { + egui::pos2(SCREEN.x - 10.0, HEADER / 2.0) + } + } + + fn icon_panel_count() -> usize { + ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum() + } + + #[test] + fn the_double_arrow_collapses_the_dock_to_icons_and_back() { + let mut h = Harness::new(); + assert!(!h.app.ui.dock_collapsed); + // Expanded, the group (at least its 230-point minimum) is right of the column. + let expanded = |left: f32| left <= SCREEN.x - ICON_COL - 230.0; + assert!(expanded(h.column_left()), "expanded: the group is right of the column: {}", h.column_left()); + assert_eq!(h.icons().len(), icon_panel_count()); + h.click(h.arrow()); + assert!(h.app.ui.dock_collapsed, "the » collapses the dock"); + assert!(h.column_left() > SCREEN.x - ICON_COL - 2.0, "collapsed: only the icon column is left: {}", h.column_left()); + let icons = h.icons(); + assert_eq!(icons.len(), icon_panel_count() + 3, "Properties, Layers and Libraries became icons"); + assert!(icons[0].top() > HEADER, "the icons sit under the « strip"); + h.click(h.arrow()); + assert!(!h.app.ui.dock_collapsed, "the « expands the dock again"); + assert!(expanded(h.column_left()), "the group is back: {}", h.column_left()); + assert_eq!(h.icons().len(), icon_panel_count()); + } + + #[test] + fn a_collapsed_panel_pops_out_left_of_the_column() { + let mut h = Harness::new(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.settle(); + // Properties, Layers, Libraries head the column. + let layers = h.icons()[1]; + h.click(layers.center()); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + h.settle(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + let column = h.column_left(); + assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}"); + assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen"); + assert!(rect.height() > 200.0, "Layers gets room for its list: {rect:?}"); + // Its » puts it away. + h.click(egui::pos2(rect.right() - 13.0, rect.top() + 13.0)); + assert_eq!(h.app.ui.open_panel, None, "the flyout's » closes it"); + // Expanding with a panel popped out shows its tab. + h.click(layers.center()); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); + h.click(h.arrow()); + assert!(!h.app.ui.dock_collapsed); + assert_eq!((h.app.ui.open_panel.as_deref(), h.app.ui.dock_tab), (None, DockTab::Layers)); + // Properties pops out as wide as the dock and shows its sections, not a sliver, also as + // the first flyout of a session (a fresh harness: the flyouts share one area, which + // would otherwise keep the size the Layers flyout gave it). + let mut h = Harness::new(); + h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + h.app.run("window.panel", json!({"panel": "properties"})).unwrap(); + h.settle(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}"); + // Taller than the 400 points egui sizes a new area at: its sections (Align, Quick + // Actions) aren't cut off below Appearance. + assert!(rect.height() > 450.0 && rect.bottom() <= SCREEN.y, "Properties flyout {rect:?}"); + } + + #[test] + fn the_collapse_command_sets_or_toggles_the_dock() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(true)); + assert!(app.ui.dock_collapsed && crate::menus::checked(&app, "window.collapseDock", &json!({})) == Some(true)); + assert_eq!(app.run("window.collapseDock", json!({"collapsed": true})).unwrap(), json!(true)); + assert!(app.run("window.collapseDock", json!({"collapsed": "yes"})).is_err()); + // Window › Layers pops Layers out of its icon while collapsed. + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("layers")); + assert_eq!(crate::menus::checked(&app, "window.panel", &json!({"panel": "layers"})), Some(true)); + assert_eq!(app.run("window.collapseDock", json!({})).unwrap(), json!(false)); + assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Layers)); + app.run("window.panel", json!({"panel": "properties"})).unwrap(); + assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_tab), (None, DockTab::Properties)); + // Saved with the preferences and with a user workspace; the built-in ones expand it. + app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); + let saved: crate::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert!(saved.sanitized().dock_collapsed); + app.run("window.workspace.new", json!({"name": "Icons"})).unwrap(); + app.run("window.workspace", json!({"name": "Essentials"})).unwrap(); + assert!(!app.ui.dock_collapsed, "Essentials shows the panels"); + app.run("window.workspace", json!({"name": "Icons"})).unwrap(); + assert!(app.ui.dock_collapsed, "the user workspace keeps them collapsed"); + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 67f1a732..e1148c8a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -569,3 +569,6 @@ Make Compound Path Vytvořit složenou cestu Release Compound Path Uvolnit složenou cestu Select All Vybrat vše + Collapse Panels to Icons Sbalit panely na ikony + Collapse to Icons Sbalit na ikony + Expand Panels Rozbalit panely diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b5737666..c3d3f913 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -569,3 +569,6 @@ Make Compound Path 複合パスを作成 Release Compound Path 複合パスを解除 Select All すべてを選択 + Collapse Panels to Icons パネルをアイコンに折りたたむ + Collapse to Icons アイコンに折りたたむ + Expand Panels パネルを展開 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c46b8dc8..555af1f2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -362,6 +362,8 @@ CMYK CMYK CMYK Color CMYK 色彩 CMYK colours print with their own values; off, they go through the colour settings too. CMYK 顏色以其本身的數值列印;關閉時,也會經過色彩設定處理。 + Collapse Panels to Icons 將面板收合為圖示 + Collapse to Icons 收合為圖示 Collate 逐份列印 Collect for Export 收集以供匯出 Collect in New Layer 收集至新圖層 @@ -816,6 +818,7 @@ Expand Gradient Mesh 展開漸層網格 Expand Gradient To 展開漸層為 Expand Options 展開選項 + Expand Panels 展開面板 Expand Repeat 展開重複 Expand Shape 展開形狀 Expand: 展開: diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0a002796..ed5c0268 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -172,6 +172,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.taskBar", "Contextual Task Bar", "", "{}"), ("window.dock", "Panels", "Tab", "{} show/hide all panels"), ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"), + ( + "window.collapseDock", + "Collapse Panels to Icons", + "", + "{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"), ("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"), @@ -895,27 +901,35 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + let collapsed = match p.get("collapsed") { + None | Some(Value::Null) => !app.ui.dock_collapsed, + Some(Value::Bool(b)) => *b, + Some(_) => return Some(Err("collapsed must be true or false".into())), + }; + crate::dock::set_collapsed(app, collapsed); + Ok(json!(collapsed)) + } "window.panel" => { let panel = s("panel").unwrap_or_default(); - match panel.as_str() { - "properties" => { - app.ui.dock_tab = DockTab::Properties; - Ok(Value::Null) - } - "layers" => { - app.ui.dock_tab = DockTab::Layers; - Ok(Value::Null) - } - "libraries" => { - app.ui.dock_tab = DockTab::Libraries; - Ok(Value::Null) - } - p if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => { + match (panel.as_str(), DockTab::from_id(&panel)) { + // A collapsed dock pops the panel out of its icon, like the icon panels. + (p, Some(tab)) if app.ui.dock_collapsed => { + app.ui.dock_tab = tab; app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) }; app.ui.dock = true; Ok(json!({"open": app.ui.open_panel})) } - other => Err(format!("unknown panel `{other}`")), + (_, Some(tab)) => { + app.ui.dock_tab = tab; + Ok(Value::Null) + } + (p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => { + app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) }; + app.ui.dock = true; + Ok(json!({"open": app.ui.open_panel})) + } + (other, None) => Err(format!("unknown panel `{other}`")), } } "window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) { @@ -1195,13 +1209,12 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "window.taskBar" => app.ui.task_bar, "window.panel" => { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - match panel { - "properties" => app.ui.dock_tab == DockTab::Properties, - "layers" => app.ui.dock_tab == DockTab::Layers, - "libraries" => app.ui.dock_tab == DockTab::Libraries, + match DockTab::from_id(panel) { + Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab, _ => app.ui.open_panel.as_deref() == Some(panel), } } + "window.collapseDock" => app.ui.dock_collapsed, "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), "view.slices.lock" => app.session.slices_locked(), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 380ee327..874c6e0b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -76,6 +76,24 @@ pub enum DockTab { Libraries, } +impl DockTab { + /// The tab's panel id (`window.panel`), English label and icon (when the dock is collapsed). + pub fn info(self) -> (&'static str, &'static str, &'static str) { + match self { + DockTab::Properties => ("properties", "Properties", "dc-options"), + DockTab::Layers => ("layers", "Layers", "layers"), + DockTab::Libraries => ("libraries", "Libraries", "library"), + } + } + + pub const ALL: [DockTab; 3] = [DockTab::Properties, DockTab::Layers, DockTab::Libraries]; + + /// The tab whose panel id is `id`. + pub fn from_id(id: &str) -> Option { + DockTab::ALL.into_iter().find(|t| t.info().0 == id) + } +} + /// Panels that live as collapsed icons in the dock (Essentials Classic). pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("color", "Color", "palette"), @@ -219,7 +237,12 @@ pub struct UiState { pub legacy_language: Option, pub brightness: Brightness, pub dock_tab: DockTab, - /// Icon panel currently popped out of the collapsed column. + /// The dock's tabbed group (Properties | Layers | Libraries) is collapsed to icons at the top of + /// the icon column (the dock's double arrow, `window.collapseDock`). + #[serde(default)] + pub dock_collapsed: bool, + /// Icon panel currently popped out of the collapsed column (also `properties`, `layers` or + /// `libraries` while the dock is collapsed). pub open_panel: Option, pub control_bar: bool, pub toolbar: bool, @@ -358,6 +381,7 @@ impl Default for UiState { legacy_language: None, brightness: Brightness::MediumDark, dock_tab: DockTab::Properties, + dock_collapsed: false, open_panel: None, control_bar: false, toolbar: true, diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 472f4074..97f52c25 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -23,6 +23,8 @@ pub struct Workspace { pub task_bar: bool, pub dock: bool, pub dock_tab: DockTab, + /// The dock's tabbed group is collapsed to icons (built-in workspaces: expanded). + pub dock_collapsed: bool, pub open_panel: Option, pub status_bar: bool, } @@ -115,6 +117,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { task_bar: ui.task_bar, dock: ui.dock, dock_tab: ui.dock_tab, + dock_collapsed: ui.dock_collapsed, open_panel: ui.open_panel.clone(), status_bar: ui.status_bar, } @@ -130,6 +133,7 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.task_bar = w.task_bar; ui.dock = w.dock; ui.dock_tab = w.dock_tab; + ui.dock_collapsed = w.dock_collapsed; ui.open_panel = w.open_panel.clone(); ui.status_bar = w.status_bar; ui.flyout = None; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a4c19aa1..d8e7a9eb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -180,6 +180,13 @@ a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Col Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run `window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items. +Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the +Properties | Layers | Libraries group and puts its three panels as icons at the top of the icon column, under a « +that expands them again. While collapsed, those icons and `window.panel {panel: "properties"|"layers"|"libraries"}` +pop the panel out next to the column like the other icon panels (`ui.dock_collapsed`, `ui.open_panel` in +`ui.inspect`); expanding with one popped out shows its tab. The state is saved with the preferences and in user +workspaces; the built-in workspaces expand the dock. + Flatten Transparency: `ui.flattenTransparencyDialog` opens the `flattenTransparency` dialog for the selection (fields `preset`: a preset name, setting it loads that preset's options; the option keys of `object.flattenTransparency`: `balance` 0–100, `lineArtPpi` and `gradientPpi` 1–2400, `textToOutlines`,