diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b7cc85c1b9..a797cfba04 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -612,6 +612,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index e8d53fc50f..799c8da2f7 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -613,6 +613,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e22dcd9cac..5b4913d552 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -51,6 +51,8 @@ pub(crate) struct Runtime { raise: Option, /// The panel the user last clicked or pressed in (a tab, a panel body, a drop). focused: Option, + /// Hidden panels shown from the window's edges ([`peek`]). + peek: peek::State, } /// Every docking control the main window drew last frame with its rect. Controls in floating @@ -72,6 +74,7 @@ pub(crate) fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "hidden": app.ui.docking_hidden.keys().collect::>(), "autoCollapse": app.session.prefs.auto_collapse_icon_panels, "panelsShown": app.ui.dock && app.ui.screen_mode < 3, + "peek": peek::shown(app), "hitRects": hits, }) } @@ -322,6 +325,7 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Opt match id { "window.panel" => return Some(show_panel(app, params)), "window.collapseDock" => return Some(crate::menus::opt_bool(params, "collapsed").and_then(|c| collapse_dock(app, c))), + "window.panels.peek" => return Some(peek::command(app, params)), "window.panel.collapse" => { let minimized = match crate::menus::opt_bool(params, "collapsed") { Ok(c) => c, @@ -1167,6 +1171,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if modal || egui::Popup::is_any_open(&ctx) { blocked.push(ctx.content_rect()); } + // Panels hidden by Tab or Shift+Tab show on the side of the window edge the pointer rests on. + let peeking = peek::update(app, &ctx, ui.available_rect_before_wrap(), &layout, !blocked.is_empty()).map(|edge| peek::edge_panels(&layout, edge)); + let shown_from_edge = |panel: &String| peeking.as_ref().is_some_and(|panels| panels.contains(panel)); let style = dock_style(ui); let mut dock = craft_ui::docking::DockArea::new(area) .blocked_rects(&blocked) @@ -1187,7 +1194,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { // Tab and Shift+Tab (`window.dock`, `window.panels`) and the full screen modes hide the panels; // the canvas stays. if !app.ui.dock || app.ui.screen_mode >= 3 { - dock = dock.hide_panels(HidePanels::All); + dock = dock.hide_panels(if peeking.is_some() { HidePanels::Except(&shown_from_edge) } else { HidePanels::All }); } if let Some(panel) = app.dock_rt.raise.take() { dock = dock.raise_panel(panel); @@ -1220,7 +1227,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.open_dropped_files(window.dropped, None, shift); } forward_keys(app, &ctx, output.forwarded_events, output.forwarded_modifiers); - if !thumbnail_panel_showing(&app.ui, &ctx) { + if peeking.is_none() && !thumbnail_panel_showing(&app.ui, &ctx) { // No panel can ask for these rows again this pass. Keep the cache of open documents, but // discard worker jobs queued behind the one already rendering. crate::panels::layers::show_thumbs_of(None); @@ -1383,8 +1390,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { /// Draw a registered panel body, fitting untouched flyouts and pinning sized panels' footers. fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { - crate::toolbar::show_beside_canvas(self.app, ui); - self.app.canvas_area(ui); + peek::canvas(self.app, ui, |app, ui| { + crate::toolbar::show_beside_canvas(app, ui); + app.canvas_area(ui); + }); return; } let floating = ui.ctx().viewport_id() != egui::ViewportId::ROOT; @@ -1513,6 +1522,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +mod peek; + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panel_docking/peek.rs b/crates/ui-egui/src/panel_docking/peek.rs new file mode 100644 index 0000000000..baf147f4fd --- /dev/null +++ b/crates/ui-egui/src/panel_docking/peek.rs @@ -0,0 +1,248 @@ +//! Panels hidden by Tab or Shift+Tab, shown for a while: resting the pointer on the left or right +//! edge of the dock area shows the docked panels on that side of the canvas +//! (`window.panels.peek`). They hide again once the pointer has been away from them for +//! [`GRACE`] seconds; Tab or Shift+Tab shows them for good. The canvas keeps the size it has with +//! every panel hidden, so the panels cover its edge and the artwork stays where it is. Floating +//! panels, the Tools panel and the Control bar stay hidden. + +use craft_ui::docking::{Layout, Node}; +use craft_ui::layout::SplitAxis; +use serde_json::{Value, json}; + +use super::CANVAS; +use crate::VectorcraftApp; + +/// Width of the band along each edge of the dock area that shows the panels on that side. +pub(crate) const EDGE: f32 = 6.0; +/// How long the pointer rests on an edge band before the panels show, in seconds. +pub(crate) const DELAY: f64 = 0.3; +/// How long the pointer stays away from the panels shown before they hide, in seconds. +pub(crate) const GRACE: f64 = 0.3; + +/// A side of the canvas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Edge { + Left, + Right, +} + +impl Edge { + fn id(self) -> &'static str { + match self { + Edge::Left => "left", + Edge::Right => "right", + } + } +} + +/// The panels shown on one side. +#[derive(Clone, Copy, Debug)] +struct Shown { + edge: Edge, + /// When the pointer left them; `None` while it is over them or has not moved since they showed. + left_at: Option, +} + +/// The session's state of the panels shown from the edges. +pub(crate) struct State { + /// Whether Tab or Shift+Tab hides the panels this frame. + hidden: bool, + /// The dock area this frame. + area: egui::Rect, + /// The edge band the pointer rests on, and since when. + resting: Option<(Edge, f64)>, + shown: Option, + /// How far the canvas's corners lay from the dock area's with every panel hidden. + canvas: Option<(egui::Vec2, egui::Vec2)>, + /// The part of the canvas left uncovered while panels show. + uncovered: Option, +} + +impl Default for State { + fn default() -> Self { + Self { hidden: false, area: egui::Rect::NOTHING, resting: None, shown: None, canvas: None, uncovered: None } + } +} + +/// The docked panels on `edge`'s side of the canvas, at any depth of horizontal splits. +pub(crate) fn edge_panels(layout: &Layout, edge: Edge) -> Vec { + let canvas = CANVAS.to_string(); + let tree = |node: &Node| Layout { root: Some(node.clone()), floating: Vec::new() }; + let mut out = Vec::new(); + let mut node = layout.root.as_ref(); + let mut depth = 0usize; + while let Some(Node::Split { axis, first, second, .. }) = node { + depth = depth.saturating_add(1); + if depth > 2 * craft_ui::docking::MAX_PANELS { + break; + } + let (inner, outer, side) = if tree(first).contains(&canvas) { (first, second, Edge::Right) } else { (second, first, Edge::Left) }; + if matches!(axis, SplitAxis::Horizontal) && side == edge { + out.extend(tree(outer).panels().into_iter().filter(|p| **p != canvas).cloned()); + } + node = Some(inner); + } + out +} + +/// The side whose panels show this frame. `area` is the dock area and `blocked` whether a modal +/// dialog or a popup takes the input. +pub(super) fn update(app: &mut VectorcraftApp, ctx: &egui::Context, area: egui::Rect, layout: &Layout, blocked: bool) -> Option { + let hidden = !app.ui.dock && app.ui.screen_mode < 3; + let flyout = app.ui.open_panel.is_some(); + // The docked Tools panel's rect, read only while Tools shows (Tab turns `toolbar` off). + let tools = app.ui.toolbar.then(|| ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id()))).flatten(); + let st = &mut app.dock_rt.peek; + st.hidden = hidden; + st.area = area; + if !hidden { + st.resting = None; + st.shown = None; + st.uncovered = None; + return None; + } + let (now, pointer, down, moved) = ctx.input(|i| { + // A release counts as moving away: a press may have held the panels while it left them. + let moved = + i.pointer.delta() != egui::Vec2::ZERO || i.pointer.any_released() || i.events.iter().any(|e| matches!(e, egui::Event::PointerGone)); + (i.time, i.pointer.hover_pos(), i.pointer.any_down(), moved) + }); + // A workspace switched to while panels show may dock none on that side. + if st.shown.is_some_and(|shown| edge_panels(layout, shown.edge).is_empty()) { + st.shown = None; + st.uncovered = None; + } + if let Some(shown) = st.shown.as_mut() { + // The panels shown sit beside the canvas, level with it. + let over = pointer.is_some_and(|p| { + let uncovered = st.uncovered.unwrap_or_else(|| area.shrink2(egui::vec2(EDGE, 0.0))); + area.contains(p) + && (uncovered.top()..=uncovered.bottom()).contains(&p.y) + && match shown.edge { + Edge::Left => p.x < uncovered.left(), + Edge::Right => p.x > uncovered.right(), + } + }); + if over || down || blocked || flyout { + shown.left_at = None; + } else if moved || pointer.is_none() { + shown.left_at.get_or_insert(now); + } + match shown.left_at { + Some(left) if now - left >= GRACE => { + st.shown = None; + st.uncovered = None; + // A flyout opened from the panels shown closes with them. + app.ui.open_panel = None; + return None; + } + Some(left) => ctx.request_repaint_after_secs((GRACE - (now - left)) as f32), + None => {} + } + return Some(shown.edge); + } + let edge = pointer.filter(|p| area.contains(*p) && !tools.is_some_and(|r| r.contains(*p))).and_then(|p| { + if p.x <= area.left() + EDGE { + Some(Edge::Left) + } else if p.x >= area.right() - EDGE { + Some(Edge::Right) + } else { + None + } + }); + let Some(edge) = edge.filter(|e| !down && !blocked && !edge_panels(layout, *e).is_empty()) else { + st.resting = None; + return None; + }; + let since = match st.resting { + Some((rested, since)) if rested == edge => since, + _ => now, + }; + if now - since >= DELAY { + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + return Some(edge); + } + st.resting = Some((edge, since)); + ctx.request_repaint_after_secs((DELAY - (now - since)) as f32); + // The strip the pointer rests on. + let band = match edge { + Edge::Left => egui::Rect::from_min_max(area.left_top(), egui::pos2(area.left() + EDGE, area.bottom())), + Edge::Right => egui::Rect::from_min_max(egui::pos2(area.right() - EDGE, area.top()), area.right_bottom()), + }; + let t = crate::theme::Tokens::get(ctx); + let layer = egui::LayerId::new(egui::Order::Foreground, super::area_id().with("peek-strip")); + ctx.layer_painter(layer).rect_filled(band, 0.0, t.accent.gamma_multiply(0.6)); + None +} + +/// The side shown, for `ui.docking`. +pub(super) fn shown(app: &VectorcraftApp) -> Option<&'static str> { + app.dock_rt.peek.shown.map(|s| s.edge.id()) +} + +/// Draw the canvas panel's body with `draw`. While panels show from an edge, the canvas keeps the +/// rect it has with every panel hidden, drawn under the panels and taking input only where they +/// leave it uncovered. +pub(super) fn canvas(app: &mut VectorcraftApp, ui: &mut egui::Ui, draw: impl FnOnce(&mut VectorcraftApp, &mut egui::Ui)) { + let uncovered = ui.max_rect(); + let st = &mut app.dock_rt.peek; + let wide = if !st.hidden { + st.canvas = None; + None + } else if st.shown.is_none() { + st.canvas = Some((uncovered.min - st.area.min, uncovered.max - st.area.max)); + None + } else { + st.uncovered = Some(uncovered); + // The canvas's insets in the dock area with every panel hidden, kept as the window resizes; + // none before such a frame was drawn, as it fills the area then. + let (min, max) = st.canvas.unwrap_or((egui::Vec2::ZERO, egui::Vec2::ZERO)); + Some(egui::Rect::from_min_max(st.area.min + min, st.area.max + max)) + }; + match wide { + Some(full) if full != uncovered && full.is_positive() => { + let clip = uncovered.intersect(ui.clip_rect()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(full)); + child.set_clip_rect(clip); + draw(app, &mut child); + } + _ => draw(app, ui), + } +} + +/// `window.panels.peek {side: left|right|null}`: show the docked panels on that side while Tab or +/// Shift+Tab hides the panels, as resting the pointer on that edge does, or (null) hide them. +pub(super) fn command(app: &mut VectorcraftApp, params: &Value) -> Result { + let edge = match params.get("side") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s == "left" => Some(Edge::Left), + Some(Value::String(s)) if s == "right" => Some(Edge::Right), + Some(_) => return Err("`side` must be left, right or null".into()), + }; + if app.ui.screen_mode >= 3 { + return Err("panels can't be shown in Presentation Mode".into()); + } + if app.ui.dock { + return Err("the panels are not hidden".into()); + } + match edge { + Some(edge) => { + if edge_panels(&super::layout_of(&app.ui), edge).is_empty() { + return Err(format!("no panels are docked on the {} side", edge.id())); + } + let st = &mut app.dock_rt.peek; + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + Ok(json!(edge.id())) + } + None => { + let st = &mut app.dock_rt.peek; + if st.shown.take().is_some() { + st.uncovered = None; + app.ui.open_panel = None; + } + Ok(Value::Null) + } + } +} diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index befffb1a9e..2c95ff0e16 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -468,6 +468,200 @@ fn tab_hides_the_panels_and_the_canvas_keeps_drawing() { assert!(h.tab("properties").is_some()); } +/// Hide the panels with Tab and settle; returns the canvas rect with every panel hidden. +fn hide_with_tab(h: &mut Harness) -> Rect { + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.canvas_rect.expect("the canvas draws") +} + +/// Frames with the pointer still, for `secs` of the harness clock. +fn wait(h: &mut Harness, secs: f64) { + let until = h.time + secs; + while h.time < until { + h.frame(vec![]); + } +} + +#[test] +fn resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + let edge = pos2(SCREEN.right() - 2.0, 500.0); + h.frame(vec![Event::PointerMoved(edge)]); + wait(&mut h, peek::DELAY / 2.0); + assert!(h.tab("properties").is_none(), "the panels wait for the pointer to rest"); + wait(&mut h, peek::DELAY); + let tab = h.tab("properties").expect("resting on the edge shows the right-hand panels"); + assert!(h.hit(&Hit::Icon("swatches".into())).is_some(), "the icon column shows with them"); + assert!(!h.app.ui.dock, "the panels stay hidden for good"); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas keeps its size, so the art stays where it is"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], json!("right")); + h.frame(vec![Event::PointerMoved(tab.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "they stay while the pointer is over them"); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE / 2.0); + assert!(h.tab("properties").is_some(), "a short trip away keeps them"); + wait(&mut h, peek::GRACE); + assert!(h.tab("properties").is_none(), "away from them, they hide again"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn a_press_on_the_canvas_while_the_panels_show_from_the_edge_does_not_reach_the_art_under_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + h.frame(vec![]); + fn selected(app: &VectorcraftApp) -> bool { + !app.session.active().unwrap().selection.is_empty() + } + assert!(selected(&h.app)); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + let tab = h.tab("properties").expect("the right-hand panels show"); + assert!(tab.left() < wide.right(), "they cover the canvas's right edge"); + assert_eq!(h.app.canvas_rect, Some(wide)); + // A click in the panels, where the canvas lies under them, leaves the selection. + h.click(pos2(tab.center().x, wide.bottom() - 40.0)); + assert!(selected(&h.app), "the canvas under the panels takes no click"); + // A click on the uncovered canvas away from the art deselects it, as it always does. + h.click(pos2(wide.left() + 40.0, wide.bottom() - 40.0)); + assert!(!selected(&h.app)); +} + +#[test] +fn the_canvas_keeps_the_hidden_panels_size_when_the_window_resizes_or_the_panels_show_at_once() { + let mut h = Harness::new(); + // Shown before any frame has drawn the panels hidden. + h.app.run("window.dock", json!({})).unwrap(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_some()); + let wide = h.app.canvas_rect.unwrap(); + h.app.run("window.panels.peek", json!({"side": null})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas had the size it has with every panel hidden"); + // A narrower window while they show. + h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(); + let narrow = Rect::from_min_max(SCREEN.min, pos2(SCREEN.right() - 200.0, SCREEN.bottom())); + for _ in 0..3 { + h.time += 0.05; + h.run(egui::RawInput { time: Some(h.time), screen_rect: Some(narrow), ..Default::default() }); + } + assert!(h.tab("properties").is_some()); + let canvas = h.app.canvas_rect.unwrap(); + assert!((canvas.width() - (wide.width() - 200.0)).abs() < 1.0, "{canvas:?} follows the window, under the panels"); +} + +#[test] +fn panels_held_by_a_press_hide_after_its_release_away_from_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + let tab = h.tab("properties").expect("the panels show"); + let body = pos2(tab.center().x, wide.bottom() - 40.0); + h.frame(vec![Event::PointerMoved(body), button(body, true)]); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "a held button keeps them"); + h.frame(vec![button(wide.center(), false)]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_none(), "released away from them, they hide"); +} + +#[test] +fn a_drag_to_the_edge_or_a_pointer_on_the_left_edge_shows_nothing() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + // The default layout docks no panel on the left. + h.frame(vec![Event::PointerMoved(pos2(SCREEN.left() + 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none()); + // A drag that reaches the right edge holds a button down. + h.frame(vec![Event::PointerMoved(wide.center()), button(wide.center(), true)]); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none(), "a drag never shows them"); + h.frame(vec![button(pos2(SCREEN.right() - 2.0, 500.0), false)]); +} + +#[test] +fn tab_while_the_panels_show_from_the_edge_shows_them_for_good() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.app.ui.dock && h.tab("properties").is_some()); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn shift_tab_hidden_panels_show_from_the_edge_too() { + let mut h = Harness::new(); + h.app.run("window.panels", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); +} + +#[test] +fn window_panels_peek_rejects_what_it_cannot_show_and_changes_nothing() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "the panels are not hidden"); + hide_with_tab(&mut h); + for side in [json!("up"), json!(1), json!(true), json!("Right")] { + assert_eq!(h.app.run("window.panels.peek", json!({"side": side})).unwrap_err(), "`side` must be left, right or null", "{side}"); + } + assert_eq!(h.app.run("window.panels.peek", json!({"side": "left"})).unwrap_err(), "no panels are docked on the left side"); + assert_eq!(h.app.run("window.panels.peek", json!({})).unwrap(), Value::Null, "nothing to hide"); + h.frame(vec![]); + assert!(h.tab("properties").is_none() && !h.app.ui.dock); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + assert!(h.tab("properties").is_some(), "the panels stay while the pointer has not moved"); + assert_eq!(h.app.run("window.panels.peek", json!({"side": null})).unwrap(), Value::Null); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.ui.screen_mode = 3; + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "panels can't be shown in Presentation Mode"); + h.app.ui.screen_mode = 0; +} + +#[test] +fn edge_panels_are_the_docked_panels_on_each_side_of_the_canvas() { + let layout = default_from(DockTab::ALL[0], &[], false, None, None); + let right = peek::edge_panels(&layout, peek::Edge::Right); + assert!(right.contains(&"properties".to_string()) && right.contains(&"swatches".to_string())); + assert!(!right.contains(&CANVAS.to_string())); + assert!(peek::edge_panels(&layout, peek::Edge::Left).is_empty()); + let mirrored = Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(310.0), + first: Box::new(Node::Tabs { panels: vec!["layers".to_string()], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + }), + floating: Vec::new(), + }; + assert_eq!(peek::edge_panels(&mirrored, peek::Edge::Left), ["layers"]); + assert!(peek::edge_panels(&mirrored, peek::Edge::Right).is_empty()); +} + #[test] fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { let mut h = Harness::new(); diff --git a/docs/mcp.md b/docs/mcp.md index c292c66685..73f5f1e959 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1832,6 +1832,15 @@ and the action still plays. `Session::replay` plays a journal the same way. The hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the Control bar stay as they are. +While Tab or Shift+Tab hides the panels, resting the pointer for 0.3 s on a 6 pt band along the left or right edge of +the area below the bars shows the panels docked on that side of the canvas, over the canvas's edge; the canvas keeps its +size, so the art does not move. They hide again once the pointer has been away from them for 0.3 s, and stay while a +flyout opened from them, a menu or a pressed button is open. Floating panels, the toolbar and the Control bar stay +hidden, and Tab or Shift+Tab shows the panels for good. `window.panels.peek {side: "left" | "right"}` shows them as the +pointer does, `{side: null}` hides them, and `ui.docking`'s `peek` names the side shown (null for none). The command +refuses another side, a side with no docked panel, panels that are not hidden and Presentation Mode, and changes +nothing. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes. They accept a key that only another format takes and do not use it: `{"format": "pdf", "quality": 80}` writes the PDF, and the reply's `warnings` name the key diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a6d730daa7..0fae606e0e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -384,6 +384,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | +| Panels hidden by Tab or Shift+Tab show for a while when the pointer rests on the left or right edge of the window, over the canvas, and hide again when it moves away; Tab shows them for good | done | Part of #5661; `window.panels.peek` ([Workspace basics](https://web.archive.org/web/2014/https://helpx.adobe.com/illustrator/using/workspace-basics.html), "Hide or show all panels"); VectorCraft's own choices: the 6 pt band and the 0.3 s delays, only the docked panels on that side show (floating panels, Tools and the Control bar stay hidden), and nothing shows in Presentation Mode; `panel_docking::tests::resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves` and the tests after it | | Behaviour | Status | Notes | |---|---|---|