From 8db84c54f11edd36b378a672776da2f742010634 Mon Sep 17 00:00:00 2001 From: prekabreki Date: Tue, 13 Oct 2026 21:17:34 +0200 Subject: [PATCH 001/160] craft-ui: a tab dragged out of the window floats where it left On X11 (including XWayland) and Windows, the pointer leaving the window reaches egui as PointerGone, and egui-winit then drops the button release because it has no pointer position. finish_drag never saw the drag end: the panel stayed in its dock with the drop outline following the pointer until the next click inside the window. Leaving the window now ends the drag as a drop. interact_pos still holds the last position on that frame, and the panel floats there (clamped to the window) instead of docking into whatever target sits at the edge. Test: tab_dragged_out_of_the_window_floats_where_it_left. It fails without the fix (no action at all). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit aecdf819ba753df0092eef73633706d3f6ce1f24) --- crates/ui/src/docking/surface.rs | 12 ++++++++---- crates/ui/src/docking/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index b0e5badd..764cfc76 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -829,8 +829,12 @@ impl Renderer<'_, P> { fn finish_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; - let (pointer, down, released, focused) = - ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused)); + let (pointer, down, released, focused, gone) = + ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer())); + // Leaving the window is the drop: on X11 and Windows the release outside never reaches + // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the + // last position this frame, and the panel floats there rather than docking at the edge. + let released = released || gone; let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { cancel_drag::

(ctx, self.id); @@ -854,10 +858,10 @@ impl Renderer<'_, P> { if pointer.distance(drag.origin) < 2.0 { return; } - let action = if let Some(proposal) = proposal { + let action = if let Some(proposal) = proposal.filter(|_| !gone) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) } else if (self.permissions)(&drag.panel).floatable - && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel))) + && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel))) { let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 54d37df6..11936502 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -395,6 +395,30 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { assert_eq!(panels.get(*active), Some(&5)); } +/// On X11 and Windows a release outside the window never reaches egui (egui-winit drops a +/// button event once the pointer has left), so a tab dragged out of the window used to stay on +/// the pointer for good. Leaving the window detaches the panel where it left, even over a dock +/// target, and ends the drag. +#[test] +fn tab_dragged_out_of_the_window_floats_where_it_left() { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, Vec::new()); + } + let tab = ctx.read_response(Id::new("test-area").with(("tab", &3u32))).unwrap().rect.center(); + let exit = pos2(4.0, 300.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(-8.0, 0.0))]); + step(&ctx, &mut layout, vec![Event::PointerMoved(exit)]); + let actions = step(&ctx, &mut layout, vec![Event::PointerGone]); + assert!(actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{actions:?}"); + assert!(layout.floating.iter().any(|g| g.panels == [3]), "{layout:?}"); + let back = pos2(400.0, 300.0); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(back)]).is_empty()); + assert!(step(&ctx, &mut layout, vec![pointer_button(back, false)]).is_empty(), "the drag ended when the pointer left"); +} + #[test] fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() { let layout = Layout { From 9cbc0888ce3f7c7e41598bb21eff96f6627b91d9 Mon Sep 17 00:00:00 2001 From: euisuh <55032583+euisuh@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:22:24 +0900 Subject: [PATCH 002/160] fix(ui): end floating group drags on pointer exit Discard a renderer-owned group gesture when its pointer disappears so a missing outside release cannot resume dragging, while retaining touch drops. Closes #95 Co-Authored-By: OpenAI Codex (cherry picked from commit 9a6f159fc5c2fca4773109ca124705cf289c07fc) --- crates/ui/src/docking/surface.rs | 9 ++- crates/ui/src/docking/tests.rs | 102 +++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 764cfc76..c5715e93 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -812,11 +812,16 @@ impl Renderer<'_, P> { } let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); - if escape || !focused || !down { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + // A button release outside the window may never reach egui. + let has_pointer = ctx.input(|i| i.pointer.has_pointer()); + // Touch-up also removes the pointer, but still completes a drop. + let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); + if escape || !focused || !down || !has_pointer { + cancel_drag::

(ctx, self.id); if escape || !focused { self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); } else if released + && (has_pointer || touch_ended) && let Some(pointer) = pointer.filter(|p| p.is_finite()) && !self.blockers.iter().any(|r| r.contains(pointer)) { diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 11936502..474a01a0 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -667,6 +667,108 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { assert_eq!(layout.floating[0].panels, vec![6]); } +#[test] +fn floating_group_drag_ends_on_release_or_pointer_exit() { + for (released, leaves) in [(false, true), (true, true), (true, false)] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let start = pos2(86.0, 82.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + let moved = start + vec2(50.0, 40.0); + let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(moved)]); + assert!(actions.iter().any(|action| matches!(action, Action::MoveFloating { .. }))); + let last = layout.clone(); + let mut events = vec![]; + if released { + events.push(pointer_button(moved, false)); + } + if leaves { + events.push(Event::PointerGone); + } + let (out, _) = frame(&ctx, &layout, events, true); + assert_eq!(out.group_drop, if leaves { None } else { Some((5, moved)) }); + for action in out.actions { + layout.apply(action).unwrap(); + } + assert_eq!(layout, last); + for pointer in [moved + vec2(20.0, 20.0), moved + vec2(30.0, 30.0)] { + let (out, _) = frame(&ctx, &layout, vec![Event::PointerMoved(pointer)], true); + assert!(out.actions.is_empty(), "{out:?}"); + assert!(out.group_drag.is_none() && out.group_drop.is_none()); + } + assert!(out.group_drag.is_none()); + assert_eq!(ctx.dragged_id(), None); + } +} + +#[test] +fn floating_group_touch_release_reports_drop_after_pointer_gone() { + for web_order in [false, true] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let touch = |phase, pos| Event::Touch { device_id: egui::TouchDeviceId(0), id: egui::TouchId(1), phase, pos, force: None }; + let start = pos2(86.0, 82.0); + let press = pointer_button(start, true); + let events = if web_order { + vec![press, touch(egui::TouchPhase::Start, start)] + } else { + vec![touch(egui::TouchPhase::Start, start), Event::PointerMoved(start), press] + }; + step(&ctx, &mut layout, events); + let moved = start + vec2(50.0, 40.0); + let events = if web_order { + vec![Event::PointerMoved(moved), touch(egui::TouchPhase::Move, moved)] + } else { + vec![touch(egui::TouchPhase::Move, moved), Event::PointerMoved(moved)] + }; + assert!(step(&ctx, &mut layout, events).iter().any(|action| matches!(action, Action::MoveFloating { .. }))); + // egui-winit emits Touch before release/gone; eframe web emits it afterwards. + let events = if web_order { + vec![pointer_button(moved, false), Event::PointerGone, touch(egui::TouchPhase::End, moved)] + } else { + vec![touch(egui::TouchPhase::End, moved), pointer_button(moved, false), Event::PointerGone] + }; + let (out, _) = frame(&ctx, &layout, events, true); + assert!(!ctx.input(|i| i.pointer.has_pointer())); + assert_eq!(out.group_drop, Some((5, moved))); + assert!(out.group_drag.is_none()); + assert_eq!(ctx.dragged_id(), None); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(moved + vec2(30.0, 30.0))]).is_empty()); + } +} + +#[test] +fn floating_group_exit_with_escape_or_focus_loss_restores_original_geometry() { + for escape in [false, true] { + let ctx = Context::default(); + let mut layout = sample(); + let original = layout.clone(); + for _ in 0..2 { + step(&ctx, &mut layout, vec![]); + } + let start = pos2(86.0, 82.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); + assert_ne!(layout.floating[0].rect, original.floating[0].rect); + let mut events = vec![Event::PointerGone]; + if escape { + events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + } + let (out, _) = frame(&ctx, &layout, events, escape); + assert!(out.group_drag.is_none() && out.group_drop.is_none()); + for action in out.actions { + layout.apply(action).unwrap(); + } + assert_eq!(layout.floating[0].rect, original.floating[0].rect); + } +} + struct CustomContent { labels: Vec, headers: usize, From 5ac5d26be183a10a201db3a1c324b7426c4ba4f3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 00:48:41 +0200 Subject: [PATCH 003/160] Truncate dock tab labels that do not fit their tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tab squeezed below its natural width painted its whole label centred and only clipped it, so long names ran over the tab's edges and under its neighbours. The label is now cut with '…' inside the same padding the natural width is measured with, and a cut tab names its panel in full on hover. (cherry picked from commit 5c28add6cfc2d381959773a31e2fc501024f1744) --- crates/ui/src/docking/surface.rs | 13 +++++++-- crates/ui/src/docking/tests.rs | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c5715e93..0cf54891 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -9,6 +9,9 @@ use crate::{ use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop}; +/// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. +const TAB_PADDING: f32 = 24.0; + /// Application-supplied chrome. Body renderers retain their own UI style and contents. #[derive(Clone, Debug)] pub struct DockStyle { @@ -609,7 +612,7 @@ impl Renderer<'_, P> { .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) .size() .x - + 24.0 + + TAB_PADDING }) .collect(); let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); @@ -671,12 +674,15 @@ impl Renderer<'_, P> { let policy = (self.permissions)(panel); let label = (self.label)(panel); let color = if active { self.style.text } else { self.style.inactive_text }; + // A tab squeezed below its natural width cuts its label with '…' inside the padding its + // natural width was measured with, instead of letting it run under its neighbours. let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( ui, - Some(egui::TextWrapMode::Extend), - f32::INFINITY, + Some(egui::TextWrapMode::Truncate), + (rect.width() - TAB_PADDING).max(0.0), self.style.font.clone(), ); + let elided = galley.elided; let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) .focus_stroke(Stroke::new(1.0, self.style.accent)) @@ -685,6 +691,7 @@ impl Renderer<'_, P> { painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); }); + let response = if elided { response.on_hover_text(&label) } else { response }; self.register_interaction(ui.ctx(), response.id); if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 474a01a0..38c9f9fc 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1216,3 +1216,52 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { ); } } + +#[test] +fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() { + let ctx = Context::default(); + ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); + let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![] }; + let label = |p: &u32| format!("Eigenschaften und Anpassungen {p}"); + let run = |events: Vec| { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 200.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + DockArea::new(Id::new("truncate")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + label, + |_| Permissions::default(), + |ui, _| { + ui.label("Body"); + }, + ); + }); + output.textures_delta.clear(); + output.shapes + }; + run(vec![]); + let shapes = run(vec![]); + let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).unwrap().rect; + for p in 1..=3 { + let tab = tab(p); + // The clip rect would hide an overflowing label; what is laid out must itself fit. + let painted: Vec = shapes + .iter() + .filter(|shape| matches!(&shape.shape, egui::Shape::Text(_))) + .map(|shape| shape.shape.visual_bounding_rect()) + .filter(|r| tab.contains(r.center())) + .collect(); + assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); + assert!(tab.contains_rect(painted[0]), "tab {p}: label {:?} overflows {tab:?}", painted[0]); + } + let at = tab(2).center(); + run(vec![Event::PointerMoved(at)]); + let shapes = run(vec![]); + let full = label(&2); + // An elided galley still reports its whole text, so the cut tab label alone would match. + assert!( + shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == full && !text.galley.elided)), + "a truncated tab names its panel in full on hover" + ); +} From c4b007706d3adb1b23ff5192eea0bad2a293428e Mon Sep 17 00:00:00 2001 From: NibrasAz7 Date: Wed, 14 Oct 2026 23:31:53 +0200 Subject: [PATCH 004/160] Docking: hide 'Move to group' when no panel accepts the move (lightcraft#1768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A movable panel's context menu always offered 'Move to group', even when the layout held no other panel that accepts tabs — one open panel showed an empty submenu. Layout gains move_targets() (the same filter the menu applies: every other panel that accepts_tabs), the menu renders only when it is non-empty, and a test pins the lone-panel and protected-anchor cases. (cherry picked from commit 671463c1af06015c0c7020450777343c39065be1) --- crates/ui/src/docking/model.rs | 6 ++++++ crates/ui/src/docking/surface.rs | 6 +++--- crates/ui/src/docking/tests.rs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index babb3e2f..a7056677 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -287,6 +287,12 @@ impl Layout

{ self.panels().contains(&panel) } + /// The panels `panel` can join as a tab: every other panel whose permissions accept tabs. + /// A "Move to group" menu with an empty answer has nothing to offer and stays hidden. + pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> { + self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect() + } + /// Capture a panel's nearest surviving group/split neighbor before hiding it. pub fn location(&self, panel: &P) -> Result, DockError> { self.validate()?; diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 0cf54891..53b81372 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -712,12 +712,12 @@ impl Renderer<'_, P> { ui.close(); } } - if policy.movable { + if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() { let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); let response = ui.menu_button(text, |ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for target in self.layout.panels() { - if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() { + for target in self.layout.move_targets(panel, self.permissions) { + if self.menu_label(ui, target).clicked() { self.output.actions.push(Action::Move { panel: panel.clone(), anchor: target.clone(), diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 38c9f9fc..941220db 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -51,6 +51,22 @@ fn detach_redock_merge_and_collapse_empty_source_containers() { assert_eq!(layout.panels().len(), 6); } +#[test] +fn move_targets_skip_the_panel_itself_and_protected_anchors() { + let layout = sample(); + let all = |_: &u32| Permissions::default(); + let targets = layout.move_targets(&1, all); + assert_eq!(targets.len(), 5); + assert!(!targets.contains(&&1)); + let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() }; + assert!(!layout.move_targets(&1, guarded).contains(&&3)); + + // A lone panel has nowhere to go — its "Move to group" menu hides + // (storytold/lightcraft#1768). + let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }; + assert!(single.move_targets(&1, all).is_empty()); +} + #[test] fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() { let layout = sample(); From 0ae7cddbb5fbf97aba98961873a96facb9a296cb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:13 +0000 Subject: [PATCH 005/160] docking: docks, columns, groups and floating stacks in the layout model Node::Dock holds columns of groups on a logical window edge (Side::Start or Side::End). Floating windows hold a stack of groups and an optional native rect; the v1 {panels, active, rect} shape still parses, and a window with one plain group writes it. Placement gains Group and Column, Target names where a group, column or window goes, and the new actions move, close, minimize, collapse and resize groups, columns and windows atomically. Location records group and column state so a panel closed from a dock or floating stack restores to the same group, seam, column or dock. Layout gains apply_all, closed_by and panel_state; Permissions gains locked. Node, Action, Placement and DockError are non_exhaustive. The renderer draws each floating window's groups stacked and a dock's columns side by side until the dock and floating-stack profiles land. --- crates/ui/src/docking/dock_model.rs | 573 +++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/model.rs | 572 +++++++++++++++++++++++--- crates/ui/src/docking/surface.rs | 41 +- crates/ui/src/docking/tests.rs | 32 +- crates/ui/tests/docking_workflows.rs | 4 +- 6 files changed, 1140 insertions(+), 84 deletions(-) create mode 100644 crates/ui/src/docking/dock_model.rs diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs new file mode 100644 index 00000000..d4200fe3 --- /dev/null +++ b/crates/ui/src/docking/dock_model.rs @@ -0,0 +1,573 @@ +//! Docks, columns, groups and floating stacks: their types and the structural edits behind +//! group, column and window moves. +//! +//! A group, column or floating window is named by a panel it contains. Edits run on a proposed +//! copy of the layout, so a helper that fails part-way leaves nothing behind. + +use std::hash::Hash; + +use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; +use crate::layout::{SplitAxis, SplitSize}; + +/// Width of a new column when nothing better is known. +pub(super) const DEFAULT_COLUMN_WIDTH: f32 = 240.0; +/// Rect of a new floating window when the caller gives none and the source has no size. +const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; + +/// The window edge a [`Node::Dock`] sits on, in reading order so right-to-left interfaces +/// mirror. `Start` is the edge of a horizontal split's first child (left in a left-to-right +/// layout); the renderer maps it to a physical edge from the layout direction. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Side { + Start, + End, +} + +/// Tabs that move, minimize and size together, in a dock column or a floating stack. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Group

{ + pub panels: Vec

, + pub active: usize, + /// Only the tab strip shows. `height` is kept for when the group expands again. + #[cfg_attr(feature = "serde", serde(default))] + pub minimized: bool, + /// Preferred height in points; `None` shares the free height of the column or stack. + #[cfg_attr(feature = "serde", serde(default))] + pub height: Option, +} + +impl

Group

{ + pub fn new(panels: Vec

, active: usize) -> Self { + Self { panels, active, minimized: false, height: None } + } + + pub fn active_panel(&self) -> Option<&P> { + self.panels.get(self.active) + } +} + +/// One column of a dock: groups from top to bottom. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Column

{ + pub groups: Vec>, + /// Expanded width in points. + pub width: f32, + /// Collapsed to one icon per panel. + #[cfg_attr(feature = "serde", serde(default))] + pub iconic: bool, + /// Width while iconic; labels show beside the icons when it is wide enough. + #[cfg_attr(feature = "serde", serde(default))] + pub iconic_width: Option, +} + +impl

Column

{ + pub fn new(groups: Vec>, width: f32) -> Self { + Self { groups, width, iconic: false, iconic_width: None } + } +} + +/// Where a group, column or floating window goes. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Target

{ + /// Relative to the panel `anchor`, which must not be part of what moves. + At { anchor: P, placement: Placement

}, + /// A new dock on an edge that has none (or an empty one). The root becomes the other side + /// of a horizontal split. + NewDock { side: Side }, + /// A new floating window. `None` keeps the source's size at a default position. + Float { rect: Option<[f32; 4]> }, +} + +/// What the Window menu shows for a panel. The model does not track the z-order of floating +/// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelState { + Hidden, + Open, + Front, +} + +/// The group a closed panel was in, when that group was in a dock or a floating stack. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GroupLocation { + pub minimized: bool, + pub height: Option, + /// The column, when the group was docked. + pub column: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ColumnLocation { + pub side: Side, + pub width: f32, + pub iconic: bool, + pub iconic_width: Option, +} + +/// Where a panel is. Paths lead from the root: false is a split's first child, true its second. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Place { + /// A `Tabs` or `Stack` node. + Node(Vec), + Dock { + path: Vec, + column: usize, + group: usize, + }, + Floating { + window: usize, + group: usize, + }, +} + +/// Which container a whole-container action names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Unit { + Group, + Column, + Window, +} + +/// Groups in transit, with the size and state of the container they came from. +pub(super) struct Moving

{ + pub groups: Vec>, + pub width: Option, + pub iconic: bool, + pub iconic_width: Option, + pub rect: Option<[f32; 4]>, + pub native: Option<[f32; 4]>, +} + +impl

Moving

{ + pub fn group(group: Group

) -> Self { + Self { groups: vec![group], width: None, iconic: false, iconic_width: None, rect: None, native: None } + } + + fn into_column(self, width: f32) -> Column

{ + Column { groups: self.groups, width: self.width.unwrap_or(width), iconic: self.iconic, iconic_width: self.iconic_width } + } +} + +pub(super) fn node_at<'a, P>(mut node: &'a Node

, path: &[bool]) -> Option<&'a Node

> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return None }; + node = if *second_child { second } else { first }; + } + Some(node) +} + +pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node

, path: &[bool]) -> Option<&'a mut Node

> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return None }; + node = if *second_child { second } else { first }; + } + Some(node) +} + +/// Insert `new` at `index` and select the member at `index + selected`. +fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, selected: usize) { + let index = index.min(panels.len()); + let count = new.len(); + panels.splice(index..index, new); + *active = index + selected.min(count.saturating_sub(1)); +} + +impl Layout

{ + pub(super) fn place(&self, panel: &P) -> Option { + for (w, window) in self.floating.iter().enumerate() { + if let Some(g) = window.groups.iter().position(|group| group.panels.contains(panel)) { + return Some(Place::Floating { window: w, group: g }); + } + } + let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return None; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Tabs { panels, .. } if panels.contains(panel) => return Some(Place::Node(path)), + Node::Stack { entries } if entries.iter().any(|e| &e.panel == panel) => return Some(Place::Node(path)), + Node::Dock { columns, .. } => { + for (c, column) in columns.iter().enumerate() { + if let Some(g) = column.groups.iter().position(|group| group.panels.contains(panel)) { + return Some(Place::Dock { path, column: c, group: g }); + } + } + } + Node::Tabs { .. } | Node::Stack { .. } => {} + } + } + None + } + + pub(super) fn dock_path(&self, side: Side) -> Option> { + let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return None; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Dock { side: s, .. } if *s == side => return Some(path), + _ => {} + } + } + None + } + + pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec>> { + match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { + Some(Node::Dock { columns, .. }) => Some(columns), + _ => None, + } + } + + pub(super) fn column(&self, place: &Place) -> Option<&Column

> { + let Place::Dock { path, column, .. } = place else { return None }; + match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { columns, .. }) => columns.get(*column), + _ => None, + } + } + + pub(super) fn column_mut(&mut self, place: &Place) -> Option<&mut Column

> { + let Place::Dock { path, column, .. } = place else { return None }; + self.columns_mut(path)?.get_mut(*column) + } + + /// The dock or floating group at `place`; `None` for a `Tabs` or `Stack` node. + pub(super) fn group(&self, place: &Place) -> Option<&Group

> { + match place { + Place::Node(_) => None, + Place::Dock { group, .. } => self.column(place)?.groups.get(*group), + Place::Floating { window, group } => self.floating.get(*window)?.groups.get(*group), + } + } + + pub(super) fn group_mut(&mut self, place: &Place) -> Option<&mut Group

> { + match place { + Place::Node(_) => None, + Place::Dock { group, .. } => self.column_mut(place)?.groups.get_mut(*group), + Place::Floating { window, group } => self.floating.get_mut(*window)?.groups.get_mut(*group), + } + } + + /// Remove the node at `path`; its parent split collapses into the sibling. + pub(super) fn take_node(&mut self, path: &[bool]) -> Option> { + let Some((second_child, parent_path)) = path.split_last() else { return self.root.take() }; + let parent = self.root.as_mut().and_then(|root| node_at_mut(root, parent_path))?; + if !matches!(parent, Node::Split { .. }) { + return None; + } + match std::mem::replace(parent, Node::Tabs { panels: Vec::new(), active: 0 }) { + Node::Split { first, second, .. } => { + let (taken, kept) = if *second_child { (second, first) } else { (first, second) }; + *parent = *kept; + Some(*taken) + } + other => { + *parent = other; + None + } + } + } + + /// The members of the group, column or floating window containing `panel`, in order. + /// An accordion entry is a group of one. + pub(super) fn members(&self, panel: &P, unit: Unit) -> Result, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + match (unit, &place) { + (Unit::Group, Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Tabs { panels, .. }) => Ok(panels.clone()), + Some(Node::Stack { .. }) => Ok(vec![panel.clone()]), + _ => Err(DockError::MissingPanel), + }, + (Unit::Group, _) => self.group(&place).map(|g| g.panels.clone()).ok_or(DockError::MissingPanel), + (Unit::Column, Place::Dock { .. }) => { + let column = self.column(&place).ok_or(DockError::MissingPanel)?; + Ok(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()) + } + (Unit::Window, Place::Floating { window, .. }) => { + let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; + Ok(window.panels().cloned().collect()) + } + (Unit::Column | Unit::Window, _) => Err(DockError::InvalidPlacement), + } + } + + pub(super) fn take_unit(&mut self, panel: &P, unit: Unit) -> Result, DockError> { + match unit { + Unit::Group => self.take_group(panel), + Unit::Column => self.take_column(panel), + Unit::Window => self.take_window(panel), + } + } + + fn take_group(&mut self, panel: &P) -> Result, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + match place { + Place::Node(path) => { + if let Some(Node::Stack { entries }) = self.root.as_mut().and_then(|root| node_at_mut(root, &path)) + && entries.len() > 1 + && let Some(index) = entries.iter().position(|e| &e.panel == panel) + { + let entry = entries.remove(index); + let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }; + return Ok(Moving::group(group)); + } + match self.take_node(&path) { + Some(Node::Tabs { panels, active }) => Ok(Moving::group(Group::new(panels, active))), + Some(Node::Stack { mut entries }) if entries.len() == 1 => { + let entry = entries.remove(0); + Ok(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })) + } + _ => Err(DockError::MissingPanel), + } + } + Place::Dock { path, column, group } => { + let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?; + let source = columns.get_mut(column).ok_or(DockError::MissingPanel)?; + if group >= source.groups.len() { + return Err(DockError::MissingPanel); + } + let taken = source.groups.remove(group); + let width = source.width; + if source.groups.is_empty() { + columns.remove(column); + } + if columns.is_empty() { + self.take_node(&path); + } + Ok(Moving { width: Some(width), ..Moving::group(taken) }) + } + Place::Floating { window, group } => { + let source = self.floating.get_mut(window).ok_or(DockError::MissingPanel)?; + if group >= source.groups.len() { + return Err(DockError::MissingPanel); + } + let taken = source.groups.remove(group); + let rect = source.rect; + let native = source.native; + let whole = source.groups.is_empty(); + if whole { + self.floating.remove(window); + } + let width = rect.get(2).copied(); + Ok(Moving { width, rect: whole.then_some(rect), native: native.filter(|_| whole), ..Moving::group(taken) }) + } + } + } + + fn take_column(&mut self, panel: &P) -> Result, DockError> { + let Some(Place::Dock { path, column, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) }; + let columns = self.columns_mut(&path).ok_or(DockError::MissingPanel)?; + if column >= columns.len() { + return Err(DockError::MissingPanel); + } + let taken = columns.remove(column); + if columns.is_empty() { + self.take_node(&path); + } + Ok(Moving { + groups: taken.groups, + width: Some(taken.width), + iconic: taken.iconic, + iconic_width: taken.iconic_width, + rect: None, + native: None, + }) + } + + fn take_window(&mut self, panel: &P) -> Result, DockError> { + let Some(Place::Floating { window, .. }) = self.place(panel) else { return Err(DockError::InvalidPlacement) }; + if window >= self.floating.len() { + return Err(DockError::MissingPanel); + } + let taken = self.floating.remove(window); + Ok(Moving { + groups: taken.groups, + width: taken.rect.get(2).copied(), + iconic: false, + iconic_width: None, + rect: Some(taken.rect), + native: taken.native, + }) + } + + /// Insert groups taken by [`Self::take_unit`] (or a new single-panel group). + pub(super) fn insert_moving(&mut self, moving: Moving

, to: Target

) -> Result<(), DockError> { + if moving.groups.is_empty() || moving.groups.iter().any(|g| g.panels.is_empty()) { + return Err(DockError::MissingPanel); + } + match to { + Target::Float { rect } => { + let native = if rect.is_none() { moving.native } else { None }; + let rect = rect.or(moving.rect).unwrap_or_else(|| { + let [x, y, width, height] = DEFAULT_FLOAT; + [x, y, moving.width.unwrap_or(width), height] + }); + if !valid_rect(rect) { + return Err(DockError::InvalidGeometry); + } + self.floating.push(Floating { groups: moving.groups, rect, native }); + Ok(()) + } + Target::NewDock { side } => { + if let Some(path) = self.dock_path(side) { + let columns = self.columns_mut(&path).ok_or(DockError::MissingTarget)?; + if !columns.is_empty() { + return Err(DockError::InvalidPlacement); + } + columns.push(moving.into_column(DEFAULT_COLUMN_WIDTH)); + return Ok(()); + } + let column = moving.into_column(DEFAULT_COLUMN_WIDTH); + self.add_dock(side, column); + Ok(()) + } + Target::At { anchor, placement } => { + let place = self.place(&anchor).ok_or(DockError::MissingTarget)?; + match placement { + Placement::Tab { before } => { + let index = match &before { + Some(before) => { + if self.place(before).as_ref() != Some(&place) { + return Err(DockError::MissingTarget); + } + self.index_in(&place, before) + } + None => None, + }; + self.merge(&place, index, moving) + } + Placement::Split(Zone::Center) => { + let index = self.index_in(&place, &anchor).map(|i| i + 1); + self.merge(&place, index, moving) + } + Placement::Split(zone) => { + let Place::Node(path) = place else { return Err(DockError::InvalidPlacement) }; + let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; + let (panels, active) = flatten(moving.groups); + let new = Node::Tabs { panels, active }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let (first, second) = if matches!(zone, Zone::Top | Zone::Left) { (new, old) } else { (old, new) }; + *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; + Ok(()) + } + Placement::Group { below } => { + let (groups, index) = match &place { + Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group), + Place::Floating { window, group } => { + (&mut self.floating.get_mut(*window).ok_or(DockError::MissingTarget)?.groups, *group) + } + Place::Node(_) => return Err(DockError::InvalidPlacement), + }; + let index = (index + usize::from(below)).min(groups.len()); + groups.splice(index..index, moving.groups); + Ok(()) + } + Placement::Column { after } => { + let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) }; + let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?; + let width = columns.get(*column).map_or(DEFAULT_COLUMN_WIDTH, |c| c.width); + let index = (column + usize::from(after)).min(columns.len()); + columns.insert(index, moving.into_column(width)); + Ok(()) + } + } + } + } + } + + /// Wrap the root in a horizontal split with a new dock on `side`. The split's fixed size is + /// the column width. + pub(super) fn add_dock(&mut self, side: Side, column: Column

) { + let width = column.width; + let dock = Node::Dock { side, columns: vec![column] }; + self.root = Some(match self.root.take() { + None => dock, + Some(root) => { + let (first, second, size) = match side { + Side::Start => (dock, root, SplitSize::FixedFirst(width)), + Side::End => (root, dock, SplitSize::FixedSecond(width)), + }; + Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) } + } + }); + } + + fn index_in(&self, place: &Place, panel: &P) -> Option { + match place { + Place::Node(path) => match self.root.as_ref().and_then(|root| node_at(root, path))? { + Node::Tabs { panels, .. } => panels.iter().position(|p| p == panel), + Node::Stack { entries } => entries.iter().position(|e| &e.panel == panel), + _ => None, + }, + _ => self.group(place)?.panels.iter().position(|p| p == panel), + } + } + + /// Join every moving panel to the group at `place`, before `index` or at the end. The first + /// moving group's active panel becomes active. + fn merge(&mut self, place: &Place, index: Option, moving: Moving

) -> Result<(), DockError> { + let (panels, selected) = flatten(moving.groups); + match place { + Place::Node(path) => match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { + Some(Node::Tabs { panels: target, active }) => { + splice(target, active, index.unwrap_or(target.len()), panels, selected); + Ok(()) + } + Some(Node::Stack { entries }) => { + let index = index.unwrap_or(entries.len()).min(entries.len()); + entries.splice(index..index, panels.into_iter().map(|panel| super::StackEntry { panel, open: true, height: None })); + Ok(()) + } + _ => Err(DockError::MissingTarget), + }, + _ => { + let group = self.group_mut(place).ok_or(DockError::MissingTarget)?; + let end = group.panels.len(); + splice(&mut group.panels, &mut group.active, index.unwrap_or(end), panels, selected); + Ok(()) + } + } + } +} + +/// All panels in order, and the index of the first group's active panel among them. +fn flatten

(groups: Vec>) -> (Vec

, usize) { + let selected = groups.first().map_or(0, |g| g.active.min(g.panels.len().saturating_sub(1))); + (groups.into_iter().flat_map(|g| g.panels).collect(), selected) +} + +pub(super) fn valid_column

(column: &Column

) -> bool { + valid_width(column.width) && column.iconic_width.is_none_or(valid_width) +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index a7bf3f98..d29be7a4 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -5,10 +5,12 @@ //! Validate deserialized layouts before rendering them. Every transaction checks its complete //! input and commits only a valid result; a rejected move cannot lose the source panel. +mod dock_model; mod model; mod surface; mod targets; +pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index a7056677..92eeedbb 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,11 +1,12 @@ use std::{collections::HashSet, fmt, hash::Hash}; +use super::dock_model::{Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, valid_column}; use crate::layout::{SplitAxis, SplitSize}; /// Limits are checked before cloning or recursively modifying an application layout. pub const MAX_DEPTH: usize = 64; pub const MAX_PANELS: usize = 4096; -const MAX_NODES: usize = MAX_PANELS * 2; +pub(super) const MAX_NODES: usize = MAX_PANELS * 2; const MAX_EXTENT: f32 = 1_000_000.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -20,6 +21,7 @@ pub enum Zone { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Node

{ Split { axis: SplitAxis, @@ -35,6 +37,13 @@ pub enum Node

{ Stack { entries: Vec>, }, + /// One window edge's dock, columns from the window edge inwards. A dock is the root or, + /// following only same-side children of horizontal splits, at its window edge. At most one + /// per side. An empty dock (no columns) is valid and accepts [`Target::NewDock`]. + Dock { + side: Side, + columns: Vec>, + }, } #[derive(Clone, Debug, PartialEq)] @@ -45,13 +54,93 @@ pub struct StackEntry

{ pub height: Option, } +/// A floating window: a stack of groups, top to bottom. +/// +/// Serde reads the v1 shape `{panels, active, rect}` as one group. A window with one plain group +/// (not minimized, no height, no native rect) writes that shape, so older builds can read files +/// that use no newer feature. #[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Floating

{ - pub panels: Vec

, - pub active: usize, + pub groups: Vec>, /// `[x, y, width, height]` in logical points, in the application's egui viewport. pub rect: [f32; 4], + /// Desktop rect while the window is in its own OS window. + pub native: Option<[f32; 4]>, +} + +impl

Floating

{ + pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { + Self { groups: vec![Group::new(panels, active)], rect, native: None } + } + + pub fn panels(&self) -> impl Iterator { + self.groups.iter().flat_map(|group| group.panels.iter()) + } + + pub fn contains(&self, panel: &P) -> bool + where + P: PartialEq, + { + self.groups.iter().any(|group| group.panels.contains(panel)) + } +} + +#[cfg(feature = "serde")] +mod floating_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + use super::{Floating, Group}; + + #[derive(Serialize)] + struct V1<'a, P> { + panels: &'a [P], + active: usize, + rect: [f32; 4], + } + + #[derive(Serialize)] + struct V2<'a, P> { + groups: &'a [Group

], + rect: [f32; 4], + #[serde(skip_serializing_if = "Option::is_none")] + native: Option<[f32; 4]>, + } + + impl Serialize for Floating

{ + fn serialize(&self, serializer: S) -> Result { + match self.groups.as_slice() { + [only] if !only.minimized && only.height.is_none() && self.native.is_none() => { + V1 { panels: &only.panels, active: only.active, rect: self.rect }.serialize(serializer) + } + groups => V2 { groups, rect: self.rect, native: self.native }.serialize(serializer), + } + } + } + + #[derive(Deserialize)] + #[serde(bound(deserialize = "P: Deserialize<'de>"))] + struct Repr

{ + groups: Option>>, + panels: Option>, + #[serde(default)] + active: usize, + rect: [f32; 4], + #[serde(default)] + native: Option<[f32; 4]>, + } + + impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating

{ + fn deserialize>(deserializer: D) -> Result { + let repr = Repr::

::deserialize(deserializer)?; + let groups = match (repr.groups, repr.panels) { + (Some(groups), None) => groups, + (None, Some(panels)) => vec![Group::new(panels, repr.active)], + (None, None) => return Err(serde::de::Error::missing_field("groups")), + (Some(_), Some(_)) => return Err(serde::de::Error::duplicate_field("panels")), + }; + Ok(Floating { groups, rect: repr.rect, native: repr.native }) + } + } } #[derive(Clone, Debug, PartialEq)] @@ -69,11 +158,17 @@ impl

Default for Layout

{ #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Placement

{ /// Insert before this existing destination member, or append. Also reorders a group. Tab { before: Option

}, - /// Center inserts after the anchor; sides create a new half-size split. + /// Center inserts after the anchor; sides create a new half-size split. Sides apply to + /// `Tabs` and `Stack` nodes only. Split(Zone), + /// A new group above or below the anchor's group, in a dock column or a floating stack. + Group { below: bool }, + /// A new column before (towards the window edge) or after the anchor's column. + Column { after: bool }, } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -92,10 +187,16 @@ pub struct Location

{ /// rather than accidentally inserting beside only its first leaf when restoring. #[cfg_attr(feature = "serde", serde(default))] pub sibling: Vec

, + /// Group and column state when the panel was in a dock or a floating stack. Applies when + /// restoring creates a new group, column or dock for the panel. + #[cfg_attr(feature = "serde", serde(default))] + pub group: Option, } +/// Groups, columns and floating windows are named by a panel they contain. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Action

{ Move { panel: P, @@ -141,6 +242,62 @@ pub enum Action

{ panel: P, height: Option, }, + /// Move the group containing `panel` with its order, active tab and minimized state. + /// `Target::At` with `Tab` or `Split(Center)` merges every member. An accordion entry is a + /// group of one. A target inside the group is a no-op. + MoveGroup { + panel: P, + to: Target

, + }, + /// Move the dock column containing `panel`. `Float` makes a window of its groups. + MoveColumn { + panel: P, + to: Target

, + }, + /// Dock or merge the floating window containing `panel`, every group included. + MoveWindow { + panel: P, + to: Target

, + }, + /// Close Tab Group. + CloseGroup { + panel: P, + }, + /// × on a floating window: closes all its panels. + CloseWindow { + panel: P, + }, + /// Close Other Panels in Group. + CloseOthers { + panel: P, + }, + /// Minimize a dock or floating group to its tab strip. On an accordion entry, closes it. + SetMinimized { + panel: P, + minimized: bool, + }, + SetIconic { + panel: P, + iconic: bool, + }, + ResizeColumn { + panel: P, + width: f32, + }, + /// Preferred height of a dock or floating group, or of an accordion entry. + ResizeGroup { + panel: P, + height: Option, + }, + ResizeIconic { + panel: P, + width: Option, + }, + /// Put the floating window in its own OS window at `rect`, or back in the app window. + SetNative { + panel: P, + rect: Option<[f32; 4]>, + }, } /// Rendering and command adapters can protect a document canvas while leaving panels movable. @@ -160,9 +317,17 @@ impl Default for Permissions { impl Permissions { pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false }; + + /// Lock Workspace: nothing moves, floats or closes; tab drops keep their policy. + /// [`super::Workspace::apply`] applies this when the workspace is locked; an application + /// with a global lock preference applies it in its own permissions callback. + pub fn locked(self) -> Self { + Self { movable: false, floatable: false, closable: false, accepts_tabs: self.accepts_tabs } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum DockError { LimitExceeded, DuplicatePanel, @@ -172,6 +337,8 @@ pub enum DockError { InvalidGeometry, InvalidPlacement, NotAllowed, + /// Saved by a newer version of the docking format. + UnsupportedVersion, } impl fmt::Display for DockError { @@ -185,6 +352,7 @@ impl fmt::Display for DockError { Self::InvalidGeometry => "docking geometry is not finite and bounded", Self::InvalidPlacement => "docking destination does not support this placement", Self::NotAllowed => "this panel does not allow the requested docking operation", + Self::UnsupportedVersion => "docking data was saved by a newer version", }) } } @@ -195,6 +363,10 @@ pub(super) fn valid_rect(rect: [f32; 4]) -> bool { rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_EXTENT) && rect.get(2).is_some_and(|v| *v > 0.0) && rect.get(3).is_some_and(|v| *v > 0.0) } +pub(super) fn valid_width(width: f32) -> bool { + width.is_finite() && width > 0.0 && width <= MAX_EXTENT +} + fn valid_size(size: SplitSize) -> bool { match size { SplitSize::Ratio(r) => r.is_finite() && (0.0..=1.0).contains(&r), @@ -219,19 +391,47 @@ impl Layout

{ } let mut seen = HashSet::new(); let mut count = 0usize; - let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize)]).unwrap_or_default(); - while let Some((node, depth)) = visit.pop() { + let mut docks = (false, false); + // Each node carries whether it touches the window's start and end edges. + let mut visit = self.root.as_ref().map(|n| vec![(n, 1usize, true, true)]).unwrap_or_default(); + while let Some((node, depth, start, end)) = visit.pop() { count = count.saturating_add(1); if depth > MAX_DEPTH || count > MAX_NODES { return Err(DockError::LimitExceeded); } match node { - Node::Split { size, first, second, .. } => { + Node::Split { axis, size, first, second } => { if !valid_size(*size) { return Err(DockError::InvalidGeometry); } - visit.push((second, depth + 1)); - visit.push((first, depth + 1)); + let horizontal = *axis == SplitAxis::Horizontal; + visit.push((second, depth + 1, false, end && horizontal)); + visit.push((first, depth + 1, start && horizontal, false)); + } + Node::Dock { side, columns } => { + let (at_edge, taken) = match side { + Side::Start => (start, &mut docks.0), + Side::End => (end, &mut docks.1), + }; + if !at_edge || *taken { + return Err(DockError::InvalidPlacement); + } + *taken = true; + if columns.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + for column in columns { + if !valid_column(column) || column.groups.is_empty() { + return Err(DockError::InvalidGeometry); + } + count = count.saturating_add(column.groups.len().saturating_add(1)); + if count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + for group in &column.groups { + check_group(group, &mut seen)?; + } + } } Node::Tabs { panels, active } => { check_tabs(panels, *active, &mut seen)?; @@ -250,10 +450,16 @@ impl Layout

{ } } for floating in &self.floating { - if !valid_rect(floating.rect) || floating.panels.is_empty() { + if !valid_rect(floating.rect) || floating.groups.is_empty() || floating.native.is_some_and(|r| !valid_rect(r)) { return Err(DockError::InvalidGeometry); } - check_tabs(&floating.panels, floating.active, &mut seen)?; + count = count.saturating_add(floating.groups.len()); + if count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + for group in &floating.groups { + check_group(group, &mut seen)?; + } } Ok(()) } @@ -275,10 +481,14 @@ impl Layout

{ } Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(MAX_PANELS - panels.len())), Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(MAX_PANELS - panels.len())), + Node::Dock { columns, .. } => { + let members = columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter()); + panels.extend(members.take(MAX_PANELS - panels.len())); + } } } - for group in self.floating.iter().take(MAX_PANELS) { - panels.extend(group.panels.iter().take(MAX_PANELS - panels.len())); + for window in self.floating.iter().take(MAX_PANELS) { + panels.extend(window.panels().take(MAX_PANELS - panels.len())); } panels } @@ -296,12 +506,69 @@ impl Layout

{ /// Capture a panel's nearest surviving group/split neighbor before hiding it. pub fn location(&self, panel: &P) -> Result, DockError> { self.validate()?; - if let Some(group) = self.floating.iter().find(|g| g.panels.contains(panel)) { - let mut location = group_location(&group.panels, panel).ok_or(DockError::MissingPanel)?; - location.floating = Some(group.rect); + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + let (groups, group, floating, column) = match &place { + Place::Node(_) => return self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel), + Place::Floating { window, group } => { + let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; + (&window.groups, *group, Some(window.rect), None) + } + Place::Dock { path, group, .. } => { + let side = match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => *side, + _ => return Err(DockError::MissingPanel), + }; + let column = self.column(&place).ok_or(DockError::MissingPanel)?; + let state = ColumnLocation { side, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }; + (&column.groups, *group, None, Some(state)) + } + }; + let own = groups.get(group).ok_or(DockError::MissingPanel)?; + let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?; + location.floating = floating; + location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, column }); + if location.anchor.is_some() { return Ok(location); } - self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel) + // Alone in its group: anchor to the neighbouring group, then the neighbouring column. + let first = |g: Option<&Group

>| g.and_then(|g| g.panels.first()).cloned(); + if let Some(anchor) = first(groups.get(group + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: false }; + } else if let Some(anchor) = first(group.checked_sub(1).and_then(|i| groups.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Group { below: true }; + } else if groups.len() == 1 + && let Place::Dock { path, column, .. } = &place + && let Some(Node::Dock { columns, .. }) = self.root.as_ref().and_then(|root| node_at(root, path)) + { + let first_of = |c: Option<&Column

>| first(c.and_then(|c| c.groups.first())); + if let Some(anchor) = first_of(columns.get(column + 1)) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: false }; + } else if let Some(anchor) = first_of(column.checked_sub(1).and_then(|i| columns.get(i))) { + location.anchor = Some(anchor); + location.placement = Placement::Column { after: true }; + } + } + Ok(location) + } + + /// Hidden, open behind another tab or in a collapsed container, or showing. + pub fn panel_state(&self, panel: &P) -> PanelState { + let showing = match self.place(panel) { + None => return PanelState::Hidden, + Some(Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, &path)) { + Some(Node::Tabs { panels, active }) => panels.get(*active) == Some(panel), + Some(Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && e.open), + _ => false, + }, + Some(place) => { + let iconic = self.column(&place).is_some_and(|c| c.iconic); + self.group(&place).is_some_and(|g| !g.minimized && g.active_panel() == Some(panel)) && !iconic + } + }; + if showing { PanelState::Front } else { PanelState::Open } } /// Restore an absent panel at a captured location. Missing anchors are reported without @@ -314,18 +581,57 @@ impl Layout

{ if location.size.is_some_and(|s| !valid_size(s)) || location.stack.is_some_and(|(_, h)| !valid_height(h)) { return Err(DockError::InvalidGeometry); } + let state = location.group.as_ref(); + if state.is_some_and(|s| { + !valid_height(s.height) || s.column.is_some_and(|c| !valid_width(c.width) || c.iconic_width.is_some_and(|w| !valid_width(w))) + }) { + return Err(DockError::InvalidGeometry); + } + // A restore that creates a group, column or dock gives it the saved state. + let moving = || { + let group = + Group { panels: vec![panel.clone()], active: 0, minimized: state.is_some_and(|s| s.minimized), height: state.and_then(|s| s.height) }; + let column = state.and_then(|s| s.column); + Moving { + width: column.map(|c| c.width), + iconic: column.is_some_and(|c| c.iconic), + iconic_width: column.and_then(|c| c.iconic_width), + ..Moving::group(group) + } + }; let mut proposed = self.clone(); if let Some(rect) = location.floating { if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } if let Some(anchor) = &location.anchor { - if !proposed.floating.iter().any(|g| g.panels.contains(anchor)) { + if !proposed.floating.iter().any(|g| g.contains(anchor)) { return Err(DockError::MissingTarget); } - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + if let Placement::Group { .. } = location.placement { + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + } else { + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + } } else { - proposed.floating.push(Floating { panels: vec![panel.clone()], active: 0, rect }); + proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; + } + } else if let Some(anchor) = &location.anchor + && matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) + { + if !proposed.contains(anchor) { + return Err(DockError::MissingTarget); + } + proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; + } else if let Some(column) = state.and_then(|s| s.column).filter(|_| location.anchor.is_none()) { + // Alone in its dock: back at that edge, as the outermost column if a dock exists. + match proposed.dock_path(column.side) { + Some(path) => { + let columns = proposed.columns_mut(&path).ok_or(DockError::MissingTarget)?; + let Moving { groups, .. } = moving(); + columns.insert(0, Column { groups, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }); + } + None => proposed.insert_moving(moving(), Target::NewDock { side: column.side })?, } } else if let Some(anchor) = &location.anchor { if !proposed.contains(anchor) { @@ -397,6 +703,31 @@ impl Layout

{ Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed), Action::MoveFloating { panel, .. } if !permissions(panel).movable => return Err(DockError::NotAllowed), Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed), + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + let members = self.members(panel, unit_of(&action))?; + if members.iter().any(|p| !permissions(p).movable) { + return Err(DockError::NotAllowed); + } + match to { + Target::Float { .. } if members.iter().any(|p| !permissions(p).floatable) => return Err(DockError::NotAllowed), + Target::At { anchor, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } + if !members.contains(anchor) && !permissions(anchor).accepts_tabs => + { + return Err(DockError::NotAllowed); + } + _ => {} + } + } + Action::CloseGroup { panel } | Action::CloseWindow { panel } | Action::CloseOthers { panel } => { + let others = matches!(action, Action::CloseOthers { .. }); + let members = self.members(panel, unit_of(&action))?; + if members.iter().filter(|p| !others || *p != panel).any(|p| !permissions(p).closable) { + return Err(DockError::NotAllowed); + } + } + Action::SetNative { panel, rect: Some(_) } if self.members(panel, Unit::Window)?.iter().any(|p| !permissions(p).floatable) => { + return Err(DockError::NotAllowed); + } _ => {} } let mut proposed = self.clone(); @@ -406,6 +737,28 @@ impl Layout

{ Ok(()) } + /// Apply every action in order, or none: the first rejection leaves the layout unchanged. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + let mut proposed = self.clone(); + for action in actions { + proposed.apply_with_permissions(action, &permissions)?; + } + *self = proposed; + Ok(()) + } + + /// The panels a close action (`Close`, `CloseGroup`, `CloseWindow`, `CloseOthers`) would + /// remove, in group order, so the application can release their state once the action is + /// applied. Empty for other actions. + pub fn closed_by(&self, action: &Action

) -> Result, DockError> { + Ok(match action { + Action::Close { panel } => vec![panel.clone()], + Action::CloseGroup { panel } | Action::CloseWindow { panel } => self.members(panel, unit_of(action))?, + Action::CloseOthers { panel } => self.members(panel, Unit::Group)?.into_iter().filter(|p| p != panel).collect(), + _ => Vec::new(), + }) + } + fn apply_inner(&mut self, action: Action

) -> Result<(), DockError> { match action { Action::Move { panel, anchor, placement } => { @@ -432,19 +785,20 @@ impl Layout

{ return Err(DockError::InvalidGeometry); } let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.floating.push(Floating { panels: vec![entry.panel], active: 0, rect }); + self.floating.push(Floating::single(vec![entry.panel], 0, rect)); Ok(()) } Action::Close { panel } => self.take(&panel).map(|_| ()).ok_or(DockError::MissingPanel), Action::Activate { panel } => { - if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + // Selecting a panel shows it: a minimized group expands. + group.active = group.panels.iter().position(|p| p == &panel).ok_or(DockError::MissingPanel)?; + group.minimized = false; return Ok(()); } - for group in &mut self.floating { - if let Some(i) = group.panels.iter().position(|p| p == &panel) { - group.active = i; - return Ok(()); - } + if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + return Ok(()); } Err(DockError::MissingPanel) } @@ -477,8 +831,8 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - let group = self.floating.iter_mut().find(|g| g.panels.contains(&panel)).ok_or(DockError::MissingPanel)?; - group.rect = rect; + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::MissingPanel)?; + window.rect = rect; Ok(()) } Action::ResizeSplit { path, size } => { @@ -510,47 +864,115 @@ impl Layout

{ entry.height = height; Ok(()) } + Action::MoveGroup { ref panel, ref to } | Action::MoveColumn { ref panel, ref to } | Action::MoveWindow { ref panel, ref to } => { + let unit = unit_of(&action); + if let Target::At { anchor, .. } = to + && self.members(panel, unit)?.contains(anchor) + { + // Dropped on itself. + return Ok(()); + } + let moving = self.take_unit(panel, unit)?; + self.insert_moving(moving, to.clone()) + } + Action::CloseGroup { ref panel } | Action::CloseWindow { ref panel } => self.take_unit(panel, unit_of(&action)).map(|_| ()), + Action::CloseOthers { panel } => { + for other in self.members(&panel, Unit::Group)? { + if other != panel { + self.take(&other).ok_or(DockError::MissingPanel)?; + } + } + Ok(()) + } + Action::SetMinimized { panel, minimized } => { + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.minimized = minimized; + return Ok(()); + } + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.open = !minimized; + Ok(()) + } + Action::ResizeGroup { panel, height } => { + if !valid_height(height) { + return Err(DockError::InvalidGeometry); + } + let place = self.place(&panel).ok_or(DockError::MissingPanel)?; + if let Some(group) = self.group_mut(&place) { + group.height = height; + return Ok(()); + } + let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + entry.height = height; + Ok(()) + } + Action::SetIconic { panel, iconic } => { + self.column_of(&panel)?.iconic = iconic; + Ok(()) + } + Action::ResizeColumn { panel, width } => { + if !valid_width(width) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.width = width; + Ok(()) + } + Action::ResizeIconic { panel, width } => { + if width.is_some_and(|w| !valid_width(w)) { + return Err(DockError::InvalidGeometry); + } + self.column_of(&panel)?.iconic_width = width; + Ok(()) + } + Action::SetNative { panel, rect } => { + if rect.is_some_and(|r| !valid_rect(r)) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; + window.native = rect; + Ok(()) + } } } + fn column_of(&mut self, panel: &P) -> Result<&mut Column

, DockError> { + let place = self.place(panel).ok_or(DockError::MissingPanel)?; + self.column_mut(&place).ok_or(DockError::InvalidPlacement) + } + fn same_group(&self, panel: &P, anchor: &P) -> bool { - fn in_node(node: &Node

, panel: &P, anchor: &P) -> bool { - match node { - Node::Tabs { panels, .. } => panels.contains(panel) && panels.contains(anchor), - Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel) && entries.iter().any(|e| &e.panel == anchor), - Node::Split { first, second, .. } => in_node(first, panel, anchor) || in_node(second, panel, anchor), - } - } - self.root.as_ref().is_some_and(|n| in_node(n, panel, anchor)) - || self.floating.iter().any(|g| g.panels.contains(panel) && g.panels.contains(anchor)) + let place = self.place(panel); + place.is_some() && place == self.place(anchor) } - fn take(&mut self, panel: &P) -> Option> { + pub(super) fn take(&mut self, panel: &P) -> Option> { let mut removed = None; self.root = self.root.take().and_then(|n| remove_node(n, panel, &mut removed)); if removed.is_some() { return removed; } - for group in &mut self.floating { - if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { - removed = Some(StackEntry { panel: p, open: true, height: None }); + for window in &mut self.floating { + for group in &mut window.groups { + if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { + removed = Some(StackEntry { panel: p, open: true, height: None }); + break; + } + } + if removed.is_some() { + window.groups.retain(|g| !g.panels.is_empty()); break; } } - self.floating.retain(|g| !g.panels.is_empty()); + self.floating.retain(|g| !g.groups.is_empty()); removed } fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { - if let Some(group) = self.floating.iter_mut().find(|g| g.panels.contains(anchor)) { - let index = match placement { - Placement::Tab { before } => before.as_ref().and_then(|p| group.panels.iter().position(|q| q == p)).unwrap_or(group.panels.len()), - Placement::Split(Zone::Center) => group.panels.iter().position(|p| p == anchor).map_or(group.panels.len(), |i| i + 1), - Placement::Split(_) => return Err(DockError::InvalidPlacement), - }; - group.panels.insert(index, entry.panel); - group.active = index; - return Ok(()); + let place = self.place(anchor).ok_or(DockError::MissingTarget)?; + if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) { + let group = Group::new(vec![entry.panel], 0); + return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); } if let Some(root) = self.root.as_mut() && insert_node(root, &entry, anchor, &placement) @@ -561,6 +983,14 @@ impl Layout

{ } } +fn unit_of

(action: &Action

) -> Unit { + match action { + Action::MoveColumn { .. } => Unit::Column, + Action::MoveWindow { .. } | Action::CloseWindow { .. } => Unit::Window, + _ => Unit::Group, + } +} + fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if seen.len() >= MAX_PANELS { return Err(DockError::LimitExceeded); @@ -571,6 +1001,13 @@ fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Res Ok(()) } +fn check_group<'a, P: Eq + Hash>(group: &'a Group

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { + if group.panels.is_empty() || !valid_height(group.height) { + return Err(DockError::InvalidGeometry); + } + check_tabs(&group.panels, group.active, seen) +} + fn check_tabs<'a, P: Eq + Hash>(panels: &'a [P], active: usize, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if panels.len() > MAX_PANELS { return Err(DockError::LimitExceeded); @@ -614,6 +1051,20 @@ fn remove_node(node: Node

, panel: &P, removed: &mut Option node, } } + Node::Dock { side, mut columns } => { + // A dock that was already empty stays; one emptied by this removal collapses. + let had_columns = !columns.is_empty(); + for column in &mut columns { + for group in &mut column.groups { + if let Some(panel) = remove_tab(&mut group.panels, &mut group.active, panel) { + *removed = Some(StackEntry { panel, open: true, height: None }); + } + } + column.groups.retain(|g| !g.panels.is_empty()); + } + columns.retain(|c| !c.groups.is_empty()); + (!had_columns || !columns.is_empty()).then_some(Node::Dock { side, columns }) + } } } @@ -636,6 +1087,7 @@ fn activate(node: &mut Node

, panel: &P) -> bool { } } Node::Split { first, second, .. } => activate(first, panel) || activate(second, panel), + Node::Dock { .. } => false, } } @@ -643,7 +1095,7 @@ fn stack_entry_mut<'a, P: Eq>(node: &'a mut Node

, panel: &P) -> Option<&'a mu match node { Node::Stack { entries } => entries.iter_mut().find(|e| &e.panel == panel), Node::Split { first, second, .. } => stack_entry_mut(first, panel).or_else(|| stack_entry_mut(second, panel)), - Node::Tabs { .. } => None, + Node::Tabs { .. } | Node::Dock { .. } => None, } } @@ -652,6 +1104,7 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Split { first, second, .. } => return insert_node(first, entry, anchor, placement) || insert_node(second, entry, anchor, placement), Node::Tabs { panels, .. } => panels.contains(anchor), Node::Stack { entries } => entries.iter().any(|e| &e.panel == anchor), + Node::Dock { .. } => false, }; if !here { return false; @@ -668,7 +1121,7 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Tabs { panels, active } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| panels.iter().position(|q| q == p)).unwrap_or(panels.len()), - Placement::Split(_) => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), + _ => panels.iter().position(|p| p == anchor).map_or(panels.len(), |i| i + 1), }; panels.insert(index, entry.panel.clone()); *active = index; @@ -676,11 +1129,11 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: Node::Stack { entries } => { let index = match placement { Placement::Tab { before } => before.as_ref().and_then(|p| entries.iter().position(|e| &e.panel == p)).unwrap_or(entries.len()), - Placement::Split(_) => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), + _ => entries.iter().position(|e| &e.panel == anchor).map_or(entries.len(), |i| i + 1), }; entries.insert(index, entry.clone()); } - Node::Split { .. } => return false, + Node::Split { .. } | Node::Dock { .. } => return false, } true } @@ -689,7 +1142,7 @@ fn group_location(panels: &[P], panel: &P) -> Option> let index = panels.iter().position(|p| p == panel)?; let anchor = panels.iter().find(|p| *p != panel).cloned(); let before = panels.get(index + 1).cloned(); - Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new() }) + Some(Location { floating: None, anchor, placement: Placement::Tab { before }, size: None, stack: None, sibling: Vec::new(), group: None }) } fn first_panel

(node: &Node

) -> Option<&P> { @@ -697,6 +1150,7 @@ fn first_panel

(node: &Node

) -> Option<&P> { Node::Tabs { panels, .. } => panels.first(), Node::Stack { entries } => entries.first().map(|e| &e.panel), Node::Split { first, second, .. } => first_panel(first).or_else(|| first_panel(second)), + Node::Dock { columns, .. } => columns.iter().flat_map(|c| c.groups.iter()).find_map(|g| g.panels.first()), } } @@ -733,6 +1187,7 @@ fn node_location(node: &Node

, panel: &P) -> Option } Some(location) } + Node::Dock { .. } => None, } } @@ -773,6 +1228,7 @@ fn node_members<'a, P>(node: &'a Node

, members: &mut Vec<&'a P>) { node_members(first, members); node_members(second, members); } + Node::Dock { columns, .. } => members.extend(columns.iter().flat_map(|c| c.groups.iter()).flat_map(|g| g.panels.iter())), } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 53b81372..545bc432 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -208,7 +208,9 @@ impl DockArea

{ || matches(self.id.with(("move-group", panels.first()))) }; for floating in &layout.floating { - if group(&floating.panels) || floating.panels.first().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) { + if floating.groups.iter().any(|g| group(&g.panels)) + || floating.panels().next().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) + { return true; } } @@ -216,6 +218,7 @@ impl DockArea

{ while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true, + Node::Dock { columns, .. } if columns.iter().flat_map(|c| c.groups.iter()).any(|g| group(&g.panels)) => return true, Node::Stack { entries } => { if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) @@ -401,6 +404,17 @@ impl Renderer<'_, P> { } } Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), + Node::Dock { columns, .. } => { + // Interim rendering: columns side by side, groups stacked evenly. + let total: f32 = columns.iter().map(|c| c.width).sum(); + let mut left = rect.left(); + for column in columns { + let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; + let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); + left += width; + self.stacked_groups(ui, &column.groups, column_rect, false); + } + } Node::Stack { entries } => { let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); let remaining = (rect.height() - total_header).max(0.0); @@ -487,8 +501,19 @@ impl Renderer<'_, P> { } } + /// Groups stacked top to bottom in equal shares of `rect`. + fn stacked_groups(&mut self, ui: &mut Ui, groups: &[super::Group

], rect: Rect, floating: bool) { + let share = rect.height() / groups.len().max(1) as f32; + for (index, group) in groups.iter().enumerate() { + let top = rect.top() + share * index as f32; + let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); + self.group(ui, &group.panels, group.active, group_rect, floating); + } + } + fn node_limits(&self, node: &Node

) -> PanelLimits { match node { + Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() }, Node::Tabs { panels, .. } => { let mut limits = PanelLimits::default(); for panel in panels { @@ -750,17 +775,17 @@ impl Renderer<'_, P> { let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); let mut groups: Vec<_> = self.layout.floating.iter().collect(); groups.sort_by_key(|group| { - let id = group.panels.first().map(|panel| self.id.with(("floating", panel))); + let id = group.panels().next().map(|panel| self.id.with(("floating", panel))); layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) }); for group in groups { - let Some(first) = group.panels.first() else { continue }; + let Some(first) = group.panels().next() else { continue }; let [x, y, width, height] = group.rect; - let limits = self.node_limits(&Node::Tabs { panels: group.panels.clone(), active: group.active }); + let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 }); let size = vec2(width, height).max(limits.min).min(limits.max); let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| group.panels.contains(panel)) { + if self.activation.as_ref().is_some_and(|panel| group.contains(panel)) { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); self.output.focused = self.activation.clone(); @@ -769,7 +794,7 @@ impl Renderer<'_, P> { ui.set_min_size(rect.size()); let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); ui.allocate_rect(rect, Sense::hover()); - self.group(ui, &group.panels, group.active, rect, true); + self.stacked_groups(ui, &group.groups, rect, true); let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); self.register_interaction(ui.ctx(), response.id); @@ -813,7 +838,7 @@ impl Renderer<'_, P> { fn finish_group_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel) && g.panels.iter().all(|p| (self.permissions)(p).movable)) { + if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) { ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); return; } @@ -873,7 +898,7 @@ impl Renderer<'_, P> { let action = if let Some(proposal) = proposal.filter(|_| !gone) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) } else if (self.permissions)(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel))) + && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) { let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 941220db..68401324 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -14,7 +14,7 @@ fn sample() -> Layout { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2, 3, 4], 1)), }), - floating: vec![Floating { panels: vec![5, 6], active: 1, rect: [80.0, 70.0, 300.0, 220.0] }], + floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])], } } @@ -29,8 +29,8 @@ fn floating_brush_panel_joins_main_tabs_without_losing_other_panels() { let Node::Split { size, second, .. } = layout.root.as_ref().unwrap() else { panic!() }; assert_eq!(*size, SplitSize::FixedSecond(240.0)); assert_eq!(**second, tabs(&[2, 3, 5, 4], 2)); - assert_eq!(layout.floating[0].panels, vec![6]); - assert_eq!(layout.floating[0].active, 0); + assert_eq!(layout.floating[0].groups[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups[0].active, 0); layout.validate().unwrap(); assert_eq!(layout.panels().len(), 6); } @@ -43,8 +43,8 @@ fn detach_redock_merge_and_collapse_empty_source_containers() { layout.apply(move_to(1, 2, Zone::Left)).unwrap(); assert_eq!(layout.floating.len(), 1); layout.apply(move_to(5, 6, Zone::Center)).unwrap(); - assert_eq!(layout.floating[0].panels, vec![6, 5]); - assert_eq!(layout.floating[0].active, 1); + assert_eq!(layout.floating[0].groups[0].panels, vec![6, 5]); + assert_eq!(layout.floating[0].groups[0].active, 1); layout.apply(move_to(6, 3, Zone::Center)).unwrap(); layout.apply(move_to(5, 3, Zone::Center)).unwrap(); assert!(layout.floating.is_empty()); @@ -154,7 +154,7 @@ fn close_and_restore_preserve_fixed_side_and_floating_geometry() { first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[2], 0)), }), - floating: vec![Floating { panels: vec![3], active: 0, rect: [41.0, 52.0, 233.0, 244.0] }], + floating: vec![Floating::single(vec![3], 0, [41.0, 52.0, 233.0, 244.0])], }; let saved = layout.clone(); let first = layout.location(&1).unwrap(); @@ -197,12 +197,12 @@ fn closing_and_reopening_an_entire_accordion_restores_its_kind_and_heights() { #[test] fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometry() { let mut duplicate = sample(); - duplicate.floating[0].panels.push(1); + duplicate.floating[0].groups[0].panels.push(1); assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); let mut invalid = sample(); - invalid.floating[0].active = usize::MAX; + invalid.floating[0].groups[0].active = usize::MAX; assert_eq!(invalid.validate(), Err(DockError::InvalidSelection)); - invalid.floating[0].active = 0; + invalid.floating[0].groups[0].active = 0; invalid.floating[0].rect[3] = f32::INFINITY; assert_eq!(invalid.validate(), Err(DockError::InvalidGeometry)); let mut deep = tabs(&[0], 0); @@ -404,7 +404,7 @@ fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() { step(&ctx, &mut layout, vec![Event::PointerMoved(target)]); let actions = step(&ctx, &mut layout, vec![pointer_button(target, false)]); assert!(actions.iter().any(|a| matches!(a, Action::Move { panel: 5, .. })), "{actions:?}"); - assert!(!layout.floating.iter().any(|g| g.panels.contains(&5))); + assert!(!layout.floating.iter().any(|g| g.contains(&5))); assert_eq!(layout.panels().len(), 6); let Node::Split { second, .. } = layout.root.as_ref().unwrap() else { panic!() }; let Node::Tabs { panels, active } = second.as_ref() else { panic!() }; @@ -429,7 +429,7 @@ fn tab_dragged_out_of_the_window_floats_where_it_left() { step(&ctx, &mut layout, vec![Event::PointerMoved(exit)]); let actions = step(&ctx, &mut layout, vec![Event::PointerGone]); assert!(actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{actions:?}"); - assert!(layout.floating.iter().any(|g| g.panels == [3]), "{layout:?}"); + assert!(layout.floating.iter().any(|g| g.panels().eq([3].iter())), "{layout:?}"); let back = pos2(400.0, 300.0); assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(back)]).is_empty()); assert!(step(&ctx, &mut layout, vec![pointer_button(back, false)]).is_empty(), "the drag ended when the pointer left"); @@ -634,7 +634,7 @@ fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace() #[test] fn floating_only_surface_does_not_allocate_the_callers_document_region() { let ctx = Context::default(); - let layout = Layout { root: None, floating: vec![Floating { panels: vec![1u32], active: 0, rect: [30.0, 40.0, 200.0, 200.0] }] }; + let layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [30.0, 40.0, 200.0, 200.0])] }; ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { let before = ui.available_rect_before_wrap(); let out = DockArea::new(Id::new("float-only")).show( @@ -666,8 +666,8 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }))); - assert_eq!(layout.floating[0].panels, original.panels); - assert_eq!(layout.floating[0].active, original.active); + assert_eq!(layout.floating[0].groups[0].panels, original.groups[0].panels); + assert_eq!(layout.floating[0].groups[0].active, original.groups[0].active); assert_ne!(layout.floating[0].rect, original.rect); step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]); for _ in 0..2 { @@ -680,7 +680,7 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]); assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. }))); assert_eq!(layout.floating.len(), 2); - assert_eq!(layout.floating[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups[0].panels, vec![6]); } #[test] @@ -1026,7 +1026,7 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); paint(tab); let mut invalid = layout.clone(); - invalid.floating.push(Floating { panels: vec![3], active: 0, rect: [10.0, 10.0, 100.0, 100.0] }); + invalid.floating.push(Floating::single(vec![3], 0, [10.0, 10.0, 100.0, 100.0])); assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &invalid)); assert!(!DockArea::::new(Id::new("focus-area")).chrome_has_focus(&ctx, &Layout::default())); ctx.memory_mut(|memory| memory.request_focus(Id::new("unrendered"))); diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 996c29e2..29a6c9d9 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -55,7 +55,7 @@ impl Harness { first: Box::new(Node::Tabs { panels: vec![CANVAS], active: 0 }), second: Box::new(Node::Tabs { panels: vec![LAYERS, PROPERTIES], active: 0 }), }), - floating: vec![Floating { panels: vec![BRUSHES], active: 0, rect: [38.0, 76.0, 210.0, 250.0] }], + floating: vec![Floating::single(vec![BRUSHES], 0, [38.0, 76.0, 210.0, 250.0])], }; let mut harness = Self { ctx, @@ -235,7 +235,7 @@ fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { assert_eq!(h.body_ids[&BRUSHES], brush_id); h.capture(&directory, "02-merged-main-tabs"); h.drag(h.tab(BRUSHES).center(), pos2((width - 240.0) * 0.5, 270.0)); - assert!(h.layout.floating.iter().any(|g| g.panels.contains(&BRUSHES)), "{name}: {:?}", h.applied); + assert!(h.layout.floating.iter().any(|g| g.contains(&BRUSHES)), "{name}: {:?}", h.applied); h.capture(&directory, "03-detached-again"); h.drag(h.tab(BRUSHES).center(), h.tab(LAYERS).left_center() + vec2(4.0, 0.0)); assert!(h.layout.floating.is_empty(), "{name}"); From 0ea5f8200ca1bf8a1327e129debf9f73c3436f52 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:13 +0000 Subject: [PATCH 006/160] docking: Workspace with hidden locations, lock, reconcile and reset Workspace

keeps the current layout, where hidden panels go back, the user's saved arrangement and Lock Workspace. It reads the {layout, hidden} files lightcraft, pdfcraft, cadcraft and soundcraft write today. Close actions record a location per closed panel; set_visible follows hidden anchors when a panel's own anchor is hidden too. reconcile moves panels the app does not register now to hidden and places missing registered panels at their default place; reset returns to the saved or default arrangement. --- crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/workspace.rs | 243 +++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 crates/ui/src/docking/workspace.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index d29be7a4..841e2f31 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -9,11 +9,13 @@ mod dock_model; mod model; mod surface; mod targets; +mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; +pub use workspace::Workspace; #[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs new file mode 100644 index 00000000..90fe8d6e --- /dev/null +++ b/crates/ui/src/docking/workspace.rs @@ -0,0 +1,243 @@ +//! A saved arrangement: the current layout, where hidden panels go back, the user's saved +//! arrangement for Reset, and Lock Workspace. +//! +//! Storage, file names and size caps stay with the application. A file that does not parse or +//! validate, or comes from a newer version, should be kept on disk and not overwritten. + +use std::{collections::HashSet, hash::Hash}; + +use super::model::{Action, DockError, Layout, Location, MAX_PANELS, Permissions}; + +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(default, bound(deserialize = "P: serde::Deserialize<'de>")))] +pub struct Workspace

{ + /// Format version; files without one are v1 (`{layout, hidden}`). Always written as + /// [`Workspace::VERSION`]. + #[cfg_attr(feature = "serde", serde(default = "first_version", serialize_with = "write_version"))] + pub version: u32, + /// The current arrangement, changes included. + pub layout: Layout

, + /// Closed panels, and panels the application does not register now, with where each one + /// goes back. + pub hidden: Vec<(P, Location

)>, + /// A user workspace's saved arrangement; Reset returns to it. Built-in workspaces use the + /// application's default instead. + pub saved: Option>, + /// Lock Workspace, saved with this workspace: [`Self::apply`] uses + /// [`Permissions::locked`], so moving, floating and closing are rejected while activating, + /// opening, minimizing and resizing still work. An application with a global lock + /// preference instead passes locked permissions itself. + pub locked: bool, +} + +#[cfg(feature = "serde")] +fn first_version() -> u32 { + 1 +} + +#[cfg(feature = "serde")] +fn write_version(_: &u32, serializer: S) -> Result { + serializer.serialize_u32(Workspace::<()>::VERSION) +} + +impl

Default for Workspace

{ + fn default() -> Self { + Self::new(Layout::default()) + } +} + +impl

Workspace

{ + pub const VERSION: u32 = 2; + + pub fn new(layout: Layout

) -> Self { + Self { version: Self::VERSION, layout, hidden: Vec::new(), saved: None, locked: false } + } +} + +impl Workspace

{ + /// Check the version, both layouts and that every hidden panel is hidden once and absent + /// from the current layout. + pub fn validate(&self) -> Result<(), DockError> { + if self.version > Self::VERSION { + return Err(DockError::UnsupportedVersion); + } + self.layout.validate()?; + if let Some(saved) = &self.saved { + saved.validate()?; + } + if self.hidden.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + let visible: HashSet<&P> = self.layout.panels().into_iter().collect(); + let mut seen = HashSet::new(); + for (panel, _) in &self.hidden { + if visible.contains(panel) || !seen.insert(panel) { + return Err(DockError::DuplicatePanel); + } + } + Ok(()) + } + + /// Apply one action. Closed panels record where they go back, so each one can be reopened + /// with [`Self::set_visible`]. Honours [`Self::locked`]. [`Layout::closed_by`], called + /// before applying, names the panels a close action removes. + pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.apply_all([action], permissions) + } + + /// Apply every action in order, or none. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + self.validate()?; + let locked = self.locked; + let policy = |panel: &P| if locked { permissions(panel).locked() } else { permissions(panel) }; + let mut proposed = self.clone(); + for action in actions { + let closed = proposed.layout.closed_by(&action)?; + let locations = close_locations(&proposed.layout, &closed); + proposed.layout.apply_with_permissions(action, policy)?; + for (panel, location) in locations { + proposed.hidden.retain(|(p, _)| p != &panel); + proposed.hidden.push((panel, location)); + } + } + let visible: HashSet

= proposed.layout.panels().into_iter().cloned().collect(); + proposed.hidden.retain(|(p, _)| !visible.contains(p)); + proposed.validate()?; + *self = proposed; + Ok(()) + } + + /// Show or hide one panel. Showing restores its recorded location, following the chain of + /// hidden anchors when its own anchor is hidden too; otherwise it opens beside `fallback`. + /// A shown panel becomes the active tab of its group. + pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

) -> Result<(), DockError> { + self.validate()?; + if !visible { + if !self.layout.contains(&panel) { + return Ok(()); + } + return self.apply(Action::Close { panel }, |_| Permissions::default()); + } + if self.layout.contains(&panel) { + return self.apply(Action::Activate { panel }, |_| Permissions::default()); + } + let mut layout = self.layout.clone(); + if self.restore_into(&mut layout, &panel).is_err() { + layout = self.layout.clone(); + layout.apply(Action::Open { panel: panel.clone(), anchor: fallback })?; + } + layout.apply(Action::Activate { panel: panel.clone() })?; + self.layout = layout; + self.hidden.retain(|(p, _)| p != &panel); + Ok(()) + } + + fn restore_into(&self, layout: &mut Layout

, panel: &P) -> Result<(), DockError> { + let find = |p: &P| self.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location); + let mut location = find(panel).ok_or(DockError::MissingTarget)?; + for _ in 0..=self.hidden.len() { + match layout.restore(panel.clone(), location) { + Err(DockError::MissingTarget) => { + let next = location.anchor.as_ref().filter(|anchor| *anchor != panel).and_then(find); + location = next.ok_or(DockError::MissingTarget)?; + } + result => return result, + } + } + Err(DockError::MissingTarget) + } + + /// Fit a loaded workspace to the panels the application registers now. An invalid + /// workspace is replaced by `default`. Panels not registered now (plug-ins, on-demand or + /// numbered panels) move to `hidden` with their location, so they come back once registered; + /// the saved arrangement drops them. Registered panels in `default` that are neither shown + /// nor hidden go to their default place, or to `hidden` when that place cannot be rebuilt. + /// `hidden` keeps at most [`MAX_PANELS`] entries, dropping the oldest unregistered ones first. + pub fn reconcile(&mut self, registered: impl Fn(&P) -> bool, default: &Workspace

) { + if self.validate().is_err() { + *self = Workspace { locked: self.locked, ..default.clone() }; + } + let unknown: Vec

= self.layout.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + let locations = close_locations(&self.layout, &unknown); + for panel in &unknown { + self.layout.take(panel); + } + self.hidden.extend(locations); + if let Some(saved) = self.saved.as_mut() { + let unknown: Vec

= saved.panels().into_iter().filter(|p| !registered(p)).cloned().collect(); + for panel in unknown { + saved.take(&panel); + } + if saved.validate().is_err() { + self.saved = None; + } + } + let known: HashSet

= self.layout.panels().into_iter().chain(self.hidden.iter().map(|(p, _)| p)).cloned().collect(); + let mut missing: Vec<(P, Location

)> = Vec::new(); + for panel in default.layout.panels() { + if registered(panel) + && !known.contains(panel) + && let Ok(location) = default.layout.location(panel) + { + missing.push((panel.clone(), location)); + } + } + // Anchors may themselves be missing: place what fits, then retry the rest. + while !missing.is_empty() { + let before = missing.len(); + missing.retain(|(panel, location)| self.layout.restore(panel.clone(), location).is_err()); + if missing.len() == before { + break; + } + } + self.hidden.extend(missing); + for (panel, location) in &default.hidden { + if registered(panel) && !known.contains(panel) && !self.layout.contains(panel) && !self.hidden.iter().any(|(p, _)| p == panel) { + self.hidden.push((panel.clone(), location.clone())); + } + } + let mut excess = self.hidden.len().saturating_sub(MAX_PANELS); + self.hidden.retain(|(p, _)| { + let drop = excess > 0 && !registered(p); + excess -= usize::from(drop); + !drop + }); + self.hidden.truncate(MAX_PANELS); + } + + /// Reset: return to the saved arrangement, or `default` when there is none. Panels the + /// reset layout does not show stay hidden with their last location. + pub fn reset(&mut self, default: &Layout

) { + let layout = self.saved.clone().unwrap_or_else(|| default.clone()); + let shown: HashSet

= layout.panels().into_iter().cloned().collect(); + for panel in self.layout.panels() { + if !shown.contains(panel) + && !self.hidden.iter().any(|(p, _)| p == panel) + && let Ok(location) = self.layout.location(panel) + { + self.hidden.push((panel.clone(), location)); + } + } + self.hidden.retain(|(p, _)| !shown.contains(p)); + self.layout = layout; + } +} + +/// Where each of `closed` goes back, captured as if they closed last to first. Reopening them +/// first to last rebuilds the group: the first restores the group itself, the rest join it. +fn close_locations(layout: &Layout

, closed: &[P]) -> Vec<(P, Location

)> { + if let [panel] = closed { + return layout.location(panel).map(|location| vec![(panel.clone(), location)]).unwrap_or_default(); + } + let mut scratch = layout.clone(); + let mut locations = Vec::new(); + for panel in closed.iter().rev() { + if let Ok(location) = scratch.location(panel) { + locations.push((panel.clone(), location)); + } + scratch.take(panel); + } + locations.reverse(); + locations +} From 69a1adf699238f36379bc35d24809907c0183d22 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:13 +0000 Subject: [PATCH 007/160] docking: model, workspace and v1 fixture tests Group, column and window moves, close and restore round trips, dock validation, Lock Workspace, reconcile and reset, the random action sequence over dock layouts, and one v1 fixture per app shape that must parse and write back unchanged. --- crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/model_tests.rs | 472 +++++++++++++++++++ crates/ui/src/docking/tests.rs | 79 ++-- crates/ui/tests/fixtures/v1/cadcraft.json | 91 ++++ crates/ui/tests/fixtures/v1/designcraft.json | 123 +++++ crates/ui/tests/fixtures/v1/lightcraft.json | 67 +++ crates/ui/tests/fixtures/v1/pdfcraft.json | 51 ++ crates/ui/tests/fixtures/v1/photocraft.json | 151 ++++++ crates/ui/tests/fixtures/v1/soundcraft.json | 180 +++++++ crates/ui/tests/fixtures/v1/vectorcraft.json | 91 ++++ 10 files changed, 1279 insertions(+), 28 deletions(-) create mode 100644 crates/ui/src/docking/model_tests.rs create mode 100644 crates/ui/tests/fixtures/v1/cadcraft.json create mode 100644 crates/ui/tests/fixtures/v1/designcraft.json create mode 100644 crates/ui/tests/fixtures/v1/lightcraft.json create mode 100644 crates/ui/tests/fixtures/v1/pdfcraft.json create mode 100644 crates/ui/tests/fixtures/v1/photocraft.json create mode 100644 crates/ui/tests/fixtures/v1/soundcraft.json create mode 100644 crates/ui/tests/fixtures/v1/vectorcraft.json diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 841e2f31..304e46d2 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -17,5 +17,7 @@ pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, beg pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; pub use workspace::Workspace; +#[cfg(test)] +mod model_tests; #[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs new file mode 100644 index 00000000..bbd9ae05 --- /dev/null +++ b/crates/ui/src/docking/model_tests.rs @@ -0,0 +1,472 @@ +use super::dock_model::{Place, node_at}; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; + +pub(super) fn g(panels: &[u32], active: usize) -> Group { + Group::new(panels.to_vec(), active) +} + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +fn hsplit(size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) } +} + +/// Canvas 1 above frame tabs [11, 12]; an end dock with column A ([2, 3], [4]) and column B +/// ([5]); a floating window with groups [6, 7] and [8]. +pub(super) fn docked() -> Layout { + let middle = + Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.7), first: Box::new(tabs(&[1], 0)), second: Box::new(tabs(&[11, 12], 1)) }; + let dock = + Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2, 3], 1), g(&[4], 0)], 180.0), Column::new(vec![g(&[5], 0)], 120.0)] }; + Layout { + root: Some(hsplit(SplitSize::FixedSecond(300.0), middle, dock)), + floating: vec![Floating { groups: vec![g(&[6, 7], 1), g(&[8], 0)], rect: [100.0, 100.0, 300.0, 400.0], native: None }], + } +} + +pub(super) const ALL: [u32; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 11, 12]; + +fn sorted(layout: &Layout) -> Vec { + let mut panels: Vec = layout.panels().into_iter().copied().collect(); + panels.sort_unstable(); + panels +} + +/// Members, active panel and minimized state of the group holding `panel`. +fn group_state(layout: &Layout, panel: u32) -> (Vec, u32, bool) { + match layout.place(&panel).unwrap() { + Place::Node(path) => match node_at(layout.root.as_ref().unwrap(), &path).unwrap() { + Node::Tabs { panels, active } => (panels.clone(), panels[*active], false), + other => panic!("{other:?}"), + }, + place => { + let group = layout.group(&place).unwrap(); + (group.panels.clone(), group.panels[group.active], group.minimized) + } + } +} + +fn dock(layout: &Layout, side: Side) -> &Vec> { + let path = layout.dock_path(side).unwrap(); + let Some(Node::Dock { columns, .. }) = node_at(layout.root.as_ref().unwrap(), &path) else { panic!() }; + columns +} + +fn panels_of(groups: &[Group]) -> Vec> { + groups.iter().map(|g| g.panels.clone()).collect() +} + +#[test] +fn group_moves_keep_order_active_tab_and_minimized_state() { + let targets = [ + Target::At { anchor: 5, placement: Placement::Group { below: true } }, + Target::At { anchor: 5, placement: Placement::Column { after: true } }, + Target::At { anchor: 8, placement: Placement::Group { below: false } }, + Target::NewDock { side: Side::Start }, + Target::Float { rect: Some([20.0, 20.0, 200.0, 300.0]) }, + Target::Float { rect: None }, + ]; + // A frame Tabs node, a column group and a floating group. + for source in [11, 2, 6] { + for to in &targets { + let mut layout = docked(); + if source != 11 { + layout.apply(Action::SetMinimized { panel: source, minimized: true }).unwrap(); + } + let before = group_state(&layout, source); + layout.apply(Action::MoveGroup { panel: source, to: to.clone() }).unwrap_or_else(|e| panic!("{source} {to:?}: {e}")); + assert_eq!(group_state(&layout, source), before, "{source} {to:?}"); + assert_eq!(sorted(&layout), ALL); + } + } + // Into a frame split the group becomes a Tabs node with the same order and selection. + let mut layout = docked(); + layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }).unwrap(); + assert_eq!(group_state(&layout, 6), (vec![6, 7], 7, false)); + assert_eq!(layout.floating[0].groups.len(), 1); +} + +#[test] +fn group_merge_into_tabs_inserts_every_member_before_the_anchor_tab() { + let mut layout = docked(); + layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 2, placement: Placement::Tab { before: Some(3) } } }).unwrap(); + assert_eq!(group_state(&layout, 2), (vec![2, 6, 7, 3], 7, false)); + assert_eq!(panels_of(&layout.floating[0].groups), [vec![8]]); + + let mut layout = docked(); + layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 11, placement: Placement::Tab { before: Some(12) } } }).unwrap(); + assert_eq!(group_state(&layout, 11), (vec![11, 2, 3, 12], 3, false)); + assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![4]]); + + // A target inside the moving group is a no-op; a protected anchor refuses tabs. + let before = docked(); + let mut layout = before.clone(); + layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 3, placement: Placement::Tab { before: None } } }).unwrap(); + assert_eq!(layout, before); + let protect = |p: &u32| if *p == 1 { Permissions::PROTECTED } else { Permissions::default() }; + let to = Target::At { anchor: 1, placement: Placement::Tab { before: None } }; + assert_eq!(layout.apply_with_permissions(Action::MoveGroup { panel: 2, to }, protect), Err(DockError::NotAllowed)); + assert_eq!(layout, before); +} + +#[test] +fn column_moves_and_floats_keep_every_group() { + let mut layout = docked(); + layout.apply(Action::SetIconic { panel: 4, iconic: true }).unwrap(); + layout.apply(Action::MoveColumn { panel: 2, to: Target::NewDock { side: Side::Start } }).unwrap(); + let Some(Node::Split { size, first, .. }) = &layout.root else { panic!() }; + assert_eq!(*size, SplitSize::FixedFirst(180.0)); + assert!(matches!(**first, Node::Dock { side: Side::Start, .. })); + let start = dock(&layout, Side::Start); + assert_eq!(start.len(), 1); + assert_eq!(panels_of(&start[0].groups), [vec![2, 3], vec![4]]); + assert_eq!((start[0].width, start[0].iconic, start[0].groups[0].active), (180.0, true, 1)); + assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![5]]); + + // The last column leaves the end dock, which collapses. + layout.apply(Action::MoveColumn { panel: 5, to: Target::At { anchor: 2, placement: Placement::Column { after: false } } }).unwrap(); + assert!(layout.dock_path(Side::End).is_none()); + let start = dock(&layout, Side::Start); + assert_eq!(start.iter().map(|c| c.width).collect::>(), [120.0, 180.0]); + + layout.apply(Action::MoveColumn { panel: 3, to: Target::Float { rect: None } }).unwrap(); + let window = layout.floating.last().unwrap(); + assert_eq!(panels_of(&window.groups), [vec![2, 3], vec![4]]); + assert_eq!(window.rect[2], 180.0); + assert_eq!(sorted(&layout), ALL); + assert_eq!(layout.apply(Action::MoveColumn { panel: 11, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement)); +} + +#[test] +fn window_docks_as_stacked_groups_or_merges_as_tabs() { + let mut layout = docked(); + layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } }).unwrap(); + assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![2, 3], vec![4], vec![6, 7], vec![8]]); + assert!(layout.floating.is_empty()); + + let mut layout = docked(); + layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 11, placement: Placement::Tab { before: None } } }).unwrap(); + assert_eq!(group_state(&layout, 11), (vec![11, 12, 6, 7, 8], 7, false)); + + let mut layout = docked(); + layout.apply(Action::MoveWindow { panel: 6, to: Target::NewDock { side: Side::Start } }).unwrap(); + let start = dock(&layout, Side::Start); + assert_eq!((panels_of(&start[0].groups), start[0].width), (vec![vec![6, 7], vec![8]], 300.0)); + + let before = docked(); + let mut layout = before.clone(); + layout.apply(Action::MoveWindow { panel: 7, to: Target::At { anchor: 8, placement: Placement::Tab { before: None } } }).unwrap(); + assert_eq!(layout, before); + assert_eq!(layout.apply(Action::MoveWindow { panel: 2, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement)); +} + +#[test] +fn close_group_and_close_window_record_a_restorable_location_per_member() { + let mut workspace = Workspace::new(docked()); + let allow = |_: &u32| Permissions::default(); + workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(); + assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [2, 3]); + assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![4]]); + workspace.set_visible(2, true, None).unwrap(); + workspace.set_visible(3, true, None).unwrap(); + assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![2, 3], vec![4]]); + + assert_eq!(workspace.layout.closed_by(&Action::CloseWindow { panel: 7 }).unwrap(), [6, 7, 8]); + workspace.apply(Action::CloseWindow { panel: 7 }, allow).unwrap(); + assert!(workspace.layout.floating.is_empty()); + assert_eq!(workspace.hidden.len(), 3); + for panel in [6, 7, 8] { + workspace.set_visible(panel, true, None).unwrap(); + } + assert_eq!(workspace.layout.floating.len(), 1); + assert_eq!(panels_of(&workspace.layout.floating[0].groups), [vec![6, 7], vec![8]]); + assert_eq!(workspace.layout.floating[0].rect, [100.0, 100.0, 300.0, 400.0]); + + workspace.apply(Action::CloseOthers { panel: 12 }, allow).unwrap(); + assert_eq!(group_state(&workspace.layout, 12).0, [12]); + workspace.set_visible(11, true, None).unwrap(); + assert_eq!(group_state(&workspace.layout, 12).0, [11, 12]); + assert!(workspace.hidden.is_empty()); + assert_eq!(sorted(&workspace.layout), ALL); + + // A member that may not close keeps the whole group open. + let keep = |p: &u32| if *p == 4 { Permissions { closable: false, ..Permissions::default() } } else { Permissions::default() }; + let before = workspace.clone(); + assert_eq!(workspace.apply(Action::CloseGroup { panel: 4 }, keep), Err(DockError::NotAllowed)); + assert_eq!(workspace, before); +} + +#[test] +fn restore_into_a_column_group_seam_and_new_column_round_trips() { + let column_a = Column::new(vec![g(&[2, 3], 1), Group { minimized: true, height: Some(150.0), ..g(&[4], 0) }, g(&[9], 0)], 180.0); + let column_b = Column { iconic: true, iconic_width: Some(48.0), ..Column::new(vec![g(&[5], 0)], 120.0) }; + let end = Node::Dock { side: Side::End, columns: vec![column_a, column_b] }; + let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![g(&[10], 0)], 200.0)] }; + let original = Layout { + root: Some(hsplit(SplitSize::FixedFirst(200.0), start, hsplit(SplitSize::FixedSecond(300.0), tabs(&[1], 0), end))), + floating: vec![Floating { + groups: vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], + rect: [10.0, 10.0, 200.0, 300.0], + native: None, + }], + }; + original.validate().unwrap(); + // 4: the seam between [2, 3] and [9]; 9: below [4]; 5: a new column after A; 10: alone in + // its dock; 3: a tab; 7: below a floating group. + for panel in [4, 9, 5, 10, 3, 7] { + let mut layout = original.clone(); + let location = layout.location(&panel).unwrap(); + #[cfg(feature = "serde")] + let location: Location = serde_json::from_str(&serde_json::to_string(&location).unwrap()).unwrap(); + layout.apply(Action::Close { panel }).unwrap(); + layout.restore(panel, &location).unwrap_or_else(|e| panic!("{panel}: {e} {location:?}")); + assert_eq!(layout, original, "{panel}: {location:?}"); + } +} + +#[test] +fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad_widths() { + docked().validate().unwrap(); + let empty_dock = |side| Node::Dock { side, columns: Vec::new() }; + Layout { root: Some(hsplit(SplitSize::FixedFirst(10.0), empty_dock(Side::Start), tabs(&[1], 0))), floating: Vec::new() }.validate().unwrap(); + + let two = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::Start), empty_dock(Side::Start)); + let nested = Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(empty_dock(Side::Start)), + second: Box::new(tabs(&[1], 0)), + }; + let wrong_side = hsplit(SplitSize::Ratio(0.5), empty_dock(Side::End), tabs(&[1], 0)); + for root in [two, nested, wrong_side] { + assert_eq!(Layout { root: Some(root), floating: Vec::new() }.validate(), Err(DockError::InvalidPlacement)); + } + + let with_column = |column: Column| Layout { root: Some(Node::Dock { side: Side::End, columns: vec![column] }), floating: Vec::new() }; + let bad = [ + Column::new(Vec::new(), 100.0), + Column::new(vec![g(&[], 0)], 100.0), + Column::new(vec![g(&[2], 1)], 100.0), + Column::new(vec![Group { height: Some(f32::NAN), ..g(&[2], 0) }], 100.0), + Column::new(vec![g(&[2], 0)], 0.0), + Column::new(vec![g(&[2], 0)], -5.0), + Column::new(vec![g(&[2], 0)], f32::INFINITY), + Column::new(vec![g(&[2], 0)], 2.0e6), + Column { iconic_width: Some(f32::NAN), ..Column::new(vec![g(&[2], 0)], 100.0) }, + ]; + for column in bad { + assert!(with_column(column.clone()).validate().is_err(), "{column:?}"); + } + + let mut duplicate = docked(); + duplicate.floating[0].groups[1].panels.push(2); + assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); + let mut empty_window = docked(); + empty_window.floating[0].groups.clear(); + assert_eq!(empty_window.validate(), Err(DockError::InvalidGeometry)); + let mut native = docked(); + native.floating[0].native = Some([0.0, 0.0, -1.0, 10.0]); + assert_eq!(native.validate(), Err(DockError::InvalidGeometry)); + let mut layout = docked(); + assert_eq!(layout.apply(Action::ResizeColumn { panel: 2, width: f32::NAN }), Err(DockError::InvalidGeometry)); + assert_eq!(layout.apply(Action::ResizeColumn { panel: 11, width: 50.0 }), Err(DockError::InvalidPlacement)); + assert_eq!(layout, docked()); +} + +#[test] +fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resize() { + let mut workspace = Workspace::new(docked()); + let allow = |_: &u32| Permissions::default(); + workspace.apply(Action::Close { panel: 12 }, allow).unwrap(); + workspace.locked = true; + let before = workspace.clone(); + let refused = [ + Action::Move { panel: 2, anchor: 5, placement: Placement::Tab { before: None } }, + Action::MoveGroup { panel: 2, to: Target::Float { rect: None } }, + Action::MoveColumn { panel: 5, to: Target::NewDock { side: Side::Start } }, + Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } }, + Action::Float { panel: 4, rect: [0.0, 0.0, 100.0, 100.0] }, + Action::MoveFloating { panel: 6, rect: [0.0, 0.0, 100.0, 100.0] }, + Action::Close { panel: 4 }, + Action::CloseGroup { panel: 4 }, + Action::CloseWindow { panel: 6 }, + Action::SetNative { panel: 6, rect: Some([0.0, 0.0, 100.0, 100.0]) }, + ]; + for action in refused { + assert_eq!(workspace.apply(action.clone(), allow), Err(DockError::NotAllowed), "{action:?}"); + assert_eq!(workspace, before); + } + assert_eq!(workspace.set_visible(4, false, None), Err(DockError::NotAllowed)); + let allowed = [ + Action::Activate { panel: 2 }, + Action::Open { panel: 12, anchor: Some(11) }, + Action::SetMinimized { panel: 4, minimized: true }, + Action::SetIconic { panel: 5, iconic: true }, + Action::ResizeColumn { panel: 5, width: 160.0 }, + Action::ResizeGroup { panel: 4, height: Some(120.0) }, + Action::ResizeIconic { panel: 5, width: Some(60.0) }, + Action::ResizeSplit { path: Vec::new(), size: SplitSize::FixedSecond(320.0) }, + ]; + workspace.apply_all(allowed, allow).unwrap(); + assert!(workspace.hidden.is_empty()); + assert_eq!(workspace.layout.panel_state(&12), PanelState::Front); + assert_eq!(workspace.layout.panel_state(&4), PanelState::Open); + assert_eq!(workspace.layout.panel_state(&5), PanelState::Open); + assert_eq!(workspace.layout.panel_state(&2), PanelState::Front); + assert_eq!(workspace.layout.panel_state(&3), PanelState::Open); +} + +#[test] +fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() { + let default = Workspace::new(docked()); + let mut workspace = default.clone(); + workspace.layout.apply(Action::Close { panel: 5 }).unwrap(); + workspace.layout.apply(Action::Close { panel: 12 }).unwrap(); + workspace.layout.apply(Action::OpenAt { panel: 99, anchor: 2, placement: Placement::Tab { before: None } }).unwrap(); + let stray = workspace.layout.location(&99).unwrap(); + workspace.hidden.push((98, stray)); + let registered = |p: &u32| *p < 90; + workspace.reconcile(registered, &default); + workspace.validate().unwrap(); + // Unregistered panels wait in `hidden` and come back once registered. + assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [98, 99]); + assert_eq!(sorted(&workspace.layout), ALL); + workspace.set_visible(99, true, None).unwrap(); + assert_eq!(group_state(&workspace.layout, 2).0, [2, 3, 99]); + assert_eq!(dock(&workspace.layout, Side::End).len(), 2); + assert_eq!(group_state(&workspace.layout, 11).0, [11, 12]); + + let mut broken = default.clone(); + broken.layout.floating[0].rect[2] = -1.0; + broken.reconcile(registered, &default); + assert_eq!(broken.layout, default.layout); +} + +#[test] +fn reset_returns_to_saved_or_default() { + let default = docked(); + let mut workspace = Workspace::new(default.clone()); + workspace.apply(Action::MoveGroup { panel: 6, to: Target::NewDock { side: Side::Start } }, |_| Permissions::default()).unwrap(); + workspace.reset(&default); + assert_eq!(workspace.layout, default); + + let mut saved = default.clone(); + saved.apply(Action::Close { panel: 8 }).unwrap(); + saved.apply(Action::MoveGroup { panel: 11, to: Target::Float { rect: None } }).unwrap(); + workspace.saved = Some(saved.clone()); + workspace.reset(&default); + assert_eq!(workspace.layout, saved); + assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [8]); + workspace.validate().unwrap(); +} + +#[cfg(feature = "serde")] +mod serde_tests { + use super::*; + use serde_json::{Value, json}; + + #[test] + fn single_plain_floating_window_writes_the_v1_shape() { + let rect = [1.0, 2.0, 300.0, 200.0]; + let plain = Floating::single(vec![4u32, 5], 1, rect); + assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect})); + let minimized = Floating { groups: vec![Group { minimized: true, ..g(&[4], 0) }], rect, native: None }; + let stacked = Floating { groups: vec![g(&[4], 0), g(&[5], 0)], rect, native: None }; + let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) }; + for window in [minimized, stacked, native] { + let value = serde_json::to_value(&window).unwrap(); + assert!(value.get("groups").is_some() && value.get("panels").is_none(), "{value}"); + assert_eq!(serde_json::from_value::>(value).unwrap(), window); + } + assert!(serde_json::from_value::>(json!({"rect": rect})).is_err()); + } + + #[test] + fn v2_round_trips() { + let mut workspace = Workspace::new(docked()); + workspace.apply(Action::CloseGroup { panel: 4 }, |_| Permissions::default()).unwrap(); + workspace.apply(Action::SetNative { panel: 8, rect: Some([5.0, 5.0, 400.0, 300.0]) }, |_| Permissions::default()).unwrap(); + workspace.saved = Some(docked()); + workspace.locked = true; + let text = serde_json::to_string(&workspace).unwrap(); + let parsed: Workspace = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed, workspace); + assert_eq!(serde_json::from_str::(&text).unwrap()["version"], json!(2)); + + let mut old = parsed.clone(); + old.version = 1; + let mut value = serde_json::to_value(&old).unwrap(); + value.as_object_mut().unwrap().remove("version"); + assert_eq!(serde_json::from_value::>(value).unwrap().version, 1); + old.version = 3; + assert_eq!(old.validate(), Err(DockError::UnsupportedVersion)); + } + + #[derive(Clone, Copy, Debug)] + enum Kind { + Layout, + Workspace, + Hidden, + } + + /// App, file contents, and the JSON pointers to its docking values. + type Fixture = (&'static str, &'static str, &'static [(&'static str, Kind)]); + + #[test] + fn v1_layout_json_parses_unchanged() { + use Kind::*; + let fixtures: [Fixture; 7] = [ + ( + "designcraft", + include_str!("../../tests/fixtures/v1/designcraft.json"), + &[ + ("/docking", Layout), + ("/dockingHidden", Hidden), + ("/customWorkspaces/0/docking", Layout), + ("/customWorkspaces/0/dockingHidden", Hidden), + ], + ), + ("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), &[("/docking", Layout), ("/docking_hidden", Hidden)]), + ("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), &[("/docking", Workspace)]), + ("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), &[("/docking", Workspace)]), + ("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), &[("/workspace/docking", Workspace)]), + ("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), &[("/docking", Workspace), ("/mix_docking", Workspace)]), + ( + "photocraft", + include_str!("../../tests/fixtures/v1/photocraft.json"), + &[("/docking", Layout), ("/docking_hidden", Hidden), ("/panel_layout/docking", Layout), ("/panel_layout/dockingHidden", Hidden)], + ), + ]; + for (app, text, fields) in fixtures { + let file: Value = serde_json::from_str(text).unwrap(); + for (pointer, kind) in fields { + let value = file.pointer(pointer).unwrap_or_else(|| panic!("{app}{pointer}")).clone(); + let layout_value = match kind { + Layout => value.clone(), + Workspace => value["layout"].clone(), + Hidden => { + let hidden: std::collections::BTreeMap> = serde_json::from_value(value).unwrap(); + assert!(!hidden.is_empty(), "{app}{pointer}"); + continue; + } + }; + if let Workspace = kind { + let workspace: super::Workspace = serde_json::from_value(value).unwrap_or_else(|e| panic!("{app}{pointer}: {e}")); + workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}")); + assert_eq!(workspace.version, 1); + for (panel, location) in &workspace.hidden { + let mut layout = workspace.layout.clone(); + layout.restore(panel.clone(), location).unwrap_or_else(|e| panic!("{app}{pointer} {panel}: {e}")); + } + } + let layout: super::Layout = serde_json::from_value(layout_value.clone()).unwrap_or_else(|e| panic!("{app}{pointer}: {e}")); + layout.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}")); + // Nothing new is in use, so the layout writes back exactly as read. + assert_eq!(serde_json::to_value(&layout).unwrap(), layout_value, "{app}{pointer}"); + } + } + } +} diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 68401324..5180b76d 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -253,35 +253,58 @@ fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { #[test] fn long_action_sequence_never_loses_or_duplicates_a_panel() { - let mut layout = sample(); - let mut seed = 72u64; - for step in 0..800 { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); - let panel = (seed % 6 + 1) as u32; - let anchor = ((seed >> 16) % 6 + 1) as u32; - let action = if step % 7 == 0 { - Action::Float { panel, rect: [10.0, 20.0, 200.0, 220.0] } - } else { - move_to( - panel, - anchor, - match seed % 5 { - 0 => Zone::Left, - 1 => Zone::Right, - 2 => Zone::Top, - 3 => Zone::Bottom, - _ => Zone::Center, - }, - ) - }; - let before = layout.clone(); - if layout.apply(action).is_err() { - assert_eq!(layout, before); + for (start, ids) in [(sample(), vec![1, 2, 3, 4, 5, 6]), (super::model_tests::docked(), super::model_tests::ALL.to_vec())] { + let mut workspace = Workspace::new(start); + let mut seed = 72u64; + for step in 0..800 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + let pick = |bits: u64| ids[(bits % ids.len() as u64) as usize]; + let (panel, anchor) = (pick(seed >> 8), pick(seed >> 24)); + let placement = match (seed >> 40) % 7 { + 0 => Placement::Split(Zone::Left), + 1 => Placement::Split(Zone::Right), + 2 => Placement::Split(Zone::Top), + 3 => Placement::Split(Zone::Bottom), + 4 => Placement::Split(Zone::Center), + 5 => Placement::Group { below: seed & 1 == 0 }, + _ => Placement::Column { after: seed & 2 == 0 }, + }; + let to = match (seed >> 32) % 4 { + 0 => Target::Float { rect: None }, + 1 => Target::NewDock { side: if seed & 4 == 0 { Side::Start } else { Side::End } }, + _ => Target::At { anchor, placement: placement.clone() }, + }; + let action = match step % 13 { + 0 => Action::Float { panel, rect: [10.0, 20.0, 200.0, 220.0] }, + 1 => Action::MoveGroup { panel, to }, + 2 => Action::MoveColumn { panel, to }, + 3 => Action::MoveWindow { panel, to }, + 4 => Action::CloseGroup { panel }, + 5 => Action::CloseWindow { panel }, + 6 => Action::CloseOthers { panel }, + 7 => Action::Close { panel }, + 8 => Action::SetMinimized { panel, minimized: seed & 8 == 0 }, + 9 => Action::SetIconic { panel, iconic: seed & 16 == 0 }, + _ => Action::Move { panel, anchor, placement }, + }; + let before = workspace.clone(); + if workspace.apply(action, |_| Permissions::default()).is_err() { + assert_eq!(workspace, before); + } + if step % 3 == 0 + && let Some((hidden, _)) = workspace.hidden.first().cloned() + { + let fallback = workspace.layout.panels().first().map(|p| **p); + let before = workspace.clone(); + if workspace.set_visible(hidden, true, fallback).is_err() { + assert_eq!(workspace, before); + } + } + workspace.validate().unwrap(); + let mut panels: Vec = workspace.layout.panels().into_iter().copied().chain(workspace.hidden.iter().map(|(p, _)| *p)).collect(); + panels.sort_unstable(); + assert_eq!(panels, ids, "step {step}"); } - layout.validate().unwrap(); - let mut panels: Vec = layout.panels().into_iter().copied().collect(); - panels.sort_unstable(); - assert_eq!(panels, vec![1, 2, 3, 4, 5, 6]); } } diff --git a/crates/ui/tests/fixtures/v1/cadcraft.json b/crates/ui/tests/fixtures/v1/cadcraft.json new file mode 100644 index 00000000..996bb02e --- /dev/null +++ b/crates/ui/tests/fixtures/v1/cadcraft.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "workspace": { + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 220.0 + }, + "first": { + "Tabs": { + "panels": [ + "toolSets" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Stack": { + "entries": [ + { + "panel": "layers", + "open": true, + "height": 280.0 + }, + { + "panel": "properties", + "open": false, + "height": null + } + ] + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "quickCalc" + ], + "active": 0, + "rect": [ + 320.0, + 200.0, + 240.0, + 300.0 + ] + } + ] + }, + "hidden": [ + [ + "externalReferences", + { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "properties" + } + }, + "size": null, + "stack": [ + true, + null + ], + "sibling": [] + } + ] + ] + } + } +} diff --git a/crates/ui/tests/fixtures/v1/designcraft.json b/crates/ui/tests/fixtures/v1/designcraft.json new file mode 100644 index 00000000..ea99c2c1 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/designcraft.json @@ -0,0 +1,123 @@ +{ + "uiScale": 1.0, + "theme": "dark", + "docking": { + "root": { + "Tabs": { + "panels": [ + "properties", + "pages", + "layers", + "stroke" + ], + "active": 2 + } + }, + "floating": [ + { + "panels": [ + "swatches", + "color" + ], + "active": 1, + "rect": [ + 640.0, + 120.0, + 280.0, + 360.0 + ] + } + ] + }, + "dockingHidden": { + "gradient": { + "floating": [ + 640.0, + 120.0, + 280.0, + 360.0 + ], + "anchor": "swatches", + "placement": { + "Tab": { + "before": null + } + }, + "size": null, + "stack": null, + "sibling": [] + }, + "effects": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "stroke" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "customWorkspaces": [ + { + "name": "Print", + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 268.0 + }, + "first": { + "Tabs": { + "panels": [ + "layers" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "properties", + "pages" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "swatches" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 280.0, + 360.0 + ] + } + ] + }, + "dockingHidden": { + "stroke": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": null + } + }, + "size": null, + "stack": null, + "sibling": [] + } + } + } + ] +} diff --git a/crates/ui/tests/fixtures/v1/lightcraft.json b/crates/ui/tests/fixtures/v1/lightcraft.json new file mode 100644 index 00000000..5b558be6 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/lightcraft.json @@ -0,0 +1,67 @@ +{ + "uiScale": 1.0, + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 270.0 + }, + "first": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 268.0 + }, + "first": { + "Tabs": { + "panels": [ + "library" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + } + } + }, + "second": { + "Tabs": { + "panels": [ + "tools" + ], + "active": 0 + } + } + } + }, + "floating": [] + }, + "hidden": [ + [ + "presets", + { + "floating": null, + "anchor": "canvas", + "placement": { + "Split": "Right" + }, + "size": { + "FixedSecond": 300.0 + }, + "stack": null, + "sibling": [ + "canvas" + ] + } + ] + ] + } +} diff --git a/crates/ui/tests/fixtures/v1/pdfcraft.json b/crates/ui/tests/fixtures/v1/pdfcraft.json new file mode 100644 index 00000000..2951f997 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/pdfcraft.json @@ -0,0 +1,51 @@ +{ + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 272.0 + }, + "first": { + "Tabs": { + "panels": [ + "tools" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 330.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "inspector" + ], + "active": 0 + } + } + } + } + } + }, + "floating": [] + }, + "hidden": [] + }, + "docking_left_open": true, + "docking_right": null, + "recent": [] +} diff --git a/crates/ui/tests/fixtures/v1/photocraft.json b/crates/ui/tests/fixtures/v1/photocraft.json new file mode 100644 index 00000000..34aed08b --- /dev/null +++ b/crates/ui/tests/fixtures/v1/photocraft.json @@ -0,0 +1,151 @@ +{ + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.5 + }, + "first": { + "Tabs": { + "panels": [ + "layers", + "channels", + "paths" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "adjustments", + "history" + ], + "active": 1 + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "properties" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 320.0, + 440.0 + ] + } + ] + }, + "docking_hidden": { + "navigator": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "channels" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "panel_layout": { + "docking": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 300.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.5 + }, + "first": { + "Tabs": { + "panels": [ + "layers", + "channels", + "paths" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "adjustments", + "history" + ], + "active": 1 + } + } + } + } + } + }, + "floating": [ + { + "panels": [ + "properties" + ], + "active": 0, + "rect": [ + 120.0, + 160.0, + 320.0, + 440.0 + ] + } + ] + }, + "dockingHidden": { + "navigator": { + "floating": null, + "anchor": "layers", + "placement": { + "Tab": { + "before": "channels" + } + }, + "size": null, + "stack": null, + "sibling": [] + } + }, + "dockingCollapsed": [] + } +} diff --git a/crates/ui/tests/fixtures/v1/soundcraft.json b/crates/ui/tests/fixtures/v1/soundcraft.json new file mode 100644 index 00000000..51bd013d --- /dev/null +++ b/crates/ui/tests/fixtures/v1/soundcraft.json @@ -0,0 +1,180 @@ +{ + "docking": { + "layout": { + "root": { + "Split": { + "axis": "Vertical", + "size": { + "FixedSecond": 348.0 + }, + "first": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 168.0 + }, + "first": { + "Tabs": { + "panels": [ + "tracks" + ], + "active": 0 + } + }, + "second": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedSecond": 230.0 + }, + "first": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "clips" + ], + "active": 0 + } + } + } + } + } + }, + "second": { + "Tabs": { + "panels": [ + "midi" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "memory" + ], + "active": 0, + "rect": [ + 160.0, + 120.0, + 480.0, + 280.0 + ] + }, + { + "panels": [ + "history" + ], + "active": 0, + "rect": [ + 680.0, + 120.0, + 260.0, + 300.0 + ] + } + ] + }, + "hidden": [] + }, + "mix_docking": { + "layout": { + "root": { + "Split": { + "axis": "Horizontal", + "size": { + "FixedFirst": 168.0 + }, + "first": { + "Tabs": { + "panels": [ + "tracks" + ], + "active": 0 + } + }, + "second": { + "Tabs": { + "panels": [ + "canvas" + ], + "active": 0 + } + } + } + }, + "floating": [ + { + "panels": [ + "memory" + ], + "active": 0, + "rect": [ + 160.0, + 120.0, + 480.0, + 280.0 + ] + }, + { + "panels": [ + "history" + ], + "active": 0, + "rect": [ + 680.0, + 120.0, + 260.0, + 300.0 + ] + } + ] + }, + "hidden": [ + [ + "clips", + { + "floating": null, + "anchor": "canvas", + "placement": { + "Split": "Right" + }, + "size": { + "FixedSecond": 230.0 + }, + "stack": null, + "sibling": [ + "canvas" + ] + } + ], + [ + "midi", + { + "floating": null, + "anchor": "tracks", + "placement": { + "Split": "Bottom" + }, + "size": { + "FixedSecond": 348.0 + }, + "stack": null, + "sibling": [ + "tracks", + "canvas" + ] + } + ] + ] + } +} diff --git a/crates/ui/tests/fixtures/v1/vectorcraft.json b/crates/ui/tests/fixtures/v1/vectorcraft.json new file mode 100644 index 00000000..d9c25542 --- /dev/null +++ b/crates/ui/tests/fixtures/v1/vectorcraft.json @@ -0,0 +1,91 @@ +{ + "dock_tab": "layers", + "docking": { + "root": { + "Split": { + "axis": "Vertical", + "size": { + "Ratio": 0.625 + }, + "first": { + "Tabs": { + "panels": [ + "properties", + "layers", + "libraries" + ], + "active": 1 + } + }, + "second": { + "Stack": { + "entries": [ + { + "panel": "color", + "open": true, + "height": 180.0 + }, + { + "panel": "swatches", + "open": false, + "height": null + }, + { + "panel": "stroke", + "open": true, + "height": null + } + ] + } + } + } + }, + "floating": [ + { + "panels": [ + "brushes", + "symbols" + ], + "active": 0, + "rect": [ + 200.0, + 140.0, + 300.0, + 400.0 + ] + }, + { + "panels": [ + "gradient" + ], + "active": 0, + "rect": [ + 540.0, + 180.0, + 260.0, + 320.0 + ] + } + ] + }, + "docking_hidden": { + "patternOptions": { + "floating": null, + "anchor": "color", + "placement": { + "Tab": { + "before": "swatches" + } + }, + "size": null, + "stack": [ + true, + 240.0 + ], + "sibling": [] + } + }, + "docking_icons": [ + "stroke" + ] +} From 87ddebd73ab8e214c4d2591f74ef256d17513fbc Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:41:18 +0000 Subject: [PATCH 008/160] docking: split the renderer into render modules DockArea, DockContent, DockStyle, DockOutput and the drag state stay in surface.rs. The Renderer moves to docking/render: the node tree, limits and helpers in mod.rs, groups and tabs in group.rs, floating windows in floating.rs, and drop resolution and previews in preview.rs. --- crates/ui/src/docking/mod.rs | 3 + crates/ui/src/docking/render/floating.rs | 77 +++ crates/ui/src/docking/render/group.rs | 214 ++++++++ crates/ui/src/docking/render/mod.rs | 260 +++++++++ crates/ui/src/docking/render/preview.rs | 106 ++++ crates/ui/src/docking/surface.rs | 652 +---------------------- 6 files changed, 673 insertions(+), 639 deletions(-) create mode 100644 crates/ui/src/docking/render/floating.rs create mode 100644 crates/ui/src/docking/render/group.rs create mode 100644 crates/ui/src/docking/render/mod.rs create mode 100644 crates/ui/src/docking/render/preview.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 304e46d2..b47dd39d 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -7,6 +7,7 @@ mod dock_model; mod model; +mod render; mod surface; mod targets; mod workspace; @@ -17,6 +18,8 @@ pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, beg pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; pub use workspace::Workspace; +use surface::{Drag, GroupDrag}; + #[cfg(test)] mod model_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs new file mode 100644 index 00000000..64ca2da4 --- /dev/null +++ b/crates/ui/src/docking/render/floating.rs @@ -0,0 +1,77 @@ +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Rect, Sense, Stroke, Vec2, pos2, vec2}; + +use super::{Renderer, clamp_float}; +use crate::docking::{Action, Node}; + +impl Renderer<'_, P> { + pub(in crate::docking) fn floating(&mut self, ctx: &Context) { + let screen = ctx.content_rect(); + if !crate::docking::targets::usable(screen) { + return; + } + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut groups: Vec<_> = self.layout.floating.iter().collect(); + groups.sort_by_key(|group| { + let id = group.panels().next().map(|panel| self.id.with(("floating", panel))); + layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) + }); + for group in groups { + let Some(first) = group.panels().next() else { continue }; + let [x, y, width, height] = group.rect; + let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 }); + let size = vec2(width, height).max(limits.min).min(limits.max); + let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); + let id = self.id.with(("floating", first)); + if self.activation.as_ref().is_some_and(|panel| group.contains(panel)) { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); + self.output.focused = self.activation.clone(); + } + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { + ui.set_min_size(rect.size()); + let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); + ui.allocate_rect(rect, Sense::hover()); + self.stacked_groups(ui, &group.groups, rect, true); + let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); + let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, + ) + }); + ui.input_mut(|input| { + if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { + delta.x -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { + delta.x += 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { + delta.y -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { + delta.y += 8.0; + } + }); + } + if response.enabled() && delta != Vec2::ZERO { + let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); + } + if response.hovered() || response.dragged() || response.has_focus() { + ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent)); + } + }); + } + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs new file mode 100644 index 00000000..850a22aa --- /dev/null +++ b/crates/ui/src/docking/render/group.rs @@ -0,0 +1,214 @@ +use std::{fmt::Debug, hash::Hash}; + +use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{Renderer, clamp_float}; +use crate::docking::{Action, DropTarget, GroupDrag, Permissions, Placement, begin_drag, surface::TAB_PADDING}; +use crate::tabs; + +impl Renderer<'_, P> { + /// Groups stacked top to bottom in equal shares of `rect`. + pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[crate::docking::Group

], rect: Rect, floating: bool) { + let share = rect.height() / groups.len().max(1) as f32; + for (index, group) in groups.iter().enumerate() { + let top = rect.top() + share * index as f32; + let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); + self.group(ui, &group.panels, group.active, group_rect, floating); + } + } + + pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { + let Some(selected) = panels.get(active) else { return }; + let policy = (self.permissions)(selected); + let bare = panels.len() == 1 && policy == Permissions::PROTECTED; + if bare { + self.contents(ui, selected, rect); + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: None, + tabs: Vec::new(), + allow_splits: !floating, + accepts_tabs: false, + }); + return; + } + let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); + ui.painter().rect_filled(strip, 0.0, self.style.tab_background); + if floating { + let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); + let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + if panels.iter().all(|p| (self.permissions)(p).movable) + && response.drag_started() + && let Some(first) = panels.first() + { + ui.ctx().data_mut(|d| { + d.insert_temp( + self.id.with("group-drag"), + GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] }, + ) + }); + } + if panels.iter().all(|p| (self.permissions)(p).movable) + && response.dragged() + && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() + && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + { + let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect()); + if let Some(first) = panels.first() { + self.output + .actions + .push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); + } + } + ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); + strip.min.x = grip.right(); + } + let requested = self.content.group_header_width(panels, selected, floating); + let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; + if header_width > 0.0 { + let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max); + let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header)); + child.set_clip_rect(header.intersect(ui.clip_rect())); + self.content.group_header(&mut child, panels, selected, floating); + } + let widths: Vec = panels + .iter() + .map(|p| { + self.content + .label(ui, p, &(self.label)(p), &self.style.font, self.style.text) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) + .size() + .x + + TAB_PADDING + }) + .collect(); + let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); + let mut x = strip.left(); + let mut targets = Vec::new(); + for (index, width) in &fit.shown { + let Some(panel) = panels.get(*index) else { continue }; + let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); + self.tab(ui, panel, tab, *index == active, rect); + targets.push((panel.clone(), tab)); + x += width; + } + if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { + let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); + let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); + ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); + egui::Popup::menu(&response).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for index in &fit.overflow { + if let Some(panel) = panels.get(*index) + && self.menu_label(ui, panel).clicked() + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + ui.close(); + } + } + }); + }); + } + // Include hidden tab identities for source-group detection without creating hit areas. + for panel in panels { + if !targets.iter().any(|(p, _)| p == panel) { + targets.push((panel.clone(), Rect::NOTHING)); + } + } + self.targets.push(DropTarget { + anchor: selected.clone(), + rect, + tab_strip: Some(strip), + tabs: targets, + allow_splits: !floating, + accepts_tabs: policy.accepts_tabs, + }); + self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + } + + pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { + let logical = (self.label)(panel); + let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); + response + } + + pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { + let policy = (self.permissions)(panel); + let label = (self.label)(panel); + let color = if active { self.style.text } else { self.style.inactive_text }; + // A tab squeezed below its natural width cuts its label with '…' inside the padding its + // natural width was measured with, instead of letting it run under its neighbours. + let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + (rect.width() - TAB_PADDING).max(0.0), + self.style.font.clone(), + ); + let elided = galley.elided; + let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) + .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) + .focus_stroke(Stroke::new(1.0, self.style.accent)) + .show_at(ui, rect, |ui, response| { + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); + painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); + }); + let response = if elided { response.on_hover_text(&label) } else { response }; + self.register_interaction(ui.ctx(), response.id); + if response.clicked() { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + if policy.movable && response.drag_started() { + begin_drag(ui.ctx(), self.id, panel.clone(), group); + } + response.context_menu(|ui| { + self.content.panel_menu(ui, panel); + if policy.movable && policy.floatable { + let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); + if response.clicked() { + let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); + ui.close(); + } + } + if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() { + let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); + let response = ui.menu_button(text, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in self.layout.move_targets(panel, self.permissions) { + if self.menu_label(ui, target).clicked() { + self.output.actions.push(Action::Move { + panel: panel.clone(), + anchor: target.clone(), + placement: Placement::Tab { before: None }, + }); + ui.close(); + } + } + }); + }); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + } + if policy.closable { + let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.close_label)); + if response.clicked() { + self.output.actions.push(Action::Close { panel: panel.clone() }); + ui.close(); + } + } + }); + } +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs new file mode 100644 index 00000000..c5a031f9 --- /dev/null +++ b/crates/ui/src/docking/render/mod.rs @@ -0,0 +1,260 @@ +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; + +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}; + +use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; + +mod floating; +mod group; +mod preview; + +pub(super) struct Renderer<'a, P> { + pub(super) id: Id, + pub(super) style: &'a DockStyle, + pub(super) label: &'a dyn Fn(&P) -> String, + pub(super) permissions: &'a dyn Fn(&P) -> Permissions, + pub(super) limits: &'a dyn Fn(&P) -> PanelLimits, + pub(super) content: &'a mut dyn DockContent

, + pub(super) activation: Option

, + pub(super) blockers: Vec, + pub(super) layout: &'a Layout

, + pub(super) targets: Vec>, + pub(super) owned_interactions: Vec, + pub(super) output: DockOutput

, +} + +impl Renderer<'_, P> { + pub(super) fn register_interaction(&mut self, ctx: &Context, id: Id) { + // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, + // floating grips/corners and splits fit comfortably inside this fixed bound. + const CAP: usize = 8 * crate::docking::MAX_PANELS; + if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) { + self.owned_interactions.push(id); + } + // Retain prior-frame controls until return, and publish current controls before + // app callbacks. No Context lock spans a callback or a stop_dragging call. + ctx.data_mut(|d| { + let ids = d.get_temp_mut_or_default::>(self.id.with("owned-interactions")); + if ids.len() < CAP && !ids.contains(&id) { + ids.push(id); + } + }); + } + + pub(in crate::docking) fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { + if !crate::docking::targets::usable(rect) { + return; + } + match node { + Node::Split { axis, size, first, second } => { + let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; + let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); + let first_size = wanted.first_size().clamp(lo, hi); + let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; + path.push(false); + self.node(ui, first, split.first, path); + path.pop(); + path.push(true); + self.node(ui, second, split.second, path); + path.pop(); + // The divider's inner-edge hit margin must win over child scrollbars, just + // as accordion dividers win over the adjacent panel's content. + let response = ResizeHandle::new( + self.id.with(("split", &*path)), + *axis, + split.divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(split.first_size(), lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 + && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) + { + self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); + } + } + Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), + Node::Dock { columns, .. } => { + // Interim rendering: columns side by side, groups stacked evenly. + let total: f32 = columns.iter().map(|c| c.width).sum(); + let mut left = rect.left(); + for column in columns { + let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; + let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); + left += width; + self.stacked_groups(ui, &column.groups, column_rect, false); + } + } + Node::Stack { entries } => { + let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); + let remaining = (rect.height() - total_header).max(0.0); + let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); + let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); + let mut top = rect.top(); + let mut dividers = Vec::new(); + for (index, entry) in entries.iter().enumerate() { + let share = if entry.open && total_wanted > 0.0 { + (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 + } else { + 0.0 + }; + let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); + let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); + self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); + let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); + let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); + response.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel)) + }); + ui.painter().with_clip_rect(collapse).text( + collapse.center(), + egui::Align2::CENTER_CENTER, + if entry.open { "−" } else { "+" }, + self.style.font.clone(), + self.style.text, + ); + if response.clicked() { + self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open }); + } + let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max); + if entry.open { + self.contents(ui, &entry.panel, body_rect); + } + self.targets.push(DropTarget { + anchor: entry.panel.clone(), + rect: panel_rect, + tab_strip: Some(title_rect), + tabs: vec![(entry.panel.clone(), title_rect)], + allow_splits: true, + accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, + }); + top = panel_rect.bottom(); + if entry.open && remaining > 0.0 { + dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect))); + } + } + // Register dividers after the headers so their narrow hit regions win at the + // shared boundary. Closed entries retain their saved sizes and are skipped. + for (index, divider) in dividers { + let Some(first) = entries.get(index) else { continue }; + let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue }; + let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0)); + let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0)); + let scale = f64::from(remaining) / total_wanted; + let first_size = (a * scale) as f32; + let pair_size = ((a + b) * scale) as f32; + let body_limits = |panel: &P| { + let mut limits = (self.limits)(panel); + limits.min.y = limits.min.y.max(self.style.min_pane); + limits.max = limits.max.max(limits.min); + limits + }; + let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel)); + let response = ResizeHandle::new( + self.id.with(("stack-resize", &first.panel)), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(first_size, lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 && scale > 0.0 { + let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale; + let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0)); + self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) }); + self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) }); + } + } + } + } + } + + pub(super) fn node_limits(&self, node: &Node

) -> PanelLimits { + match node { + Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() }, + Node::Tabs { panels, .. } => { + let mut limits = PanelLimits::default(); + for panel in panels { + let policy = (self.limits)(panel); + limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); + limits.max = limits.max.min(policy.max); + } + limits.max = limits.max.max(limits.min); + if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) { + limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); + limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); + } + limits + } + Node::Stack { entries } => { + let mut limits = PanelLimits::default(); + for entry in entries { + let policy = (self.limits)(&entry.panel); + limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); + limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; + limits.max.x = limits.max.x.min(policy.max.x); + } + limits.min = limits.min.min(Vec2::splat(1_000_000.0)); + limits.max = limits.max.max(limits.min); + limits + } + Node::Split { axis, first, second, .. } => { + let a = self.node_limits(first); + let b = self.node_limits(second); + let limits = match axis { + SplitAxis::Horizontal => PanelLimits { + min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)), + max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)), + }, + SplitAxis::Vertical => PanelLimits { + min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap), + max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap), + }, + }; + PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } + } + } + } + + pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { + if !crate::docking::targets::usable(rect) { + return; + } + ui.painter().rect_filled(rect, 0.0, self.style.background); + let mut child = + ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + self.content.body(&mut child, panel); + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { + self.output.focused = Some(panel.clone()); + } + } +} + +pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect { + if !crate::docking::targets::usable(screen) { + return rect; + } + let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); + let min = rect.min.max(screen.min).min(screen.max - size); + Rect::from_min_size(min, size) +} + +pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { + let (a_min, a_max, b_min, b_max) = match axis { + SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x), + SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y), + }; + let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 }; + let lo = (a_min * scale).min(available); + let hi = (available - b_min * scale).max(lo).min(available); + let constrained_lo = lo.max(available - b_max); + let constrained_hi = hi.min(a_max); + if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs new file mode 100644 index 00000000..e4e427ed --- /dev/null +++ b/crates/ui/src/docking/render/preview.rs @@ -0,0 +1,106 @@ +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Rect, Stroke, StrokeKind}; + +use super::{Renderer, clamp_float}; +use crate::docking::{Action, Drag, GroupDrag, cancel_drag, resolve_drop}; + +impl Renderer<'_, P> { + pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { + let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; + if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) { + ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + return; + } + let (escape, focused, down, released, pointer) = + ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); + // A button release outside the window may never reach egui. + let has_pointer = ctx.input(|i| i.pointer.has_pointer()); + // Touch-up also removes the pointer, but still completes a drop. + let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); + if escape || !focused || !down || !has_pointer { + cancel_drag::

(ctx, self.id); + if escape || !focused { + self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); + } else if released + && (has_pointer || touch_ended) + && let Some(pointer) = pointer.filter(|p| p.is_finite()) + && !self.blockers.iter().any(|r| r.contains(pointer)) + { + self.output.group_drop = Some((drag.panel, pointer)); + } + } else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) { + self.output.group_drag = Some(drag.panel); + } + } + + pub(in crate::docking) fn finish_drag(&mut self, ctx: &Context) { + let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; + let (pointer, down, released, focused, gone) = + ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer())); + // Leaving the window is the drop: on X11 and Windows the release outside never reaches + // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the + // last position this frame, and the panel floats there rather than docking at the edge. + let released = released || gone; + let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { + cancel_drag::

(ctx, self.id); + return; + } + let Some(pointer) = pointer.filter(|p| p.is_finite()) else { + if released { + cancel_drag::

(ctx, self.id); + } + return; + }; + if self.blockers.iter().any(|rect| rect.contains(pointer)) { + if released { + cancel_drag::

(ctx, self.id); + } + return; + } + let proposal = resolve_drop(&drag.panel, pointer, &self.targets); + if released { + cancel_drag::

(ctx, self.id); + if pointer.distance(drag.origin) < 2.0 { + return; + } + let action = if let Some(proposal) = proposal.filter(|_| !gone) { + Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) + } else if (self.permissions)(&drag.panel).floatable + && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) + { + let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); + Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) + } else { + None + }; + if let Some(action) = action { + // Validate the proposed gesture against the same complete snapshot. The app + // still applies it via its own command path after body rendering finishes. + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } + return; + } + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview"))); + if let Some(proposal) = proposal { + painter.rect( + proposal.preview.shrink(1.0), + 0.0, + self.style.accent.gamma_multiply(0.15), + Stroke::new(2.0, self.style.accent), + StrokeKind::Inside, + ); + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + } else if (self.permissions)(&drag.panel).floatable { + let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + } + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 545bc432..3d034faf 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -1,16 +1,11 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Color32, Context, FontId, Id, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2}; +use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use crate::{ - layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}, - tabs, -}; - -use super::{Action, DockError, DropTarget, Layout, Node, Permissions, Placement, resolve_drop}; +use super::{Action, DockError, Layout, Node, Permissions, render::Renderer}; /// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. -const TAB_PADDING: f32 = 24.0; +pub(super) const TAB_PADDING: f32 = 24.0; /// Application-supplied chrome. Body renderers retain their own UI style and contents. #[derive(Clone, Debug)] @@ -59,7 +54,7 @@ impl DockStyle { } } - fn valid(&self) -> bool { + pub(super) fn valid(&self) -> bool { [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 @@ -98,7 +93,7 @@ impl Default for PanelLimits { } impl PanelLimits { - fn valid(self) -> bool { + pub(super) fn valid(self) -> bool { self.min.is_finite() && self.max.is_finite() && self.min.x >= 0.0 @@ -111,17 +106,17 @@ impl PanelLimits { } #[derive(Clone)] -struct Drag

{ - panel: P, - origin: Pos2, - source: Rect, - grab: Vec2, +pub(super) struct Drag

{ + pub(super) panel: P, + pub(super) origin: Pos2, + pub(super) source: Rect, + pub(super) grab: Vec2, } #[derive(Clone)] -struct GroupDrag

{ - panel: P, - original: [f32; 4], +pub(super) struct GroupDrag

{ + pub(super) panel: P, + pub(super) original: [f32; 4], } /// Start a docking drag from an application's existing tab/header. Use the same area ID when @@ -335,624 +330,3 @@ impl DockArea

{ renderer.output } } - -struct Renderer<'a, P> { - id: Id, - style: &'a DockStyle, - label: &'a dyn Fn(&P) -> String, - permissions: &'a dyn Fn(&P) -> Permissions, - limits: &'a dyn Fn(&P) -> PanelLimits, - content: &'a mut dyn DockContent

, - activation: Option

, - blockers: Vec, - layout: &'a Layout

, - targets: Vec>, - owned_interactions: Vec, - output: DockOutput

, -} - -impl Renderer<'_, P> { - fn register_interaction(&mut self, ctx: &Context, id: Id) { - // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, - // floating grips/corners and splits fit comfortably inside this fixed bound. - const CAP: usize = 8 * super::MAX_PANELS; - if self.owned_interactions.len() < CAP && !self.owned_interactions.contains(&id) { - self.owned_interactions.push(id); - } - // Retain prior-frame controls until return, and publish current controls before - // app callbacks. No Context lock spans a callback or a stop_dragging call. - ctx.data_mut(|d| { - let ids = d.get_temp_mut_or_default::>(self.id.with("owned-interactions")); - if ids.len() < CAP && !ids.contains(&id) { - ids.push(id); - } - }); - } - - fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { - if !super::targets::usable(rect) { - return; - } - match node { - Node::Split { axis, size, first, second } => { - let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; - let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); - let first_size = wanted.first_size().clamp(lo, hi); - let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; - path.push(false); - self.node(ui, first, split.first, path); - path.pop(); - path.push(true); - self.node(ui, second, split.second, path); - path.pop(); - // The divider's inner-edge hit margin must win over child scrollbars, just - // as accordion dividers win over the adjacent panel's content. - let response = ResizeHandle::new( - self.id.with(("split", &*path)), - *axis, - split.divider, - &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, - ) - .value(split.first_size(), lo..=hi) - .show(ui); - self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 - && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) - { - self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); - } - } - Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), - Node::Dock { columns, .. } => { - // Interim rendering: columns side by side, groups stacked evenly. - let total: f32 = columns.iter().map(|c| c.width).sum(); - let mut left = rect.left(); - for column in columns { - let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; - let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); - left += width; - self.stacked_groups(ui, &column.groups, column_rect, false); - } - } - Node::Stack { entries } => { - let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); - let remaining = (rect.height() - total_header).max(0.0); - let total_wanted: f64 = entries.iter().filter(|e| e.open).map(|e| f64::from(e.height.unwrap_or(self.style.min_pane).max(1.0))).sum(); - let header = self.style.tab_height.min(rect.height() / entries.len().max(1) as f32); - let mut top = rect.top(); - let mut dividers = Vec::new(); - for (index, entry) in entries.iter().enumerate() { - let share = if entry.open && total_wanted > 0.0 { - (f64::from(remaining) * f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) / total_wanted) as f32 - } else { - 0.0 - }; - let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); - let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); - self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); - let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); - let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); - response.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel)) - }); - ui.painter().with_clip_rect(collapse).text( - collapse.center(), - egui::Align2::CENTER_CENTER, - if entry.open { "−" } else { "+" }, - self.style.font.clone(), - self.style.text, - ); - if response.clicked() { - self.output.actions.push(Action::SetStackOpen { panel: entry.panel.clone(), open: !entry.open }); - } - let body_rect = Rect::from_min_max(pos2(rect.left(), title_rect.bottom()), panel_rect.max); - if entry.open { - self.contents(ui, &entry.panel, body_rect); - } - self.targets.push(DropTarget { - anchor: entry.panel.clone(), - rect: panel_rect, - tab_strip: Some(title_rect), - tabs: vec![(entry.panel.clone(), title_rect)], - allow_splits: true, - accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, - }); - top = panel_rect.bottom(); - if entry.open && remaining > 0.0 { - dividers.push((index, Rect::from_min_max(pos2(rect.left(), top - 1.0), pos2(rect.right(), top + 1.0)).intersect(rect))); - } - } - // Register dividers after the headers so their narrow hit regions win at the - // shared boundary. Closed entries retain their saved sizes and are skipped. - for (index, divider) in dividers { - let Some(first) = entries.get(index) else { continue }; - let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue }; - let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0)); - let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0)); - let scale = f64::from(remaining) / total_wanted; - let first_size = (a * scale) as f32; - let pair_size = ((a + b) * scale) as f32; - let body_limits = |panel: &P| { - let mut limits = (self.limits)(panel); - limits.min.y = limits.min.y.max(self.style.min_pane); - limits.max = limits.max.max(limits.min); - limits - }; - let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel)); - let response = ResizeHandle::new( - self.id.with(("stack-resize", &first.panel)), - SplitAxis::Vertical, - divider, - &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, - ) - .value(first_size, lo..=hi) - .show(ui); - self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 && scale > 0.0 { - let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale; - let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0)); - self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) }); - self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) }); - } - } - } - } - } - - /// Groups stacked top to bottom in equal shares of `rect`. - fn stacked_groups(&mut self, ui: &mut Ui, groups: &[super::Group

], rect: Rect, floating: bool) { - let share = rect.height() / groups.len().max(1) as f32; - for (index, group) in groups.iter().enumerate() { - let top = rect.top() + share * index as f32; - let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); - self.group(ui, &group.panels, group.active, group_rect, floating); - } - } - - fn node_limits(&self, node: &Node

) -> PanelLimits { - match node { - Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() }, - Node::Tabs { panels, .. } => { - let mut limits = PanelLimits::default(); - for panel in panels { - let policy = (self.limits)(panel); - limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); - limits.max = limits.max.min(policy.max); - } - limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) { - limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); - limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); - } - limits - } - Node::Stack { entries } => { - let mut limits = PanelLimits::default(); - for entry in entries { - let policy = (self.limits)(&entry.panel); - limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); - limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; - limits.max.x = limits.max.x.min(policy.max.x); - } - limits.min = limits.min.min(Vec2::splat(1_000_000.0)); - limits.max = limits.max.max(limits.min); - limits - } - Node::Split { axis, first, second, .. } => { - let a = self.node_limits(first); - let b = self.node_limits(second); - let limits = match axis { - SplitAxis::Horizontal => PanelLimits { - min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)), - max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)), - }, - SplitAxis::Vertical => PanelLimits { - min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap), - max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap), - }, - }; - PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } - } - } - } - - fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { - if !super::targets::usable(rect) { - return; - } - ui.painter().rect_filled(rect, 0.0, self.style.background); - let mut child = - ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); - child.set_clip_rect(rect.intersect(ui.clip_rect())); - self.content.body(&mut child, panel); - if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { - self.output.focused = Some(panel.clone()); - } - } - - fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { - let Some(selected) = panels.get(active) else { return }; - let policy = (self.permissions)(selected); - let bare = panels.len() == 1 && policy == Permissions::PROTECTED; - if bare { - self.contents(ui, selected, rect); - self.targets.push(DropTarget { - anchor: selected.clone(), - rect, - tab_strip: None, - tabs: Vec::new(), - allow_splits: !floating, - accepts_tabs: false, - }); - return; - } - let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); - ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if floating { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); - let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if panels.iter().all(|p| (self.permissions)(p).movable) - && response.drag_started() - && let Some(first) = panels.first() - { - ui.ctx().data_mut(|d| { - d.insert_temp( - self.id.with("group-drag"), - GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] }, - ) - }); - } - if panels.iter().all(|p| (self.permissions)(p).movable) - && response.dragged() - && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect()); - if let Some(first) = panels.first() { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); - strip.min.x = grip.right(); - } - let requested = self.content.group_header_width(panels, selected, floating); - let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; - if header_width > 0.0 { - let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max); - let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header)); - child.set_clip_rect(header.intersect(ui.clip_rect())); - self.content.group_header(&mut child, panels, selected, floating); - } - let widths: Vec = panels - .iter() - .map(|p| { - self.content - .label(ui, p, &(self.label)(p), &self.style.font, self.style.text) - .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) - .size() - .x - + TAB_PADDING - }) - .collect(); - let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); - let mut x = strip.left(); - let mut targets = Vec::new(); - for (index, width) in &fit.shown { - let Some(panel) = panels.get(*index) else { continue }; - let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); - self.tab(ui, panel, tab, *index == active, rect); - targets.push((panel.clone(), tab)); - x += width; - } - if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { - let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); - let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); - ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); - egui::Popup::menu(&response).show(|ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for index in &fit.overflow { - if let Some(panel) = panels.get(*index) - && self.menu_label(ui, panel).clicked() - { - self.output.actions.push(Action::Activate { panel: panel.clone() }); - self.output.focused = Some(panel.clone()); - ui.close(); - } - } - }); - }); - } - // Include hidden tab identities for source-group detection without creating hit areas. - for panel in panels { - if !targets.iter().any(|(p, _)| p == panel) { - targets.push((panel.clone(), Rect::NOTHING)); - } - } - self.targets.push(DropTarget { - anchor: selected.clone(), - rect, - tab_strip: Some(strip), - tabs: targets, - allow_splits: !floating, - accepts_tabs: policy.accepts_tabs, - }); - self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); - ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); - } - - fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { - let logical = (self.label)(panel); - let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); - response - } - - fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { - let policy = (self.permissions)(panel); - let label = (self.label)(panel); - let color = if active { self.style.text } else { self.style.inactive_text }; - // A tab squeezed below its natural width cuts its label with '…' inside the padding its - // natural width was measured with, instead of letting it run under its neighbours. - let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - (rect.width() - TAB_PADDING).max(0.0), - self.style.font.clone(), - ); - let elided = galley.elided; - let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) - .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) - .focus_stroke(Stroke::new(1.0, self.style.accent)) - .show_at(ui, rect, |ui, response| { - let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); - painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); - painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); - }); - let response = if elided { response.on_hover_text(&label) } else { response }; - self.register_interaction(ui.ctx(), response.id); - if response.clicked() { - self.output.actions.push(Action::Activate { panel: panel.clone() }); - self.output.focused = Some(panel.clone()); - } - if policy.movable && response.drag_started() { - begin_drag(ui.ctx(), self.id, panel.clone(), group); - } - response.context_menu(|ui| { - self.content.panel_menu(ui, panel); - if policy.movable && policy.floatable { - let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); - if response.clicked() { - let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); - self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); - ui.close(); - } - } - if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() { - let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); - let response = ui.menu_button(text, |ui| { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for target in self.layout.move_targets(panel, self.permissions) { - if self.menu_label(ui, target).clicked() { - self.output.actions.push(Action::Move { - panel: panel.clone(), - anchor: target.clone(), - placement: Placement::Tab { before: None }, - }); - ui.close(); - } - } - }); - }); - response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - } - if policy.closable { - let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); - let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.close_label)); - if response.clicked() { - self.output.actions.push(Action::Close { panel: panel.clone() }); - ui.close(); - } - } - }); - } - - fn floating(&mut self, ctx: &Context) { - let screen = ctx.content_rect(); - if !super::targets::usable(screen) { - return; - } - let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut groups: Vec<_> = self.layout.floating.iter().collect(); - groups.sort_by_key(|group| { - let id = group.panels().next().map(|panel| self.id.with(("floating", panel))); - layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) - }); - for group in groups { - let Some(first) = group.panels().next() else { continue }; - let [x, y, width, height] = group.rect; - let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 }); - let size = vec2(width, height).max(limits.min).min(limits.max); - let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); - let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| group.contains(panel)) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); - ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); - self.output.focused = self.activation.clone(); - } - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); - ui.allocate_rect(rect, Sense::hover()); - self.stacked_groups(ui, &group.groups, rect, true); - let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); - let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); - if response.drag_started() { - response.request_focus(); - } - let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { Vec2::ZERO }; - if response.has_focus() { - ui.memory_mut(|memory| { - memory.set_focus_lock_filter( - response.id, - egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, - ) - }); - ui.input_mut(|input| { - if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { - delta.x -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { - delta.x += 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { - delta.y -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { - delta.y += 8.0; - } - }); - } - if response.enabled() && delta != Vec2::ZERO { - let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); - } - if response.hovered() || response.dragged() || response.has_focus() { - ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent)); - } - }); - } - } - - fn finish_group_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); - return; - } - let (escape, focused, down, released, pointer) = - ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); - // A button release outside the window may never reach egui. - let has_pointer = ctx.input(|i| i.pointer.has_pointer()); - // Touch-up also removes the pointer, but still completes a drop. - let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); - if escape || !focused || !down || !has_pointer { - cancel_drag::

(ctx, self.id); - if escape || !focused { - self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); - } else if released - && (has_pointer || touch_ended) - && let Some(pointer) = pointer.filter(|p| p.is_finite()) - && !self.blockers.iter().any(|r| r.contains(pointer)) - { - self.output.group_drop = Some((drag.panel, pointer)); - } - } else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) { - self.output.group_drag = Some(drag.panel); - } - } - - fn finish_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; - let (pointer, down, released, focused, gone) = - ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer())); - // Leaving the window is the drop: on X11 and Windows the release outside never reaches - // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the - // last position this frame, and the panel floats there rather than docking at the edge. - let released = released || gone; - let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); - if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { - cancel_drag::

(ctx, self.id); - return; - } - let Some(pointer) = pointer.filter(|p| p.is_finite()) else { - if released { - cancel_drag::

(ctx, self.id); - } - return; - }; - if self.blockers.iter().any(|rect| rect.contains(pointer)) { - if released { - cancel_drag::

(ctx, self.id); - } - return; - } - let proposal = resolve_drop(&drag.panel, pointer, &self.targets); - if released { - cancel_drag::

(ctx, self.id); - if pointer.distance(drag.origin) < 2.0 { - return; - } - let action = if let Some(proposal) = proposal.filter(|_| !gone) { - Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) - } else if (self.permissions)(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) - { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); - Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) - } else { - None - }; - if let Some(action) = action { - // Validate the proposed gesture against the same complete snapshot. The app - // still applies it via its own command path after body rendering finishes. - let mut candidate = self.layout.clone(); - if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() { - self.output.actions.push(action); - self.output.focused = Some(drag.panel); - } - } - return; - } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview"))); - if let Some(proposal) = proposal { - painter.rect( - proposal.preview.shrink(1.0), - 0.0, - self.style.accent.gamma_multiply(0.15), - Stroke::new(2.0, self.style.accent), - StrokeKind::Inside, - ); - if let Some(marker) = proposal.insertion { - painter.rect_filled(marker, 0.0, self.style.accent); - } - } else if (self.permissions)(&drag.panel).floatable { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); - painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); - } - } -} - -fn clamp_float(rect: Rect, screen: Rect) -> Rect { - if !super::targets::usable(screen) { - return rect; - } - let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); - let min = rect.min.max(screen.min).min(screen.max - size); - Rect::from_min_size(min, size) -} - -fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { - let (a_min, a_max, b_min, b_max) = match axis { - SplitAxis::Horizontal => (first.min.x, first.max.x, second.min.x, second.max.x), - SplitAxis::Vertical => (first.min.y, first.max.y, second.min.y, second.max.y), - }; - let scale = if a_min + b_min > available { available / (a_min + b_min).max(1.0) } else { 1.0 }; - let lo = (a_min * scale).min(available); - let hi = (available - b_min * scale).max(lo).min(available); - let constrained_lo = lo.max(available - b_max); - let constrained_hi = hi.min(a_max); - if constrained_lo <= constrained_hi { (constrained_lo, constrained_hi) } else { (lo, hi) } -} From 0784873c068d841b87bae83893e95043ef2b5240 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:42:49 +0000 Subject: [PATCH 009/160] docking: per-panel policy on DockContent and DockArea::show_content DockContent gains default methods for the logical label, permissions, limits, floating size, icon, native-window permission, bare panels and a host drop hook. show_content reads all of them from the content; the closures passed to the older show functions override the label, permission and limit defaults. floating_size, resolve_drag, DropRequest and DropFeedback are ported from storytold/craft-libs#110 by @Zenodeon. Co-authored-by: Zenodeon --- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/render/group.rs | 20 ++-- crates/ui/src/docking/render/mod.rs | 29 ++++-- crates/ui/src/docking/render/preview.rs | 8 +- crates/ui/src/docking/surface.rs | 126 ++++++++++++++++++++++-- 5 files changed, 155 insertions(+), 30 deletions(-) diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b47dd39d..b08872b9 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -14,7 +14,7 @@ mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; -pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag}; +pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 850a22aa..3dca5511 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -19,7 +19,7 @@ impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { let Some(selected) = panels.get(active) else { return }; - let policy = (self.permissions)(selected); + let policy = self.permissions(selected); let bare = panels.len() == 1 && policy == Permissions::PROTECTED; if bare { self.contents(ui, selected, rect); @@ -40,7 +40,7 @@ impl Renderer<'_, P> { let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if panels.iter().all(|p| (self.permissions)(p).movable) + if panels.iter().all(|p| self.permissions(p).movable) && response.drag_started() && let Some(first) = panels.first() { @@ -51,7 +51,7 @@ impl Renderer<'_, P> { ) }); } - if panels.iter().all(|p| (self.permissions)(p).movable) + if panels.iter().all(|p| self.permissions(p).movable) && response.dragged() && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) @@ -78,7 +78,7 @@ impl Renderer<'_, P> { .iter() .map(|p| { self.content - .label(ui, p, &(self.label)(p), &self.style.font, self.style.text) + .label(ui, p, &self.label_of(p), &self.style.font, self.style.text) .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone()) .size() .x @@ -133,7 +133,7 @@ impl Renderer<'_, P> { } pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { - let logical = (self.label)(panel); + let logical = self.label_of(panel); let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); @@ -141,8 +141,8 @@ impl Renderer<'_, P> { } pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { - let policy = (self.permissions)(panel); - let label = (self.label)(panel); + let policy = self.permissions(panel); + let label = self.label_of(panel); let color = if active { self.style.text } else { self.style.inactive_text }; // A tab squeezed below its natural width cuts its label with '…' inside the padding its // natural width was measured with, instead of letting it run under its neighbours. @@ -182,11 +182,13 @@ impl Renderer<'_, P> { ui.close(); } } - if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() { + let targets: Vec

= + if policy.movable { self.layout.move_targets(panel, |p| self.permissions(p)).into_iter().cloned().collect() } else { Vec::new() }; + if !targets.is_empty() { let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); let response = ui.menu_button(text, |ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for target in self.layout.move_targets(panel, self.permissions) { + for target in &targets { if self.menu_label(ui, target).clicked() { self.output.actions.push(Action::Move { panel: panel.clone(), diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index c5a031f9..b0c62a52 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -13,19 +13,30 @@ mod preview; pub(super) struct Renderer<'a, P> { pub(super) id: Id, pub(super) style: &'a DockStyle, - pub(super) label: &'a dyn Fn(&P) -> String, - pub(super) permissions: &'a dyn Fn(&P) -> Permissions, - pub(super) limits: &'a dyn Fn(&P) -> PanelLimits, pub(super) content: &'a mut dyn DockContent

, pub(super) activation: Option

, pub(super) blockers: Vec, pub(super) layout: &'a Layout

, + /// The docked area, for host drop requests. + pub(super) workspace: Rect, pub(super) targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, } impl Renderer<'_, P> { + pub(super) fn label_of(&self, panel: &P) -> String { + self.content.logical_label(panel) + } + + pub(super) fn permissions(&self, panel: &P) -> Permissions { + self.content.permissions(panel) + } + + pub(super) fn limits(&self, panel: &P) -> PanelLimits { + self.content.limits(panel) + } + pub(super) fn register_interaction(&mut self, ctx: &Context, id: Id) { // A validated tree has at most 2*MAX_PANELS nodes. Tabs, stack dividers, // floating grips/corners and splits fit comfortably inside this fixed bound. @@ -108,7 +119,7 @@ impl Renderer<'_, P> { let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); response.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, (self.label)(&entry.panel)) + egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, self.label_of(&entry.panel)) }); ui.painter().with_clip_rect(collapse).text( collapse.center(), @@ -130,7 +141,7 @@ impl Renderer<'_, P> { tab_strip: Some(title_rect), tabs: vec![(entry.panel.clone(), title_rect)], allow_splits: true, - accepts_tabs: (self.permissions)(&entry.panel).accepts_tabs, + accepts_tabs: self.permissions(&entry.panel).accepts_tabs, }); top = panel_rect.bottom(); if entry.open && remaining > 0.0 { @@ -148,7 +159,7 @@ impl Renderer<'_, P> { let first_size = (a * scale) as f32; let pair_size = ((a + b) * scale) as f32; let body_limits = |panel: &P| { - let mut limits = (self.limits)(panel); + let mut limits = self.limits(panel); limits.min.y = limits.min.y.max(self.style.min_pane); limits.max = limits.max.max(limits.min); limits @@ -181,12 +192,12 @@ impl Renderer<'_, P> { Node::Tabs { panels, .. } => { let mut limits = PanelLimits::default(); for panel in panels { - let policy = (self.limits)(panel); + let policy = self.limits(panel); limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); limits.max = limits.max.min(policy.max); } limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| (self.permissions)(p) != Permissions::PROTECTED) { + if panels.len() != 1 || panels.first().is_some_and(|p| self.permissions(p) != Permissions::PROTECTED) { limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); } @@ -195,7 +206,7 @@ impl Renderer<'_, P> { Node::Stack { entries } => { let mut limits = PanelLimits::default(); for entry in entries { - let policy = (self.limits)(&entry.panel); + let policy = self.limits(&entry.panel); limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; limits.max.x = limits.max.x.min(policy.max.x); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index e4e427ed..0fb075e1 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,7 +8,7 @@ use crate::docking::{Action, Drag, GroupDrag, cancel_drag, resolve_drop}; impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| (self.permissions)(p).movable)) { + if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| self.permissions(p).movable)) { ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); return; } @@ -67,7 +67,7 @@ impl Renderer<'_, P> { } let action = if let Some(proposal) = proposal.filter(|_| !gone) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) - } else if (self.permissions)(&drag.panel).floatable + } else if self.permissions(&drag.panel).floatable && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) { let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); @@ -79,7 +79,7 @@ impl Renderer<'_, P> { // Validate the proposed gesture against the same complete snapshot. The app // still applies it via its own command path after body rendering finishes. let mut candidate = self.layout.clone(); - if candidate.apply_with_permissions(action.clone(), self.permissions).is_ok() { + if candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); self.output.focused = Some(drag.panel); } @@ -98,7 +98,7 @@ impl Renderer<'_, P> { if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } - } else if (self.permissions)(&drag.panel).floatable { + } else if self.permissions(&drag.panel).floatable { let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 3d034faf..3578579b 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use super::{Action, DockError, Layout, Node, Permissions, render::Renderer}; +use super::{Action, DockError, DropTarget, Layout, Node, Permissions, render::Renderer}; /// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. pub(super) const TAB_PADDING: f32 = 24.0; @@ -149,10 +149,47 @@ pub fn cancel_drag(ctx: &Context, area_id: Id) } } -/// One mutable application adapter owns bodies, menus and shaped text, avoiding competing -/// mutable closures. Logical labels remain separate and are used for tab accessibility. +/// One mutable application adapter owns bodies, menus, shaped text and per-panel policy, +/// avoiding competing mutable closures. Logical labels are used for tab accessibility. +/// [`DockArea::show_content`] reads every policy from here; the closures passed to the older +/// `show*` functions override `logical_label`, `permissions` and `limits`. pub trait DockContent

{ fn body(&mut self, ui: &mut Ui, panel: &P); + /// Plain text name for accessibility, menus and the default visual label. + fn logical_label(&self, panel: &P) -> String + where + P: Debug, + { + format!("{panel:?}") + } + fn permissions(&self, _panel: &P) -> Permissions { + Permissions::default() + } + fn limits(&self, _panel: &P) -> PanelLimits { + PanelLimits::default() + } + /// Preferred floating size, independent of the current dock geometry. `None` falls back + /// to the panel limits. + fn floating_size(&self, _panel: &P) -> Option { + None + } + fn icon(&self, _panel: &P) -> Option> { + None + } + /// Whether the panel may move to a native OS window (feature `native-windows`). + fn allow_native(&self, _panel: &P) -> bool { + false + } + /// A lone bare panel draws no tab strip and no drag bar, like a lone protected panel. It + /// still moves by command and menu. + fn bare(&self, _panel: &P) -> bool { + false + } + /// Host-owned drop resolution for targets the renderer cannot know about. Returning + /// feedback consumes the gesture, including no-op targets; the renderer then emits no move. + fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option { + None + } fn label(&mut self, _ui: &Ui, _panel: &P, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText { logical.into() } @@ -168,6 +205,24 @@ pub trait DockContent

{ fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} } +/// Host-owned drop resolution request, see [`DockContent::resolve_drag`]. +pub struct DropRequest<'a, P> { + pub panel: &'a P, + pub pointer: Pos2, + pub targets: &'a [DropTarget

], + pub floating_rect: Option, + pub whole_group: bool, + pub released: bool, + pub workspace: Rect, + pub screen: Rect, +} + +#[derive(Clone, Debug, Default)] +pub struct DropFeedback { + pub preview: Option, + pub insertion: Option, +} + struct BodyContent(F); impl DockContent

for BodyContent { fn body(&mut self, ui: &mut Ui, panel: &P) { @@ -175,6 +230,59 @@ impl DockContent

for BodyContent { } } +/// The older `show*` closures in front of an application's content. +struct Overrides<'a, P, C: ?Sized> { + label: &'a dyn Fn(&P) -> String, + permissions: &'a dyn Fn(&P) -> Permissions, + limits: &'a dyn Fn(&P) -> PanelLimits, + inner: &'a mut C, +} + +impl + ?Sized> DockContent

for Overrides<'_, P, C> { + fn body(&mut self, ui: &mut Ui, panel: &P) { + self.inner.body(ui, panel); + } + fn logical_label(&self, panel: &P) -> String { + (self.label)(panel) + } + fn permissions(&self, panel: &P) -> Permissions { + (self.permissions)(panel) + } + fn limits(&self, panel: &P) -> PanelLimits { + (self.limits)(panel) + } + fn floating_size(&self, panel: &P) -> Option { + self.inner.floating_size(panel) + } + fn icon(&self, panel: &P) -> Option> { + self.inner.icon(panel) + } + fn allow_native(&self, panel: &P) -> bool { + self.inner.allow_native(panel) + } + fn bare(&self, panel: &P) -> bool { + self.inner.bare(panel) + } + fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option { + self.inner.resolve_drag(ctx, style, request) + } + fn label(&mut self, ui: &Ui, panel: &P, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText { + self.inner.label(ui, panel, logical, font, color) + } + fn action_label(&mut self, ui: &Ui, logical: &str, font: &FontId, color: Color32) -> egui::WidgetText { + self.inner.action_label(ui, logical, font, color) + } + fn panel_menu(&mut self, ui: &mut Ui, panel: &P) { + self.inner.panel_menu(ui, panel); + } + fn group_header_width(&self, panels: &[P], active: &P, floating: bool) -> f32 { + self.inner.group_header_width(panels, active, floating) + } + fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { + self.inner.group_header(ui, panels, active, floating); + } +} + /// Optional renderer for applications without an existing docking surface. Apps with custom /// chrome can instead use the model, target resolver and resize/tab primitives independently. pub struct DockArea

{ @@ -296,25 +404,29 @@ impl DockArea

{ limits: impl Fn(&P) -> PanelLimits, content: &mut impl DockContent

, ) -> DockOutput

{ + let mut content = Overrides { label: &label, permissions: &permissions, limits: &limits, inner: content }; + self.show_content(ui, layout, style, &mut content) + } + + /// Render with every label, policy and size from `content`. + pub fn show_content(self, ui: &mut Ui, layout: &Layout

, style: &DockStyle, content: &mut dyn DockContent

) -> DockOutput

{ let rect = ui.available_rect_before_wrap(); if let Err(error) = layout.validate() { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(error), ..Default::default() }; } - if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !limits(p).valid()) { + if !style.valid() || !super::targets::usable(rect) || layout.panels().into_iter().any(|p| !content.limits(p).valid()) { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } let mut renderer = Renderer { id: self.id, style, - label: &label, - permissions: &permissions, - limits: &limits, content, activation: self.activation, blockers: self.blockers, layout, + workspace: rect, targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), From 9777c291c354ce6f696922532d38101e65021d67 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:45:52 +0000 Subject: [PATCH 010/160] docking: anchored floating resize, no-op drops and the host drop hook Floating windows resize from both side edges, the bottom edge and both bottom corners; the opposite edges stay fixed and a resize past the viewport waits for the pointer to come back. A release over a tab's current slot or its own group body is a no-op instead of a float. Insertion markers stay inside their strip. Previews paint on the Tooltip layer, and a reorder inside the source strip shows only the insertion line. resolve_drag lets the host own a gesture. A panel floated from a dock uses floating_size or its limits instead of the column height. ResizeHandle gives focus back after a pointer drag. node_limits is a free function for the render modules. is_dragging and tab_insertion_marker are public. Ported from storytold/craft-libs#110 by @Zenodeon. Co-authored-by: Zenodeon --- crates/ui/src/docking/mod.rs | 5 +- crates/ui/src/docking/render/floating.rs | 189 +++++++++++---- crates/ui/src/docking/render/group.rs | 3 +- crates/ui/src/docking/render/mod.rs | 95 ++++---- crates/ui/src/docking/render/preview.rs | 122 ++++++++-- crates/ui/src/docking/resize.rs | 147 ++++++++++++ crates/ui/src/docking/surface.rs | 23 +- crates/ui/src/docking/targets.rs | 68 +++++- crates/ui/src/docking/tests.rs | 293 ++++++++++++++++++++++- crates/ui/src/layout/resize.rs | 5 + crates/ui/src/layout/tests.rs | 8 + 11 files changed, 831 insertions(+), 127 deletions(-) create mode 100644 crates/ui/src/docking/resize.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b08872b9..1be249d6 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -8,14 +8,15 @@ mod dock_model; mod model; mod render; +mod resize; mod surface; mod targets; mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; -pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag}; -pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect}; +pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag, is_dragging}; +pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; use surface::{Drag, GroupDrag}; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 64ca2da4..cd55820b 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -1,9 +1,42 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Key, Rect, Sense, Stroke, Vec2, pos2, vec2}; +use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; use super::{Renderer, clamp_float}; -use crate::docking::{Action, Node}; +use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize}; + +/// Floating window resize handles: id suffix, hit rect and the edges they move. +fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { + [ + ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), + ( + "resize-left", + Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)), + vec2(-1.0, 0.0), + ), + ( + "resize-right", + Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)), + vec2(1.0, 0.0), + ), + ( + "resize-bottom", + Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())), + vec2(0.0, 1.0), + ), + ( + "resize-bottom-left", + Rect::from_min_max( + pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), + pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), + ), + vec2(-1.0, 1.0), + ), + ] +} + +/// Names of every floating window resize handle, for focus classification. +pub(in crate::docking) const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; impl Renderer<'_, P> { pub(in crate::docking) fn floating(&mut self, ctx: &Context) { @@ -12,19 +45,19 @@ impl Renderer<'_, P> { return; } let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut groups: Vec<_> = self.layout.floating.iter().collect(); - groups.sort_by_key(|group| { - let id = group.panels().next().map(|panel| self.id.with(("floating", panel))); + let mut windows: Vec<_> = self.layout.floating.iter().collect(); + windows.sort_by_key(|window| { + let id = window.panels().next().map(|panel| self.id.with(("floating", panel))); layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) }); - for group in groups { - let Some(first) = group.panels().next() else { continue }; - let [x, y, width, height] = group.rect; - let limits = self.node_limits(&Node::Tabs { panels: group.panels().cloned().collect(), active: 0 }); + for window in windows { + let Some(first) = window.panels().next() else { continue }; + let [x, y, width, height] = window.rect; + let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 }); let size = vec2(width, height).max(limits.min).min(limits.max); let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| group.contains(panel)) { + if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); self.output.focused = self.activation.clone(); @@ -33,45 +66,105 @@ impl Renderer<'_, P> { ui.set_min_size(rect.size()); let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); ui.allocate_rect(rect, Sense::hover()); - self.stacked_groups(ui, &group.groups, rect, true); - let corner = Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max); - let response = ui.interact(corner, id.with("resize"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); - if response.drag_started() { - response.request_focus(); - } - let mut delta = if response.dragged() { ui.input(|i| i.pointer.delta()) } else { Vec2::ZERO }; - if response.has_focus() { - ui.memory_mut(|memory| { - memory.set_focus_lock_filter( - response.id, - egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, - ) - }); - ui.input_mut(|input| { - if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { - delta.x -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { - delta.x += 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { - delta.y -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { - delta.y += 8.0; - } - }); - } - if response.enabled() && delta != Vec2::ZERO { - let size = (rect.size() + delta).max(limits.min.max(vec2(40.0, self.style.tab_height + 20.0))).min(limits.max).min(screen.size()); - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [rect.left(), rect.top(), size.x, size.y] }); - } - if response.hovered() || response.dragged() || response.has_focus() { - ui.painter().line_segment([corner.left_bottom(), corner.right_top()], Stroke::new(2.0, self.style.accent)); - } + self.stacked_groups(ui, &window.groups, rect, true); + self.resize_handles(ui, window, id, rect, limits, screen); }); } } + + /// Anchored resize from the side edges, the bottom edge and both bottom corners: the + /// opposite edges stay where they are. + fn resize_handles(&mut self, ui: &mut Ui, window: &Floating

, id: Id, rect: Rect, limits: PanelLimits, screen: Rect) { + let Some(first) = window.panels().next() else { return }; + for (name, handle, direction) in handles(rect) { + if !crate::docking::targets::usable(handle) { + continue; + } + let cursor = if direction.x == 0.0 { + egui::CursorIcon::ResizeVertical + } else if direction.y == 0.0 { + egui::CursorIcon::ResizeHorizontal + } else if direction.x == direction.y { + egui::CursorIcon::ResizeNwSe + } else { + egui::CursorIcon::ResizeNeSw + }; + let response = ui.interact(handle, id.with(name), Sense::drag()).on_hover_cursor(cursor); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); + let anchor_id = response.id.with("pointer-anchor"); + if response.drag_started() { + response.request_focus(); + if let Some(origin) = ui.input(|i| i.pointer.press_origin()) + && let Some(anchor) = AnchoredResize::new(rect, origin, direction) + { + ui.ctx().data_mut(|data| { + if let Some(previous) = data.get_temp::(self.id.with("pointer-resize")) { + data.remove::(previous.with("pointer-anchor")); + } + data.insert_temp(self.id.with("pointer-resize"), response.id); + data.insert_temp(anchor_id, anchor); + }); + } + } + let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + let mut delta = Vec2::ZERO; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, + ) + }); + ui.input_mut(|input| { + if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { + delta.x -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { + delta.x += 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { + delta.y -= 8.0; + } + if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { + delta.y += 8.0; + } + }); + } + let pointer_resize = response.dragged() || response.drag_stopped(); + let limits = PanelLimits { min: limits.min.max(vec2(40.0, self.style.tab_height + 20.0)).min(limits.max), ..limits }; + let next = if pointer_resize { + ui.ctx() + .data(|data| data.get_temp::(anchor_id)) + .and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, screen, limits))) + } else if delta != Vec2::ZERO { + AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, screen, limits)) + } else { + None + }; + let permitted = response.enabled() && window.panels().all(|panel| self.permissions(panel).movable); + if permitted + && !cancelled + && let Some(resized) = next.filter(|next| *next != rect) + { + self.output + .actions + .push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); + } + if response.drag_stopped() { + response.surrender_focus(); + } + if cancelled || !permitted || !ui.input(|input| input.pointer.primary_down()) { + ui.ctx().data_mut(|data| { + data.remove::(anchor_id); + if data.get_temp::(self.id.with("pointer-resize")) == Some(response.id) { + data.remove::(self.id.with("pointer-resize")); + } + }); + } + if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { + ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, self.style.accent)); + } + } + } } diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 3dca5511..0c80d634 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -177,7 +177,8 @@ impl Renderer<'_, P> { let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); if response.clicked() { - let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + let size = self.float_size(panel, group.size()); + let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); ui.close(); } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b0c62a52..9ed2e9c8 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -7,6 +7,8 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; mod floating; + +pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -187,50 +189,7 @@ impl Renderer<'_, P> { } pub(super) fn node_limits(&self, node: &Node

) -> PanelLimits { - match node { - Node::Dock { .. } => PanelLimits { min: Vec2::splat(self.style.min_pane), ..PanelLimits::default() }, - Node::Tabs { panels, .. } => { - let mut limits = PanelLimits::default(); - for panel in panels { - let policy = self.limits(panel); - limits.min = limits.min.max(policy.min).max(Vec2::splat(self.style.min_pane)); - limits.max = limits.max.min(policy.max); - } - limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| self.permissions(p) != Permissions::PROTECTED) { - limits.min.y = (limits.min.y + self.style.tab_height).min(1_000_000.0); - limits.max.y = (limits.max.y + self.style.tab_height).min(1_000_000.0); - } - limits - } - Node::Stack { entries } => { - let mut limits = PanelLimits::default(); - for entry in entries { - let policy = self.limits(&entry.panel); - limits.min.x = limits.min.x.max(policy.min.x).max(self.style.min_pane); - limits.min.y += if entry.open { policy.min.y.max(self.style.min_pane) } else { 0.0 } + self.style.tab_height; - limits.max.x = limits.max.x.min(policy.max.x); - } - limits.min = limits.min.min(Vec2::splat(1_000_000.0)); - limits.max = limits.max.max(limits.min); - limits - } - Node::Split { axis, first, second, .. } => { - let a = self.node_limits(first); - let b = self.node_limits(second); - let limits = match axis { - SplitAxis::Horizontal => PanelLimits { - min: vec2(a.min.x + b.min.x + self.style.gap, a.min.y.max(b.min.y)), - max: vec2(a.max.x + b.max.x + self.style.gap, a.max.y.min(b.max.y)), - }, - SplitAxis::Vertical => PanelLimits { - min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + self.style.gap), - max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + self.style.gap), - }, - }; - PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } - } - } + node_limits(node, self.style, &*self.content) } pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { @@ -248,6 +207,54 @@ impl Renderer<'_, P> { } } +/// Size policy of a subtree, shared by the renderer and its measurements. +pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { + match node { + Node::Dock { .. } => PanelLimits { min: Vec2::splat(style.min_pane), ..PanelLimits::default() }, + Node::Tabs { panels, .. } => { + let mut limits = PanelLimits::default(); + for panel in panels { + let policy = content.limits(panel); + limits.min = limits.min.max(policy.min).max(Vec2::splat(style.min_pane)); + limits.max = limits.max.min(policy.max); + } + limits.max = limits.max.max(limits.min); + if panels.len() != 1 || panels.first().is_some_and(|p| content.permissions(p) != Permissions::PROTECTED) { + limits.min.y = (limits.min.y + style.tab_height).min(1_000_000.0); + limits.max.y = (limits.max.y + style.tab_height).min(1_000_000.0); + } + limits + } + Node::Stack { entries } => { + let mut limits = PanelLimits::default(); + for entry in entries { + let policy = content.limits(&entry.panel); + limits.min.x = limits.min.x.max(policy.min.x).max(style.min_pane); + limits.min.y += if entry.open { policy.min.y.max(style.min_pane) } else { 0.0 } + style.tab_height; + limits.max.x = limits.max.x.min(policy.max.x); + } + limits.min = limits.min.min(Vec2::splat(1_000_000.0)); + limits.max = limits.max.max(limits.min); + limits + } + Node::Split { axis, first, second, .. } => { + let a = node_limits(first, style, content); + let b = node_limits(second, style, content); + let limits = match axis { + SplitAxis::Horizontal => PanelLimits { + min: vec2(a.min.x + b.min.x + style.gap, a.min.y.max(b.min.y)), + max: vec2(a.max.x + b.max.x + style.gap, a.max.y.min(b.max.y)), + }, + SplitAxis::Vertical => PanelLimits { + min: vec2(a.min.x.max(b.min.x), a.min.y + b.min.y + style.gap), + max: vec2(a.max.x.min(b.max.x), a.max.y + b.max.y + style.gap), + }, + }; + PanelLimits { min: limits.min.min(Vec2::splat(1_000_000.0)), max: limits.max.max(limits.min).min(Vec2::splat(1_000_000.0)) } + } + } +} + pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect { if !crate::docking::targets::usable(screen) { return rect; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0fb075e1..9b6214f9 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,9 +1,12 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Key, Rect, Stroke, StrokeKind}; +use egui::{Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; use super::{Renderer, clamp_float}; -use crate::docking::{Action, Drag, GroupDrag, cancel_drag, resolve_drop}; +use crate::docking::{Action, Drag, DropRequest, GroupDrag, cancel_drag, dock_model::Place, resolve_drop}; + +/// Height of a panel floated out of a dock column when the host gives no floating size. +const DOCKED_FLOAT_HEIGHT: f32 = 320.0; impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { @@ -14,6 +17,18 @@ impl Renderer<'_, P> { } let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); + if !escape + && focused + && (down || released) + && let Some(pointer) = pointer.filter(|p| p.is_finite()) + && !self.blockers.iter().any(|r| r.contains(pointer)) + && self.host_drop(ctx, &drag.panel, pointer, true, released, None) + { + if released { + ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + } + return; + } // A button release outside the window may never reach egui. let has_pointer = ctx.input(|i| i.pointer.has_pointer()); // Touch-up also removes the pointer, but still completes a drop. @@ -59,18 +74,24 @@ impl Renderer<'_, P> { } return; } + let floatable = self.permissions(&drag.panel).floatable + && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); + if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { + if released { + cancel_drag::

(ctx, self.id); + } + return; + } let proposal = resolve_drop(&drag.panel, pointer, &self.targets); if released { cancel_drag::

(ctx, self.id); - if pointer.distance(drag.origin) < 2.0 { + if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { return; } let action = if let Some(proposal) = proposal.filter(|_| !gone) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) - } else if self.permissions(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))) - { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); + } else if let Some(rect) = floating_rect { Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) } else { None @@ -86,21 +107,88 @@ impl Renderer<'_, P> { } return; } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, self.id.with("drop-preview"))); + let painter = self.preview_painter(ctx); if let Some(proposal) = proposal { - painter.rect( - proposal.preview.shrink(1.0), - 0.0, - self.style.accent.gamma_multiply(0.15), - Stroke::new(2.0, self.style.accent), - StrokeKind::Inside, - ); + // Reordering inside the source strip shows only the insertion line. + let same_group = proposal.insertion.is_some() + && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); + if !same_group && !proposal.noop { + painter.rect( + proposal.preview.shrink(1.0), + 0.0, + self.style.accent.gamma_multiply(0.15), + Stroke::new(2.0, self.style.accent), + StrokeKind::Inside, + ); + } if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } - } else if self.permissions(&drag.panel).floatable { - let rect = clamp_float(Rect::from_min_size(pointer - drag.grab, drag.source.size()), ctx.content_rect()); + } else if let Some(rect) = floating_rect { painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); } } + + /// The single drop preview layer, above every floating window. + pub(super) fn preview_painter(&self, ctx: &Context) -> egui::Painter { + let layer = egui::LayerId::new(egui::Order::Tooltip, self.id.with("drop-preview")); + ctx.move_to_top(layer); + ctx.layer_painter(layer) + } + + /// Size of a panel floated by a drag or the Float menu: the host's floating size, else the + /// source size within the panel limits. A panel leaving a dock column does not take the + /// column's height. + pub(super) fn float_size(&self, panel: &P, source: Vec2) -> Vec2 { + if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { + return size; + } + let mut size = source; + if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { + size.y = size.y.min(DOCKED_FLOAT_HEIGHT); + } + let limits = self.limits(panel); + let header = vec2(0.0, self.style.tab_height); + size.max(limits.min + header).min(limits.max + header) + } + + fn tab_float_rect(&self, ctx: &Context, drag: &Drag

, pointer: Pos2) -> Rect { + let size = self.float_size(&drag.panel, drag.source.size()); + let grab = drag.grab.min(vec2(size.x, self.style.tab_height)); + clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + } + + /// Offer the gesture to [`crate::docking::DockContent::resolve_drag`]. True when the host + /// owns it; its feedback replaces the renderer's preview. + pub(super) fn host_drop( + &mut self, + ctx: &Context, + panel: &P, + pointer: Pos2, + whole_group: bool, + released: bool, + floating_rect: Option, + ) -> bool { + let request = DropRequest { + panel, + pointer, + targets: &self.targets, + floating_rect, + whole_group, + released, + workspace: self.workspace, + screen: ctx.content_rect(), + }; + let Some(feedback) = self.content.resolve_drag(ctx, self.style, &request) else { return false }; + if !released { + let painter = self.preview_painter(ctx); + if let Some(rect) = feedback.preview.filter(|rect| crate::docking::targets::usable(*rect)) { + painter.rect(rect, 0.0, self.style.accent.gamma_multiply(0.15), Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + } + if let Some(rect) = feedback.insertion.filter(|rect| crate::docking::targets::usable(*rect)) { + painter.rect_filled(rect, 0.0, self.style.accent); + } + } + true + } } diff --git a/crates/ui/src/docking/resize.rs b/crates/ui/src/docking/resize.rs new file mode 100644 index 00000000..48e50f4e --- /dev/null +++ b/crates/ui/src/docking/resize.rs @@ -0,0 +1,147 @@ +use egui::{Pos2, Rect, Vec2}; + +use super::PanelLimits; + +/// A pointer-anchored resize. The opposite edges and handle grab offset stay fixed +/// through boundary overshoot; viewport constraints never translate the window. +#[derive(Clone, Copy, Debug)] +pub struct AnchoredResize { + start: Rect, + offset: Vec2, + direction: Vec2, +} + +impl AnchoredResize { + pub fn new(start: Rect, pointer: Pos2, direction: Vec2) -> Option { + if !start.is_finite() + || !start.is_positive() + || !pointer.is_finite() + || ![-1.0, 0.0, 1.0].contains(&direction.x) + || ![-1.0, 0.0, 1.0].contains(&direction.y) + || direction == Vec2::ZERO + { + return None; + } + let edge = + egui::pos2(if direction.x < 0.0 { start.left() } else { start.right() }, if direction.y < 0.0 { start.top() } else { start.bottom() }); + Some(Self { start, offset: pointer - edge, direction }) + } + + /// None suspends resizing when the fixed anchor cannot accommodate the minimum. + pub fn rect(self, pointer: Pos2, viewport: Rect, limits: PanelLimits) -> Option { + if !pointer.is_finite() + || !viewport.is_finite() + || !viewport.is_positive() + || !limits.min.is_finite() + || !limits.max.is_finite() + || limits.min.x <= 0.0 + || limits.min.y <= 0.0 + || limits.max.x < limits.min.x + || limits.max.y < limits.min.y + || !viewport.contains_rect(self.start) + || self.start.width() < limits.min.x + || self.start.height() < limits.min.y + { + return None; + } + let desired = pointer - self.offset; + if !desired.is_finite() { + return None; + } + let mut rect = self.start; + for (axis, direction) in [(0, self.direction.x), (1, self.direction.y)] { + if direction == 0.0 { + continue; + } + let (lo, hi) = if direction < 0.0 { + ((self.start.max[axis] - limits.max[axis]).max(viewport.min[axis]), self.start.max[axis] - limits.min[axis]) + } else { + (self.start.min[axis] + limits.min[axis], (self.start.min[axis] + limits.max[axis]).min(viewport.max[axis])) + }; + if !lo.is_finite() || !hi.is_finite() || lo > hi { + return None; + } + if direction < 0.0 { + rect.min[axis] = desired[axis].clamp(lo, hi); + } else { + rect.max[axis] = desired[axis].clamp(lo, hi); + } + } + Some(rect) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{pos2, vec2}; + + #[test] + fn every_handle_tracks_absolute_pointer_and_keeps_opposite_edges() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_max(Pos2::ZERO, pos2(800.0, 600.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(500.0, 400.0) }; + for direction in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0)] { + let edge = pos2(if direction.x < 0.0 { start.left() } else { start.right() }, start.bottom()); + let pointer = edge + vec2(2.0, -3.0); + let resize = AnchoredResize::new(start, pointer, direction).unwrap(); + let rect = resize.rect(pointer + vec2(20.0, 25.0), viewport, limits).unwrap(); + assert_eq!(rect.top(), start.top()); + if direction.x < 0.0 { + assert_eq!(rect.right(), start.right()); + assert_eq!(rect.left(), start.left() + 20.0); + } else { + assert_eq!(rect.left(), start.left()); + } + if direction.x > 0.0 { + assert_eq!(rect.right(), start.right() + 20.0); + } + if direction.y > 0.0 { + assert_eq!(rect.bottom(), start.bottom() + 25.0); + } else { + assert_eq!(rect.bottom(), start.bottom()); + } + } + } + + #[test] + fn overshoot_requires_pointer_to_return_to_the_valid_range() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_max(Pos2::ZERO, pos2(600.0, 500.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(1000.0, 1000.0) }; + let resize = AnchoredResize::new(start, pos2(398.0, 200.0), vec2(1.0, 0.0)).unwrap(); + for x in [800.0, 750.0, 598.0] { + let rect = resize.rect(pos2(x, 200.0), viewport, limits).unwrap(); + assert_eq!((rect.left(), rect.right()), (100.0, 600.0)); + } + for x in [120.0, 200.0, 258.0] { + assert_eq!(resize.rect(pos2(x, 200.0), viewport, limits).unwrap().right(), 260.0); + } + assert_eq!(resize.rect(pos2(278.0, 200.0), viewport, limits).unwrap().right(), 280.0); + let left = AnchoredResize::new(start, pos2(102.0, 200.0), vec2(-1.0, 0.0)).unwrap(); + assert_eq!(left.rect(pos2(-300.0, 200.0), viewport, limits).unwrap().min.x, 0.0); + assert_eq!(left.rect(pos2(350.0, 200.0), viewport, limits).unwrap().min.x, 240.0); + assert_eq!(left.rect(pos2(300.0, 200.0), viewport, limits).unwrap().min.x, 240.0); + assert_eq!(left.rect(pos2(222.0, 200.0), viewport, limits).unwrap().min.x, 220.0); + } + + #[test] + fn panel_maximum_caps_edges_without_losing_the_absolute_anchor() { + let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); + let viewport = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(320.0, 240.0) }; + let resize = AnchoredResize::new(start, start.max, Vec2::splat(1.0)).unwrap(); + for pointer in [pos2(700.0, 550.0), pos2(600.0, 450.0)] { + assert_eq!(resize.rect(pointer, viewport, limits).unwrap(), Rect::from_min_max(start.min, pos2(420.0, 320.0))); + } + assert_eq!(resize.rect(pos2(410.0, 310.0), viewport, limits).unwrap(), Rect::from_min_max(start.min, pos2(410.0, 310.0))); + } + + #[test] + fn constrained_viewports_and_invalid_geometry_suspend_resize() { + let start = Rect::from_min_size(Pos2::ZERO, vec2(100.0, 90.0)); + let resize = AnchoredResize::new(start, start.max, Vec2::splat(1.0)).unwrap(); + assert!(resize.rect(start.max, start, PanelLimits { min: vec2(160.0, 120.0), ..Default::default() }).is_none()); + assert!(AnchoredResize::new(start, pos2(f32::NAN, 0.0), Vec2::splat(1.0)).is_none()); + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 3578579b..5321fb25 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -2,7 +2,11 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use super::{Action, DockError, DropTarget, Layout, Node, Permissions, render::Renderer}; +use super::{ + Action, DockError, DropTarget, Layout, Node, Permissions, + render::{RESIZE_HANDLES, Renderer}, + resize::AnchoredResize, +}; /// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. pub(super) const TAB_PADDING: f32 = 24.0; @@ -131,6 +135,11 @@ pub fn begin_drag(ctx: &Context, area_id: Id, true } +/// Whether this area's tab or group gesture is active. +pub fn is_dragging(ctx: &Context, area_id: Id) -> bool { + ctx.data(|data| data.get_temp::>(area_id.with("drag")).is_some() || data.get_temp::>(area_id.with("group-drag")).is_some()) +} + /// Discard pending tab/group gestures and an actively dragged renderer-owned control before /// replacing a workspace. Body/custom-header/menu gestures and other areas are untouched. /// Ownership is recorded as controls render; before an initial render or drag threshold there @@ -142,7 +151,12 @@ pub fn cancel_drag(ctx: &Context, area_id: Id) d.remove::>(area_id.with("drag")); d.remove::>(area_id.with("group-drag")); let ids = d.remove_temp::>(area_id.with("owned-interactions")); - dragged.is_some_and(|id| ids.is_some_and(|ids| ids.contains(&id))) + let resize = d.get_temp::(area_id.with("pointer-resize")); + d.remove::(area_id.with("pointer-resize")); + for id in resize.iter().chain(ids.iter().flatten()) { + d.remove::(id.with("pointer-anchor")); + } + dragged.is_some_and(|id| resize == Some(id) || ids.is_some_and(|ids| ids.contains(&id))) }); if owned { ctx.stop_dragging(); @@ -312,7 +326,10 @@ impl DockArea

{ }; for floating in &layout.floating { if floating.groups.iter().any(|g| group(&g.panels)) - || floating.panels().next().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) + || floating + .panels() + .next() + .is_some_and(|first| RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name)))) { return true; } diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 515b9c65..a4c8b631 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -20,6 +20,8 @@ pub struct DropProposal

{ pub placement: Placement

, pub preview: Rect, pub insertion: Option, + /// The pointer addresses the tab's current insertion slot. + pub noop: bool, } pub(super) fn usable(rect: Rect) -> bool { @@ -57,6 +59,28 @@ pub fn zone_rect(rect: Rect, zone: Zone) -> Option { }) } +/// Bounded insertion marker for a logical slot in a measured tab strip. Hidden tabs +/// retain their order; their slot uses the next visible boundary or the visible strip end. +pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) -> Option { + if target.tabs.len() > super::MAX_PANELS || !target.accepts_tabs || !usable(target.rect) { + return None; + } + let strip = target.tab_strip?.intersect(target.rect); + if !usable(strip) { + return None; + } + let index = before.and_then(|before| target.tabs.iter().position(|(panel, _)| panel == before)); + let x = index + .and_then(|index| target.tabs.get(index..)) + .and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.left())) + .unwrap_or_else(|| { + target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.right()).fold(strip.left(), f32::max).min(strip.right()) + }); + let width = strip.width().min(2.0); + let left = (x - width * 0.5).max(strip.left()).min((strip.right() - width).max(strip.left())); + Some(Rect::from_min_max(pos2(left, strip.top()), pos2((left + width).min(strip.right()), strip.bottom()))) +} + /// Resolve a pointer against visible groups. The frontmost containing group occludes groups /// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source /// group is a no-op at center. Floating groups normally set `allow_splits` to false. @@ -68,26 +92,48 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg if target.tabs.len() > super::MAX_PANELS { return None; } - let anchor = if &target.anchor == panel { target.tabs.iter().find(|(p, _)| p != panel).map(|(p, _)| p)? } else { &target.anchor }; - let strip = target.tab_strip.filter(|r| usable(*r) && r.contains(pointer)); - if let Some(strip) = strip { + let anchor = if &target.anchor == panel { target.tabs.iter().find(|(p, _)| p != panel).map(|(p, _)| p).unwrap_or(panel) } else { &target.anchor }; + let strip = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|r| usable(*r) && r.contains(pointer)); + if strip.is_some() { if !target.accepts_tabs { return None; } let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| pointer.x < r.center().x); let before_panel = before.map(|(p, _)| p.clone()); - let x = before.map(|(_, r)| r.left()).unwrap_or_else(|| { - target.tabs.iter().filter(|(_, r)| usable(*r)).map(|(_, r)| r.right()).fold(strip.left(), f32::max).min(strip.right()) - }); - let insertion = Rect::from_min_max(pos2((x - 1.0).max(strip.left()), strip.top()), pos2((x + 1.0).min(strip.right()), strip.bottom())); + let insertion = tab_insertion_marker(target, before_panel.as_ref())?; + let old_order: Vec<_> = target.tabs.iter().map(|(p, _)| p).collect(); + let mut order = old_order.clone(); + let noop = if before_panel.as_ref() == Some(panel) { + old_order.contains(&panel) + } else if old_order.contains(&panel) { + order.retain(|p| *p != panel); + let at = before_panel.as_ref().and_then(|before| order.iter().position(|p| *p == before)).unwrap_or(order.len()); + order.insert(at, panel); + order == old_order + } else { + false + }; return Some(DropProposal { anchor: anchor.clone(), placement: Placement::Tab { before: before_panel }, preview: target.rect, insertion: Some(insertion), + noop, }); } let zone = if target.allow_splits { drop_zone(target.rect, 0.0, pointer)? } else { Zone::Center }; + if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { + return Some(DropProposal { + anchor: anchor.clone(), + placement: Placement::Tab { before: Some(panel.clone()) }, + preview: target.rect, + insertion: None, + noop: true, + }); + } + if anchor == panel { + return None; + } if zone == Zone::Center { if !target.accepts_tabs || target.tabs.iter().any(|(p, _)| p == panel) { return None; @@ -95,5 +141,11 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg } else if !target.allow_splits { return None; } - Some(DropProposal { anchor: anchor.clone(), placement: Placement::Split(zone), preview: zone_rect(target.rect, zone)?, insertion: None }) + Some(DropProposal { + anchor: anchor.clone(), + placement: Placement::Split(zone), + preview: zone_rect(target.rect, zone)?, + insertion: None, + noop: false, + }) } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 5180b76d..cd5adafc 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::layout::{SplitAxis, SplitSize}; -use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, pos2, vec2}; +use egui::{Context, Event, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; fn tabs(panels: &[u32], active: usize) -> Node { Node::Tabs { panels: panels.to_vec(), active } @@ -241,7 +241,7 @@ fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { assert!(placement.insertion.unwrap().is_finite()); assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Center)); assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Left)); - assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group)).is_none()); + assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group)).unwrap().noop); assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group)).is_some()); let mut overlay = target(5, &[5], rect); overlay.accepts_tabs = false; @@ -355,6 +355,8 @@ fn frame(ctx: &Context, layout: &Layout, events: Vec, focused: bool) ui.label("Actual panel content"); }, ); + let cursor = ctx.output(|o| o.cursor_icon); + ctx.data_mut(|d| d.insert_temp(Id::new("test-cursor"), cursor)); }) .textures_delta .clear(); @@ -699,8 +701,10 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let tab = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center(); step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]); step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(8.0, 0.0))]); - step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(100.0, 100.0))]); - let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]); + // Inside the source window the release is a no-op; outside it floats. + let outside = pos2(780.0, 650.0); + step(&ctx, &mut layout, vec![Event::PointerMoved(outside)]); + let detached = step(&ctx, &mut layout, vec![pointer_button(outside, false)]); assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. }))); assert_eq!(layout.floating.len(), 2); assert_eq!(layout.floating[0].groups[0].panels, vec![6]); @@ -1304,3 +1308,284 @@ fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tool "a truncated tab names its panel in full on hover" ); } + +// Ported from storytold/craft-libs#110 by @Zenodeon. + +#[test] +fn resize_cancellation_retains_ownership_across_other_host_rendering() { + let ctx = Context::default(); + let area = Id::new("test-area"); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, vec![]); + } + let id = area.with(("floating", &5u32)).with("resize-right"); + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(20.0, 0.0))]); + assert_eq!(ctx.dragged_id(), Some(id)); + ctx.data_mut(|data| data.insert_temp(area.with("owned-interactions"), vec![Id::new("another-host-control")])); + cancel_drag::(&ctx, area); + assert!(ctx.dragged_id().is_none()); + let before = layout.clone(); + assert!(step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(100.0, 0.0))]).is_empty()); + assert_eq!(layout, before); +} + +#[test] +fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() { + let ctx = Context::default(); + let mut layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [80.0, 70.0, 300.0, 26.0])] }; + let id = Id::new("collapsed-resize").with(("floating", &1u32)).with("resize-right"); + let mut frame = |events| { + let mut actions = Vec::new(); + ctx.run_ui( + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), focused: true, events, ..Default::default() }, + |ui| { + let mut style = DockStyle::from_ui(ui); + style.min_pane = 0.0; + actions = DockArea::new(Id::new("collapsed-resize")) + .show_with_limits( + ui, + &layout, + &style, + |p| p.to_string(), + |_| Permissions::default(), + |_| PanelLimits { min: vec2(220.0, 0.0), max: vec2(1000.0, 0.0) }, + |_, _| {}, + ) + .actions; + }, + ) + .textures_delta + .clear(); + for action in actions { + layout.apply(action).unwrap(); + } + }; + for _ in 0..3 { + frame(vec![]); + } + ctx.memory_mut(|memory| memory.request_focus(id)); + frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 26.0]); +} + +#[test] +fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() { + for panels in [vec![1u32], vec![1, 2, 3]] { + for allow_splits in [false, true] { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 300.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 26.0))), + tabs: panels.iter().map(|id| (*id, Rect::NOTHING)).collect(), + allow_splits, + accepts_tabs: true, + }; + let proposal = resolve_drop(&1, rect.center(), &[target]).unwrap(); + assert!(proposal.noop); + assert!(proposal.insertion.is_none()); + } + } +} + +#[test] +fn pointer_floating_resize_stops_at_edges_and_waits_for_cursor_to_return() { + for name in ["resize-right", "resize-left", "resize-bottom", "resize", "resize-bottom-left"] { + let ctx = Context::default(); + let mut layout = sample(); + for _ in 0..3 { + step(&ctx, &mut layout, vec![]); + } + let id = Id::new("test-area").with(("floating", &5u32)).with(name); + let start = ctx.read_response(id).unwrap().rect.center(); + let original = layout.floating[0].rect; + let horizontal = name != "resize-bottom"; + let left = name.contains("left"); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + let outside = if horizontal { pos2(if left { -200.0 } else { 1100.0 }, start.y) } else { pos2(start.x, 1000.0) }; + step(&ctx, &mut layout, vec![Event::PointerMoved(outside)]); + let stopped = layout.floating[0].rect; + if horizontal { + if left { + assert_eq!(stopped[0] + stopped[2], original[0] + original[2]); + } else { + assert_eq!(stopped[0], original[0]); + assert_eq!(stopped[0] + stopped[2], 800.0); + } + } else { + assert_eq!(stopped[1], original[1]); + assert_eq!(stopped[1] + stopped[3], 600.0); + } + let still_outside = if horizontal { outside + vec2(if left { 100.0 } else { -100.0 }, 0.0) } else { outside - vec2(0.0, 100.0) }; + step(&ctx, &mut layout, vec![Event::PointerMoved(still_outside)]); + assert_eq!(layout.floating[0].rect, stopped, "{name}: reversal outside the edge must not resize"); + step(&ctx, &mut layout, vec![Event::PointerMoved(start)]); + assert_eq!(layout.floating[0].rect, original, "{name}: returning to the original handle restores its geometry"); + step(&ctx, &mut layout, vec![pointer_button(start, false)]); + assert!(!ctx.memory(|memory| memory.has_focus(id))); + } +} + +#[test] +fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 200.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(rect.min, vec2(300.0, 30.0))), + tabs: vec![ + (1, Rect::from_min_size(pos2(0.0, 0.0), vec2(60.0, 30.0))), + (2, Rect::from_min_size(pos2(60.0, 0.0), vec2(60.0, 30.0))), + (3, Rect::from_min_size(pos2(120.0, 0.0), vec2(60.0, 30.0))), + ], + allow_splits: true, + accepts_tabs: true, + }; + for (panel, positions) in [(1, [10.0, 50.0]), (2, [70.0, 110.0]), (3, [130.0, 210.0])] { + for x in positions { + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target)).unwrap(); + assert!(proposal.noop, "{panel}/{x}"); + assert!(target.tab_strip.unwrap().contains_rect(proposal.insertion.unwrap())); + } + } + for (panel, x) in [(1, 140.0), (3, 10.0), (2, 210.0)] { + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target)).unwrap(); + assert!(!proposal.noop); + assert!(proposal.insertion.is_some()); + } +} + +#[test] +fn host_owned_noop_consumes_release_without_a_fallback_float() { + struct Content(usize); + impl DockContent for Content { + fn body(&mut self, _ui: &mut egui::Ui, _panel: &u32) {} + fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, request: &DropRequest<'_, u32>) -> Option { + if request.released { + self.0 += 1; + } + Some(DropFeedback::default()) + } + } + let ctx = Context::default(); + let area = Id::new("host-owned"); + let layout = sample(); + let mut content = Content(0); + let from = pos2(30.0, 20.0); + let to = pos2(400.0, 300.0); + let render = |events: Vec, content: &mut Content| { + let mut output = DockOutput::default(); + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| { + output = DockArea::new(area).show_customized( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| p.to_string(), + |_| Permissions::default(), + |_| PanelLimits::default(), + content, + ); + }) + .textures_delta + .clear(); + output + }; + render( + vec![ + Event::PointerMoved(from), + Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + &mut content, + ); + assert!(begin_drag(&ctx, area, 1u32, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)))); + render(vec![Event::PointerMoved(to)], &mut content); + let output = render( + vec![Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }], + &mut content, + ); + assert_eq!(content.0, 1); + assert!(output.actions.iter().all(|action| !matches!(action, Action::Move { .. } | Action::Float { .. }))); +} + +#[test] +fn overflow_noop_detection_uses_logical_order_including_hidden_tabs() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)); + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(Rect::from_min_size(Pos2::ZERO, vec2(140.0, 30.0))), + tabs: vec![ + (1, Rect::from_min_size(Pos2::ZERO, vec2(60.0, 30.0))), + (2, Rect::NOTHING), + (3, Rect::from_min_size(pos2(60.0, 0.0), vec2(60.0, 30.0))), + ], + allow_splits: true, + accepts_tabs: true, + }; + assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); + // Before the next visible tab crosses hidden tab 2 and really changes the order. + assert!(!resolve_drop(&1, pos2(65.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); + assert!(resolve_drop(&3, pos2(130.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); +} + +#[test] +fn insertion_markers_stay_inside_clipped_and_singleton_strips() { + for width in [0.5, 1.0, 20.0, 140.0] { + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(width, 200.0)); + let strip = Rect::from_min_size(rect.min, vec2(width, 30.0)); + for tab_left in [-100.0, 10.0, 300.0] { + let target = DropTarget { + anchor: 1, + rect, + tab_strip: Some(strip.expand(5.0)), + tabs: vec![(1, Rect::from_min_size(pos2(tab_left, 20.0), vec2(60.0, 30.0)))], + allow_splits: true, + accepts_tabs: true, + }; + for x in [rect.left(), rect.center().x, rect.right()] { + let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target)).unwrap(); + assert!(proposal.noop); + let marker = proposal.insertion.unwrap(); + assert!(rect.intersect(target.tab_strip.unwrap()).contains_rect(marker)); + assert!(marker.width() > 0.0 && marker.width() <= 2.0); + } + } + } +} + +#[test] +fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() { + let rect = Rect::from_min_max(pos2(-0.001, 0.0), pos2(0.002, 30.0)); + let target = DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true }; + let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target]).unwrap(); + assert!(proposal.noop); + assert!(rect.contains_rect(proposal.insertion.unwrap())); +} + +#[test] +fn overflowing_strip_paints_a_single_ellipsis_symbol() { + use egui_kittest::Harness; + fn paints(shape: &egui::Shape, text: &str) -> bool { + match shape { + egui::Shape::Text(shape) => shape.galley.text() == text, + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| paints(shape, text)), + _ => false, + } + } + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 0)), floating: vec![] }; + let mut h = Harness::builder().with_size(vec2(150.0, 240.0)).build_ui(move |ui| { + DockArea::new(Id::new("overflow-symbol")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |panel| format!("Panel {panel}"), + |_| Permissions::default(), + |_ui: &mut egui::Ui, _panel: &u32| {}, + ); + }); + h.run_steps(2); + assert!(h.output().shapes.iter().any(|shape| paints(&shape.shape, "\u{2026}"))); +} diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index e8a2a820..c0de628f 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -82,6 +82,11 @@ impl<'a> ResizeHandle<'a> { let mut response = ui.interact(if valid { hit } else { inert }, self.id, if valid { Sense::drag() } else { Sense::hover() }); let enabled = valid && response.enabled(); let mut delta = 0.0; + // Pointer resizing borrows focus only for the gesture. Keyboard focus acquired + // through traversal remains intact until the user chooses another control. + if response.drag_stopped() { + response.surrender_focus(); + } if enabled { if response.drag_started() { response.request_focus(); diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index 37914652..1a23ac02 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -171,6 +171,14 @@ fn resize_handle_preserves_pointer_drag_lifecycle_and_reports_per_frame_delta() let (response, _) = h.frame(vec![pointer(center + vec2(16.0, 0.0), false)]); assert!(response.response.drag_stopped()); assert_eq!(response.delta, 0.0); + assert!(!response.response.has_focus(), "pointer release must not leave a sticky focus highlight"); + let (_, output) = h.frame(vec![Event::PointerMoved(pos2(300.0, 180.0))]); + assert!( + !output + .shapes + .iter() + .any(|shape| { matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == h.rect && rect.fill == Color32::BLUE) }) + ); assert_eq!(h.value, 100.0, "the component never owns pane state"); } From 1b091edefbd1d450d9b82ccc3736ba784be80ade Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:55:27 +0000 Subject: [PATCH 011/160] docking: drag tab groups by their drag bar and top border Every group with a tab strip has a drag bar between its tabs and the host's header, at least drag_bar_width wide: tabs shrink and overflow before it narrows. The bar and the 4 pt top border move the whole group with its order and active tab. While dragging, a ghost outline follows the pointer and one destination preview paints on the Tooltip layer. On release DockArea resolves the target and emits one MoveGroup: joined as tabs, split beside a frame, or floated at the group's own size. group_drop is filled only for releases without a target. Ctrl or Cmd held at release always floats, for tab drags too. Escape, focus loss, leaving the window and cancel_drag end the drag without a change. A release over the group's own docked position is a no-op; a floating group released over itself moves there. Double-clicking a tab or the bar of a floating or dock-column group minimizes it to its strip; clicking another tab expands it. The bar is focusable; its menu (right-click, Enter or Space) and the tab menu end with Close Tab Group, Minimize or Expand, Float Tab Group and Group with. Floating windows paint floating_background and floating_stroke under their groups, so they stay opaque over a transparent host. A lone bare panel, like a lone protected one, draws no strip and no bar. The group handles and their tests build on storytold/craft-libs#110 by @Zenodeon. Co-authored-by: Zenodeon --- crates/ui/src/docking/group_tests.rs | 521 +++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/render/floating.rs | 14 +- crates/ui/src/docking/render/group.rs | 306 ++++++++++--- crates/ui/src/docking/render/mod.rs | 18 +- crates/ui/src/docking/render/preview.rs | 125 +++++- crates/ui/src/docking/surface.rs | 44 +- 7 files changed, 944 insertions(+), 88 deletions(-) create mode 100644 crates/ui/src/docking/group_tests.rs diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs new file mode 100644 index 00000000..584fff2e --- /dev/null +++ b/crates/ui/src/docking/group_tests.rs @@ -0,0 +1,521 @@ +//! Whole-group gestures: drag bar, top border, drop resolution, minimize and the floating frame. + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Color32, Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, UiBuilder, Vec2, pos2, vec2}; + +const AREA: &str = "group-area"; + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(300.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2, 3], 1)), + }), + floating: Vec::new(), + } +} + +/// A start dock with one column holding `[2, 3]` (3 selected) above `[4]`, beside canvas 0. +fn docked() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(Node::Dock { + side: Side::Start, + columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)], + }), + second: Box::new(tabs(&[0], 0)), + }), + floating: Vec::new(), + } +} + +#[derive(Default)] +struct TestContent { + protected: Vec, + header: f32, + header_clicks: usize, + floating_size: Option, + host_owned: bool, + whole_group: bool, +} + +impl DockContent for TestContent { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn permissions(&self, panel: &u32) -> Permissions { + if self.protected.contains(panel) { Permissions::PROTECTED } else { Permissions::default() } + } + fn floating_size(&self, _: &u32) -> Option { + self.floating_size + } + fn resolve_drag(&mut self, _: &Context, _: &DockStyle, request: &DropRequest<'_, u32>) -> Option { + self.whole_group |= request.whole_group; + self.host_owned.then(DropFeedback::default) + } + fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + self.header + } + fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { + if ui.button("≡").clicked() { + self.header_clicks += 1; + } + } +} + +/// Renders a layout through `show_content` in `area` of an 800x600 window and applies what +/// it emits, the way an application's command handler would. +struct Driver { + ctx: Context, + layout: Layout, + content: TestContent, + area: Rect, + modifiers: Modifiers, + focused: bool, + transparent: bool, + shapes: Vec, + time: f64, +} + +impl Driver { + fn new(layout: Layout) -> Self { + Self::with(layout, TestContent::default(), Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))) + } + + fn with(layout: Layout, content: TestContent, area: Rect) -> Self { + let mut driver = Self { + ctx: Context::default(), + layout, + content, + area, + modifiers: Modifiers::NONE, + focused: true, + transparent: false, + shapes: Vec::new(), + time: 0.0, + }; + driver.step(vec![]); + driver.step(vec![]); + driver + } + + fn frame(&mut self, mut events: Vec) -> DockOutput { + let mut output = DockOutput::default(); + events.insert(0, Event::ModifiersChanged(self.modifiers)); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + events, + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, area, transparent) = (&self.layout, &mut self.content, self.area, self.transparent); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + if transparent { + style.background = Color32::TRANSPARENT; + style.tab_background = Color32::TRANSPARENT; + } + ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { + output = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + self.shapes = full.shapes; + output + } + + fn step(&mut self, events: Vec) -> DockOutput { + let output = self.frame(events); + assert!(output.error.is_none(), "{output:?}"); + let permissions = |p: &u32| self.content.permissions(p); + for action in &output.actions { + self.layout.apply_with_permissions(action.clone(), permissions).unwrap(); + } + output + } + + fn rect(&self, name: &str, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with((name, Some(&panel)))).unwrap().rect + } + + fn tab(&self, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect + } + + /// Press at `from`, pass the drag threshold, move to `to`; returns the last move's output. + fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(from), button(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + self.step(vec![Event::PointerMoved(to)]) + } + + /// A full drag; returns the release frame's output. + fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + /// Lets egui's double- and triple-click windows pass. + fn wait(&mut self) { + for _ in 0..45 { + self.step(vec![]); + } + } + + fn click(&mut self, at: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(at), button(at, true)]); + self.step(vec![button(at, false)]) + } +} + +fn button(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: egui::Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +fn moved_group(actions: &[Action]) -> Option<&Target> { + actions.iter().find_map(|a| match a { + Action::MoveGroup { to, .. } => Some(to), + _ => None, + }) +} + +#[test] +fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() { + // Onto another group's strip: one MoveGroup, both panels in order, 3 still selected. + let mut d = Driver::new(frames()); + let bar = d.rect("drag-bar", 2); + assert!(bar.width() >= 40.0); + let out = d.drag(bar.center(), d.tab(1).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Tab { .. } })), "{:?}", out.actions); + assert_eq!(out.actions.len(), 1); + assert!(out.group_drop.is_none(), "a resolved target leaves group_drop empty"); + assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2))); + + // Onto empty space: a floating window at the group's docked size. + let mut d = Driver::new(frames()); + let border = d.rect("drag-border", 2); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(700.0, 200.0)); + let Some(Target::Float { rect: Some([_, _, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + assert_eq!((*width, *height), (border.width(), 600.0)); + assert_eq!(out.group_drop, Some((2, pos2(700.0, 200.0)))); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![2, 3], 1)]); + + // The same group in a dock column joins the group below it. + let mut d = Driver::new(docked()); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), d.tab(4).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 4, .. })), "{:?}", out.actions); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() }; + let Node::Dock { columns, .. } = first.as_ref() else { panic!() }; + assert_eq!(columns[0].groups.len(), 1); + assert_eq!(columns[0].groups[0].panels, vec![4, 2, 3]); + assert_eq!(columns[0].groups[0].active_panel(), Some(&3)); +} + +#[test] +fn drag_bar_survives_a_strip_full_of_tabs() { + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6, 7, 8], 0)), floating: Vec::new() }; + let content = TestContent { header: 24.0, ..Default::default() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0))); + let bar = d.rect("drag-bar", 1); + assert!(bar.width() >= 40.0, "{bar:?}"); + assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header"); + assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs overflow before the bar narrows"); + let out = d.drag(bar.center(), pos2(500.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); + assert_eq!(d.layout.floating[0].groups[0].panels.len(), 8); +} + +#[test] +fn top_border_drags_the_group_and_the_menu_button_only_clicks() { + let content = TestContent { header: 30.0, ..Default::default() }; + let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let border = d.rect("drag-border", 2); + assert!(border.height() <= 4.0); + // The band lies over the tabs and wins there. + let over_tab = pos2(d.tab(2).center().x, border.center().y); + let menu = pos2(576.0, d.tab(2).center().y); + + // Pressing ≡ and dragging moves nothing; clicking it reaches the host. + let held = d.hold(menu, menu + vec2(0.0, 200.0)); + assert!(held.group_drag.is_none()); + let out = d.step(vec![button(menu + vec2(0.0, 200.0), false)]); + assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveGroup { .. } | Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + d.click(menu); + assert_eq!(d.content.header_clicks, 1); + + let out = d.drag(over_tab, d.tab(1).center()); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, .. })), "{:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1, 2, 3], 2))); +} + +#[test] +fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_centre_floats() { + let canvas = || TestContent { protected: vec![1], ..Default::default() }; + let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)); + let mut d = Driver::with(frames(), canvas(), area); + let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions); + let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; + assert_eq!(**first, tabs(&[2, 3], 1)); + assert_eq!(**second, tabs(&[1], 0)); + + // The canvas centre takes nothing: no join, no preview; the group floats there. + let mut d = Driver::with(frames(), canvas(), area); + let out = d.drag(d.rect("drag-bar", 2).center(), pos2(150.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); +} + +#[test] +fn ctrl_or_cmd_held_at_release_always_floats() { + for modifiers in [Modifiers::CTRL, Modifiers::MAC_CMD, Modifiers::COMMAND] { + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + d.hold(d.rect("drag-bar", 2).center(), target); + d.modifiers = modifiers; + let out = d.step(vec![button(target, false)]); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{modifiers:?}: {:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + + // A tab drag floats the same way. + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + d.hold(d.tab(3).center(), target); + d.modifiers = modifiers; + let out = d.step(vec![button(target, false)]); + assert!(out.actions.iter().any(|a| matches!(a, Action::Float { panel: 3, .. })), "{modifiers:?}: {:?}", out.actions); + } +} + +#[test] +fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() { + for case in ["escape", "focus", "exit", "cancel"] { + let mut d = Driver::new(frames()); + let target = d.tab(1).center(); + let held = d.hold(d.rect("drag-bar", 2).center(), target); + assert_eq!(held.group_drag, Some(2)); + let out = match case { + "escape" => d.step(vec![key(egui::Key::Escape)]), + "focus" => { + d.focused = false; + let out = d.step(vec![]); + d.focused = true; + out + } + "exit" => d.step(vec![Event::PointerGone]), + _ => { + cancel_drag::(&d.ctx, Id::new(AREA)); + d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))]) + } + }; + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{case}: {out:?}"); + let out = d.step(vec![Event::PointerMoved(target), button(target, false)]); + assert!(out.actions.is_empty() && out.group_drop.is_none() && out.group_drag.is_none(), "{case}: {out:?}"); + assert_eq!(d.layout, frames(), "{case}"); + assert!(!is_dragging::(&d.ctx, Id::new(AREA)), "{case}"); + } +} + +#[test] +fn floated_panel_uses_floating_size_not_the_column_height() { + for size in [Some(vec2(200.0, 150.0)), None] { + let mut layout = docked(); + let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; + **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; + let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0)); + let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { + Action::Float { panel: 3, rect } => Some(*rect), + _ => None, + }) else { + panic!("{:?}", out.actions) + }; + match size { + Some(size) => assert_eq!((width, height), (size.x, size.y)), + None => assert!(height <= 320.0, "{height}"), + } + } +} + +#[test] +fn floating_window_is_opaque_with_a_transparent_host_background() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + d.transparent = true; + d.step(vec![]); + let window = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0)); + let fill = d.ctx.global_style().visuals.window_fill; + assert_ne!(fill, Color32::TRANSPARENT); + fn filled(shape: &egui::Shape, rect: Rect, fill: Color32) -> bool { + match shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == fill, + egui::Shape::Vec(shapes) => shapes.iter().any(|shape| filled(shape, rect, fill)), + _ => false, + } + } + assert!(d.shapes.iter().any(|clipped| filled(&clipped.shape, window, fill)), "no opaque fill under the floating window"); +} + +#[test] +fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + let minimized = |d: &Driver| d.layout.floating[0].groups[0].minimized; + let tab = d.tab(6).center(); + d.click(tab); + let out = d.click(tab); + assert!(out.actions.contains(&Action::SetMinimized { panel: 6, minimized: true }), "{:?}", out.actions); + assert!(minimized(&d)); + d.wait(); + + // Another tab of the minimized group expands it and selects that tab. + let other = d.tab(5).center(); + d.click(other); + assert!(!minimized(&d)); + assert_eq!(d.layout.floating[0].groups[0].active_panel(), Some(&5)); + + d.wait(); + let bar = d.rect("drag-bar", 5).center(); + d.click(bar); + d.click(bar); + assert!(minimized(&d)); + d.wait(); + d.click(bar); + d.click(bar); + assert!(!minimized(&d)); +} + +#[test] +fn release_over_own_position_is_a_noop() { + let mut d = Driver::new(frames()); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), bar.center() + vec2(0.0, 200.0)); + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{out:?}"); + assert_eq!(d.layout, frames()); +} + +#[test] +fn floating_border_and_drag_bar_move_and_cancel_as_a_group() { + for name in ["drag-border", "drag-bar"] { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout.clone()); + let from = d.rect(name, 5).center(); + d.hold(from, from + vec2(50.0, 40.0)); + assert_eq!(d.layout, layout, "{name}: a ghost moves, not the window"); + d.step(vec![key(egui::Key::Escape)]); + d.step(vec![button(from + vec2(50.0, 40.0), false)]); + assert_eq!(d.layout, layout, "{name}"); + + let out = d.drag(from, from + vec2(50.0, 40.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{name}: {:?}", out.actions); + let [x, y, w, h] = d.layout.floating[0].rect; + assert_eq!([x, y, w, h], [130.0, 110.0, 300.0, 220.0], "{name}"); + assert_eq!(d.layout.floating[0].groups[0], Group::new(vec![5, 6], 1)); + } +} + +#[test] +fn floating_border_movement_does_not_accumulate_fractional_step_rounding() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + let from = d.rect("drag-border", 5).center(); + d.step(vec![Event::PointerMoved(from), button(from, true)]); + for index in 1..=8 { + d.step(vec![Event::PointerMoved(from + vec2(80.3, 60.7) * (index as f32 / 8.0))]); + } + d.step(vec![button(from + vec2(80.3, 60.7), false)]); + let [x, y, ..] = d.layout.floating[0].rect; + assert!((x - 160.3).abs() <= 1.0 && (y - 130.7).abs() <= 1.0, "{x} {y}"); +} + +#[test] +fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() { + for name in ["drag-border", "drag-bar"] { + let content = TestContent { host_owned: true, ..Default::default() }; + let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let out = d.drag(d.rect(name, 2).center(), pos2(70.0, 300.0)); + assert!(d.content.whole_group, "{name}"); + assert!(out.actions.is_empty() && out.group_drop.is_none(), "{name}: the host owns the release"); + assert_eq!(d.layout, frames()); + } +} + +#[test] +fn lone_bare_or_protected_panels_draw_no_strip_and_no_bar() { + struct Bare; + impl DockContent for Bare { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + fn bare(&self, panel: &u32) -> bool { + *panel == 1 + } + } + let ctx = Context::default(); + for _ in 0..2 { + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { + DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &DockStyle::from_ui(ui), &mut Bare); + }) + .textures_delta + .clear(); + } + assert!(ctx.read_response(Id::new(AREA).with(("drag-bar", Some(&1u32)))).is_none()); + assert!(ctx.read_response(Id::new(AREA).with(("tab", &1u32))).is_none()); + assert!(ctx.read_response(Id::new(AREA).with(("drag-bar", Some(&2u32)))).is_some()); +} + +#[test] +fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, (content, actions): &mut (TestContent, Vec>)| { + let out = DockArea::new(Id::new(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content); + actions.extend(out.actions); + }, + (TestContent { protected: vec![0], ..Default::default() }, Vec::new()), + ); + h.run_steps(2); + let bar_id = Id::new(AREA).with(("drag-bar", Some(&2u32))); + let at = h.ctx.read_response(bar_id).unwrap().rect.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Group with"] { + assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); + } + h.get_by_label("Close Tab Group").click(); + h.run_steps(2); + assert!(h.state().1.contains(&Action::CloseGroup { panel: 3 }), "{:?}", h.state().1); + + h.ctx.memory_mut(|memory| memory.request_focus(bar_id)); + h.run_steps(1); + assert!(DockArea::::new(Id::new(AREA)).chrome_has_focus(&h.ctx, &docked())); + h.input_mut().events.push(key(egui::Key::Enter)); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 1be249d6..849a5e59 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -19,8 +19,10 @@ pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, Dr pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; -use surface::{Drag, GroupDrag}; +use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(test)] +mod group_tests; #[cfg(test)] mod model_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index cd55820b..43d18e25 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; -use super::{Renderer, clamp_float}; +use super::{GroupKind, Renderer, clamp_float}; use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize}; /// Floating window resize handles: id suffix, hit rect and the edges they move. @@ -54,7 +54,10 @@ impl Renderer<'_, P> { let Some(first) = window.panels().next() else { continue }; let [x, y, width, height] = window.rect; let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 }); - let size = vec2(width, height).max(limits.min).min(limits.max); + let mut size = vec2(width, height).max(limits.min).min(limits.max); + if window.groups.iter().all(|group| group.minimized) { + size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height); + } let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { @@ -62,11 +65,14 @@ impl Renderer<'_, P> { ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); self.output.focused = self.activation.clone(); } - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { + egui::Area::new(id).fade_in(false).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { ui.set_min_size(rect.size()); let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); ui.allocate_rect(rect, Sense::hover()); - self.stacked_groups(ui, &window.groups, rect, true); + // Opaque even when the host's panel colours are transparent. + ui.painter().rect_filled(rect, 0.0, self.style.floating_background); + self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect }); + ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside); self.resize_handles(ui, window, id, rect, limits, screen); }); } diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 0c80d634..3b80dfb3 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -2,70 +2,77 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; -use super::{Renderer, clamp_float}; -use crate::docking::{Action, DropTarget, GroupDrag, Permissions, Placement, begin_drag, surface::TAB_PADDING}; +use super::{Renderer, clamp_float, stripless}; +use crate::docking::{Action, DropTarget, Group, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; use crate::tabs; +/// Where a group is drawn. It decides the drop targets the group offers and its gestures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) enum GroupKind { + /// A `Tabs` node of the split tree: side drops split it. + Frame, + /// A group in a dock column. + Dock, + /// A group in the floating window drawn at `window`. + Floating { window: Rect }, +} + +/// The group a tab belongs to, for its menu and minimize gestures. +pub(super) struct TabGroup<'a, P> { + pub(super) panels: &'a [P], + pub(super) selected: &'a P, + pub(super) minimized: bool, + pub(super) kind: GroupKind, + pub(super) rect: Rect, +} + +/// Width of a floating window's move grip at the left of each strip. +const GRIP_WIDTH: f32 = 12.0; +/// The top band of a strip that drags the whole group. +const BORDER_BAND: f32 = 4.0; + impl Renderer<'_, P> { - /// Groups stacked top to bottom in equal shares of `rect`. - pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[crate::docking::Group

], rect: Rect, floating: bool) { - let share = rect.height() / groups.len().max(1) as f32; - for (index, group) in groups.iter().enumerate() { - let top = rect.top() + share * index as f32; - let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), top + share)); - self.group(ui, &group.panels, group.active, group_rect, floating); + /// Groups stacked top to bottom. Minimized groups take their strip; the others share the + /// rest equally. + pub(super) fn stacked_groups(&mut self, ui: &mut Ui, groups: &[Group

], rect: Rect, kind: GroupKind) { + let strip = self.style.tab_height; + let open = groups.iter().filter(|g| !g.minimized).count(); + let fixed = groups.len().saturating_sub(open) as f32 * strip; + let share = if open > 0 { (rect.height() - fixed).max(0.0) / open as f32 } else { 0.0 }; + let mut top = rect.top(); + for group in groups { + let bottom = (top + if group.minimized { strip } else { share }).min(rect.bottom()); + let group_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), bottom)); + self.group(ui, &group.panels, group.active, group.minimized, group_rect, kind); + top = bottom; } } - pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, rect: Rect, floating: bool) { + pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; let policy = self.permissions(selected); - let bare = panels.len() == 1 && policy == Permissions::PROTECTED; - if bare { + let splits = kind == GroupKind::Frame; + if stripless(panels, &*self.content) { self.contents(ui, selected, rect); self.targets.push(DropTarget { anchor: selected.clone(), rect, tab_strip: None, - tabs: Vec::new(), - allow_splits: !floating, - accepts_tabs: false, + tabs: vec![(selected.clone(), Rect::NOTHING)], + allow_splits: splits, + accepts_tabs: policy.accepts_tabs, }); return; } + let movable = panels.iter().all(|p| self.permissions(p).movable); let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if floating { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom())); - let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if panels.iter().all(|p| self.permissions(p).movable) - && response.drag_started() - && let Some(first) = panels.first() - { - ui.ctx().data_mut(|d| { - d.insert_temp( - self.id.with("group-drag"), - GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] }, - ) - }); - } - if panels.iter().all(|p| self.permissions(p).movable) - && response.dragged() - && ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))).is_some() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect()); - if let Some(first) = panels.first() { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); + if let GroupKind::Floating { window } = kind { + let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom())); + self.window_grip(ui, panels, grip, window, movable); strip.min.x = grip.right(); } + let floating = matches!(kind, GroupKind::Floating { .. }); let requested = self.content.group_header_width(panels, selected, floating); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { @@ -74,6 +81,9 @@ impl Renderer<'_, P> { child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, panels, selected, floating); } + // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. + let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); + let tabs_width = (strip.width() - header_width - bar_width).max(0.0); let widths: Vec = panels .iter() .map(|p| { @@ -85,19 +95,21 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); - let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0); + let fit = tabs::fit(&widths, active, tabs_width, 40.0, 22.0); + let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); - let mut targets = Vec::new(); + let mut shown = Vec::new(); for (index, width) in &fit.shown { let Some(panel) = panels.get(*index) else { continue }; let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); - self.tab(ui, panel, tab, *index == active, rect); - targets.push((panel.clone(), tab)); + self.tab(ui, panel, tab, *index == active, rect, Some(&owner)); + shown.push((panel.clone(), tab)); x += width; } if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); ui.painter().with_clip_rect(overflow).text(overflow.center(), egui::Align2::CENTER_CENTER, "…", self.style.font.clone(), self.style.text); egui::Popup::menu(&response).show(|ui| { @@ -113,25 +125,190 @@ impl Renderer<'_, P> { } }); }); + x += fit.overflow_width; } - // Include hidden tab identities for source-group detection without creating hit areas. - for panel in panels { - if !targets.iter().any(|(p, _)| p == panel) { - targets.push((panel.clone(), Rect::NOTHING)); - } - } + let bar = Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom())); + self.group_handle(ui, "drag-bar", bar, &owner, movable); + // Registered last, so the band wins over the tabs below it. + let border = Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom()))); + self.group_handle(ui, "drag-border", border, &owner, movable); + // Logical order, hidden tabs included without a hit area, for no-op detection. + let tabs = + panels.iter().map(|panel| (panel.clone(), shown.iter().find(|(p, _)| p == panel).map_or(Rect::NOTHING, |(_, rect)| *rect))).collect(); self.targets.push(DropTarget { anchor: selected.clone(), rect, tab_strip: Some(strip), - tabs: targets, - allow_splits: !floating, + tabs, + allow_splits: splits, accepts_tabs: policy.accepts_tabs, }); - self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + if !minimized { + self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); + } ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } + /// The floating window's own move: the window follows the pointer, every group included. + fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) { + let Some(first) = panels.first() else { return }; + let response = ui.interact(grip, self.id.with(("move-group", Some(first))), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + if movable && response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [window.left(), window.top(), window.width(), window.height()], + gesture: GroupGesture::Window, + members: panels.to_vec(), + origin, + source: window, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if movable + && response.dragged() + && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) + && drag.gesture == GroupGesture::Window + && let Some(pointer) = ui.ctx().pointer_interact_pos() + && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + { + // From the press origin, so fractional pointer steps do not accumulate rounding. + let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); + } + ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); + } + + /// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band + /// (`drag-border`: drag only). Both move the whole group. + fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { + if !crate::docking::targets::usable(hit) { + return; + } + let bar = name == "drag-bar"; + let sense = if bar { Sense::click_and_drag() } else { Sense::drag() }; + let response = ui.interact(hit, self.id.with((name, owner.panels.first())), sense); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); + if movable && response.drag_started() { + self.start_group_drag(ui, owner.panels, owner.rect); + } + if !bar { + return; + } + if owner.kind != GroupKind::Frame && response.double_clicked() { + self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); + } + if response.has_focus() || response.hovered() { + ui.painter().with_clip_rect(hit).rect_stroke( + hit.shrink(1.0), + 0.0, + Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)), + StrokeKind::Inside, + ); + } + let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space)); + let command = if response.secondary_clicked() || keyboard { + Some(egui::SetOpenCommand::Bool(true)) + } else if response.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::menu(&response).open_memory(command).show(|ui| self.group_menu(ui, owner, owner.selected)); + } + + fn start_group_drag(&mut self, ui: &Ui, panels: &[P], rect: Rect) { + let Some(first) = panels.first() else { return }; + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(rect.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Group, + members: panels.to_vec(), + origin, + source: rect, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + + /// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group + /// and Group with. `panel` is the panel whose menu this is. + fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) { + let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p))); + let closable = all(&|p| p.closable); + let movable = all(&|p| p.movable); + let floatable = movable && all(&|p| p.floatable); + if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { + self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); + } + if owner.kind != GroupKind::Frame { + let label = if owner.minimized { self.style.expand_label.clone() } else { self.style.minimize_label.clone() }; + if self.menu_action(ui, &label) { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); + } + } + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { + let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, + }); + } + let targets = if movable { self.group_targets(owner.panels) } else { Vec::new() }; + if !targets.is_empty() { + let logical = self.style.group_with_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let response = ui.menu_button(text, |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for target in &targets { + if self.menu_label(ui, target).clicked() { + self.output.actions.push(Action::MoveGroup { + panel: panel.clone(), + to: Target::At { anchor: target.clone(), placement: Placement::Tab { before: None } }, + }); + ui.close(); + } + } + }); + }); + response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); + } + } + + /// A stock menu button with shaped text and its logical accessibility label; true when + /// clicked, after closing the menu. + fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); + let response = ui.button(text); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// One anchor per other group that accepts a whole group as tabs, in layout order. + fn group_targets(&self, members: &[P]) -> Vec

{ + let Some(first) = members.first() else { return Vec::new() }; + let mut places = Vec::new(); + let mut anchors = Vec::new(); + for anchor in self.layout.move_targets(first, |p| self.permissions(p)) { + if members.contains(anchor) { + continue; + } + let Some(place) = self.layout.place(anchor) else { continue }; + if !places.contains(&place) { + places.push(place); + anchors.push(anchor.clone()); + } + } + anchors + } + pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response { let logical = self.label_of(panel); let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text); @@ -140,7 +317,8 @@ impl Renderer<'_, P> { response } - pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) { + pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) { + let minimized = owner.filter(|owner| owner.kind != GroupKind::Frame).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); let color = if active { self.style.text } else { self.style.inactive_text }; @@ -166,6 +344,14 @@ impl Renderer<'_, P> { if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); self.output.focused = Some(panel.clone()); + if minimized == Some(true) && !active { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + } + } + if let Some(minimized) = minimized + && response.double_clicked() + { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } if policy.movable && response.drag_started() { begin_drag(ui.ctx(), self.id, panel.clone(), group); @@ -212,6 +398,10 @@ impl Renderer<'_, P> { ui.close(); } } + if let Some(owner) = owner { + ui.separator(); + self.group_menu(ui, owner, panel); + } }); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 9ed2e9c8..7539a1c0 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -8,6 +8,8 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node mod floating; +pub(in crate::docking) use group::GroupKind; + pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -90,7 +92,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); } } - Node::Tabs { panels, active } => self.group(ui, panels, *active, rect, false), + Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), Node::Dock { columns, .. } => { // Interim rendering: columns side by side, groups stacked evenly. let total: f32 = columns.iter().map(|c| c.width).sum(); @@ -99,7 +101,7 @@ impl Renderer<'_, P> { let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); left += width; - self.stacked_groups(ui, &column.groups, column_rect, false); + self.stacked_groups(ui, &column.groups, column_rect, GroupKind::Dock); } } Node::Stack { entries } => { @@ -117,7 +119,7 @@ impl Renderer<'_, P> { }; let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); - self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect); + self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); response.widget_info(|| { @@ -207,6 +209,14 @@ impl Renderer<'_, P> { } } +/// A lone protected or bare panel draws no tab strip and no drag bar. +pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

) -> bool { + match panels { + [panel] => content.permissions(panel) == Permissions::PROTECTED || content.bare(panel), + _ => false, + } +} + /// Size policy of a subtree, shared by the renderer and its measurements. pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { @@ -219,7 +229,7 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont limits.max = limits.max.min(policy.max); } limits.max = limits.max.max(limits.min); - if panels.len() != 1 || panels.first().is_some_and(|p| content.permissions(p) != Permissions::PROTECTED) { + if !stripless(panels, content) { limits.min.y = (limits.min.y + style.tab_height).min(1_000_000.0); limits.max.y = (limits.max.y + style.tab_height).min(1_000_000.0); } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 9b6214f9..de2aa445 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,31 +1,41 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; +use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; use super::{Renderer, clamp_float}; -use crate::docking::{Action, Drag, DropRequest, GroupDrag, cancel_drag, dock_model::Place, resolve_drop}; +use crate::docking::{ + Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, +}; + +/// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. +fn float_only(ctx: &Context) -> bool { + ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd) +} /// Height of a panel floated out of a dock column when the host gives no floating size. const DOCKED_FLOAT_HEIGHT: f32 = 320.0; impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { - let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))) else { return }; - if !self.layout.floating.iter().any(|g| g.contains(&drag.panel) && g.panels().all(|p| self.permissions(p).movable)) { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + let key = self.id.with("group-drag"); + let Some(drag) = ctx.data(|d| d.get_temp::>(key)) else { return }; + if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) { + ctx.data_mut(|d| d.remove::>(key)); return; } let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); + let pointer = pointer.filter(|p| p.is_finite()); + let ghost = pointer.map(|pointer| clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect())); if !escape && focused && (down || released) - && let Some(pointer) = pointer.filter(|p| p.is_finite()) + && let Some(pointer) = pointer && !self.blockers.iter().any(|r| r.contains(pointer)) - && self.host_drop(ctx, &drag.panel, pointer, true, released, None) + && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture == GroupGesture::Group)) { if released { - ctx.data_mut(|d| d.remove::>(self.id.with("group-drag"))); + ctx.data_mut(|d| d.remove::>(key)); } return; } @@ -33,19 +43,95 @@ impl Renderer<'_, P> { let has_pointer = ctx.input(|i| i.pointer.has_pointer()); // Touch-up also removes the pointer, but still completes a drop. let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); + let float_only = float_only(ctx); if escape || !focused || !down || !has_pointer { cancel_drag::

(ctx, self.id); if escape || !focused { - self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); - } else if released - && (has_pointer || touch_ended) - && let Some(pointer) = pointer.filter(|p| p.is_finite()) - && !self.blockers.iter().any(|r| r.contains(pointer)) - { - self.output.group_drop = Some((drag.panel, pointer)); + if drag.gesture == GroupGesture::Window { + self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original }); + } + return; + } + // Leaving the window ends a group drag where it is, with no drop. + if !released || !(has_pointer || touch_ended) { + return; + } + let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; + match drag.gesture { + GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), + GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), + } + } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { + self.output.group_drag = Some(drag.panel.clone()); + if drag.gesture == GroupGesture::Group { + self.paint_group_drag(ctx, &drag, pointer, float_only); + } + } + } + + /// The valid target under the pointer for a whole group. A target holding the group + /// itself is a no-op proposal; a target the model would reject is no target. + fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { + let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; + if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { + return Some(DropProposal { + anchor: drag.panel.clone(), + placement: Placement::Tab { before: None }, + preview: target.rect, + insertion: None, + noop: true, + }); + } + let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; + let action = Action::MoveGroup { + panel: drag.panel.clone(), + to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, + }; + self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) + } + + /// Release of a group drag: join or split at the target, or float at the ghost with the + /// group's size. Over its own docked position nothing changes; a floating group released + /// over itself moves there. + fn drop_group(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + if pointer.distance(drag.origin) < 2.0 { + return; + } + let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. })); + let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; + if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + self.output.actions.push(Action::MoveGroup { panel: drag.panel.clone(), to }); + return; + } + if proposal.is_some() && !floating { + return; + } + // No target: hosts that still apply their own policy for a whole-group release get it. + self.output.group_drop = Some((drag.panel.clone(), pointer)); + let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + let action = + Action::MoveGroup { panel: drag.panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) } }; + if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { + self.output.actions.push(action); + } + } + + /// Ghost outline at the pointer and one destination preview. + fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let painter = self.preview_painter(ctx); + let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + if float_only { + return; + } + if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { + let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); + let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; + painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); } - } else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) { - self.output.group_drag = Some(drag.panel); } } @@ -74,8 +160,9 @@ impl Renderer<'_, P> { } return; } + let float_only = float_only(ctx); let floatable = self.permissions(&drag.panel).floatable - && (gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { if released { @@ -83,7 +170,7 @@ impl Renderer<'_, P> { } return; } - let proposal = resolve_drop(&drag.panel, pointer, &self.targets); + let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 5321fb25..901fc273 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -32,6 +32,19 @@ pub struct DockStyle { pub panels_label: String, pub resize_label: String, pub resize_window_label: String, + /// Accessibility label of a group's drag bar and top border. + pub move_group_label: String, + pub close_group_label: String, + pub minimize_label: String, + pub expand_label: String, + pub float_group_label: String, + pub group_with_label: String, + /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, + /// before the bar narrows. + pub drag_bar_width: f32, + /// Painted under every floating window, so it stays opaque when the panel colours are not. + pub floating_background: Color32, + pub floating_stroke: Stroke, } impl DockStyle { @@ -55,11 +68,22 @@ impl DockStyle { panels_label: "Panels".into(), resize_label: "Resize panels".into(), resize_window_label: "Resize panel window".into(), + move_group_label: "Move tab group".into(), + close_group_label: "Close Tab Group".into(), + minimize_label: "Minimize".into(), + expand_label: "Expand".into(), + float_group_label: "Float Tab Group".into(), + group_with_label: "Group with".into(), + drag_bar_width: 40.0, + floating_background: visuals.window_fill, + floating_stroke: visuals.window_stroke, } } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size].iter().all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -117,10 +141,26 @@ pub(super) struct Drag

{ pub(super) grab: Vec2, } +/// What a group gesture moves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GroupGesture { + /// A floating window's grip: the window follows the pointer. + Window, + /// A drag bar or top border: a ghost follows the pointer, the release picks the target. + Group, +} + #[derive(Clone)] pub(super) struct GroupDrag

{ + /// The first member; identifies the gesture. pub(super) panel: P, + /// The floating window rect at the press, restored when a window move is cancelled. pub(super) original: [f32; 4], + pub(super) gesture: GroupGesture, + pub(super) members: Vec

, + pub(super) origin: Pos2, + /// The group (or window) rect at the press. A floated group keeps this size. + pub(super) source: Rect, } /// Start a docking drag from an application's existing tab/header. Use the same area ID when @@ -322,7 +362,7 @@ impl DockArea

{ let matches = |id: Id| id == focused; let group = |panels: &[P]| { panels.iter().any(|panel| matches(self.id.with(("tab", panel))) || matches(self.id.with(("overflow", panel)))) - || matches(self.id.with(("move-group", panels.first()))) + || ["move-group", "drag-bar", "drag-border"].iter().any(|name| matches(self.id.with((*name, panels.first())))) }; for floating in &layout.floating { if floating.groups.iter().any(|g| group(&g.panels)) From 4bf11647adb05e1b6f5a49c1ae5f7105bf746c71 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:56:52 +0000 Subject: [PATCH 012/160] docking: test tearing a group out of a floating stack --- crates/ui/src/docking/group_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 584fff2e..7edcf28f 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -519,3 +519,16 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx)); } + +#[test] +fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { + let stack = Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [80.0, 70.0, 300.0, 400.0], native: None }; + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![stack] }; + let mut d = Driver::new(layout); + let from = d.rect("drag-bar", 6).center(); + let out = d.drag(from, pos2(700.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 200.0]) })), "{:?}", out.actions); + assert_eq!(d.layout.floating.len(), 2); + assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); + assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); +} From 954ad06d250399e3ebc18ac9a0fad785eb7d7445 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:05:42 +0000 Subject: [PATCH 013/160] docking: Placement::Edge splits the layout's outer edge A frame along one edge of the docked layout, a quarter of its size. In a layout with docks the split wraps the part between them, so a bottom Timeline or top bar stays inside the docks. Behaviour follows filmcraft dock_ui.rs (workspace_edge). --- crates/ui/src/docking/dock_model.rs | 39 +++++++++++++++++++++++++++- crates/ui/src/docking/model.rs | 8 +++++- crates/ui/src/docking/model_tests.rs | 23 ++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index d4200fe3..05b3409f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -6,7 +6,7 @@ use std::hash::Hash; -use super::model::{DockError, Floating, Layout, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; +use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; use crate::layout::{SplitAxis, SplitSize}; /// Width of a new column when nothing better is known. @@ -242,6 +242,25 @@ impl Layout

{ None } + /// The node an edge split wraps: the root, or the part between the docks. + pub(in crate::docking) fn edge_path(&self) -> Vec { + let mut path = Vec::new(); + let mut node = self.root.as_ref(); + while let Some(Node::Split { axis: SplitAxis::Horizontal, first, second, .. }) = node { + if path.len() > MAX_DEPTH { + break; + } + let second_side = match (&**first, &**second) { + (Node::Dock { .. }, _) => true, + (_, Node::Dock { .. }) => false, + _ => break, + }; + path.push(second_side); + node = Some(if second_side { second } else { first }); + } + path + } + pub(super) fn columns_mut(&mut self, path: &[bool]) -> Option<&mut Vec>> { match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { Some(Node::Dock { columns, .. }) => Some(columns), @@ -494,6 +513,24 @@ impl Layout

{ groups.splice(index..index, moving.groups); Ok(()) } + Placement::Edge(zone) => { + if zone == Zone::Center || matches!(place, Place::Floating { .. }) { + return Err(DockError::InvalidPlacement); + } + let path = self.edge_path(); + let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; + if matches!(node, Node::Dock { .. }) { + return Err(DockError::InvalidPlacement); + } + let (panels, active) = flatten(moving.groups); + let new = Node::Tabs { panels, active }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let (first, second, size) = + if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; + *node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) }; + Ok(()) + } Placement::Column { after } => { let Place::Dock { path, column, .. } = &place else { return Err(DockError::InvalidPlacement) }; let columns = self.columns_mut(path).ok_or(DockError::MissingTarget)?; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 92eeedbb..952092ea 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -169,6 +169,10 @@ pub enum Placement

{ Group { below: bool }, /// A new column before (towards the window edge) or after the anchor's column. Column { after: bool }, + /// A new frame along one edge of the docked layout, a quarter of its width or height: the + /// root is split, or in a layout with docks, the part between them. The anchor names any + /// docked panel. `Center` is invalid. + Edge(Zone), } /// A closed panel's former placement. Applications may retain this beside visibility flags. @@ -970,7 +974,9 @@ impl Layout

{ fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; - if matches!(place, Place::Dock { .. } | Place::Floating { .. }) || matches!(placement, Placement::Group { .. } | Placement::Column { .. }) { + if matches!(place, Place::Dock { .. } | Place::Floating { .. }) + || matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_)) + { let group = Group::new(vec![entry.panel], 0); return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index bbd9ae05..88ca72f6 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -470,3 +470,26 @@ mod serde_tests { } } } + +#[test] +fn edge_placement_splits_the_part_between_the_docks_or_the_root() { + // Dock layout: a floating window lands under the frames, beside the end dock. + let mut layout = docked(); + layout.apply(Action::MoveWindow { panel: 6, to: Target::At { anchor: 1, placement: Placement::Edge(Zone::Bottom) } }).unwrap(); + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("{layout:?}") }; + assert!(matches!(&**second, Node::Dock { .. })); + let Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(size), second: bottom, .. } = &**first else { panic!("{first:?}") }; + assert_eq!(*size, 0.75); + assert_eq!(**bottom, tabs(&[6, 7, 8], 1)); + assert!(layout.floating.is_empty()); + + // Frame layout: one panel takes the whole left edge. + let mut frames = Layout { root: Some(hsplit(SplitSize::Ratio(0.5), tabs(&[1], 0), tabs(&[2, 3], 0))), floating: Vec::new() }; + frames.apply(Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }).unwrap(); + let Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(size), first, .. }) = &frames.root else { panic!() }; + assert_eq!((*size, &**first), (0.25, &tabs(&[3], 0))); + + let before = frames.clone(); + assert_eq!(frames.apply(Action::Move { panel: 2, anchor: 1, placement: Placement::Edge(Zone::Center) }), Err(DockError::InvalidPlacement)); + assert_eq!(frames, before); +} From f1a3683cc996bb5dbe3e964de7f0ab61e60143f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:15:14 +0000 Subject: [PATCH 014/160] docking: dock profile with columns, column bars, seams and dock targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node::Dock renders as side docks: columns from the window edge inwards, each with a column bar (drag, »/« button, double-click and menu toggle iconic) above stacked groups. A split beside a dock gives the dock its columns' width. Every column's inner edge resizes it; dividers between open groups save both heights; minimized groups keep theirs. A column whose groups' minimum heights do not fit scrolls. Drops resolve dock targets before group targets: column edges (new column), seams and the top or bottom quarter of a group body (new group), the room under a minimized column (below its last group) and the left or right edge of the docked area without a dock (new dock, offered once the layout has a dock). They show a horizontal or full-height vertical line. Floating windows and host overlays occlude them. Tab drags use the same targets with Move. A column bar drag moves the column with a ghost and floats its groups away from a dock; Escape, focus loss, pointer exit and cancel_drag cancel it like a group drag. Side::Start maps to the left edge, or the right with DockStyle::right_to_left. New DockStyle fields: column_bar_height, seam_band, edge_band, right_to_left and the column labels. Behaviour follows vectorcraft crates/ui-egui/src/floating.rs (drop geometry, column layout and scrolling) and photocraft crates/ui-egui/src/dock.rs (group heights). --- crates/ui/src/docking/render/dock.rs | 505 ++++++++++++++++++++++++ crates/ui/src/docking/render/group.rs | 5 +- crates/ui/src/docking/render/mod.rs | 21 +- crates/ui/src/docking/render/preview.rs | 41 +- crates/ui/src/docking/surface.rs | 35 +- crates/ui/src/docking/targets.rs | 76 +++- 6 files changed, 656 insertions(+), 27 deletions(-) create mode 100644 crates/ui/src/docking/render/dock.rs diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs new file mode 100644 index 00000000..c3e39966 --- /dev/null +++ b/crates/ui/src/docking/render/dock.rs @@ -0,0 +1,505 @@ +//! [D] The dock profile: side docks of columns, each a column bar above stacked groups. +//! +//! Columns run from the window edge inwards. Every column's inner edge resizes it; the last +//! column's inner edge is the dock's inner edge. Behaviour follows VectorCraft `floating.rs` +//! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{GroupKind, Renderer}; +use crate::docking::{ + Action, Column, Group, GroupDrag, GroupGesture, Node, Side, Target, + targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, +}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +/// Width of an iconic column without a saved width. +const ICONIC_WIDTH: f32 = 36.0; +/// Widest an iconic column gets by dragging its inner edge. +const ICONIC_MAX: f32 = 240.0; +/// Width of the »/« button at the outer end of a column bar. +const COLLAPSE_WIDTH: f32 = 18.0; + +/// The physical window edge a dock sits on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::docking) enum Edge { + Left, + Right, +} + +/// `Start` is the left edge in a left-to-right layout and the right edge in a right-to-left one. +pub(in crate::docking) fn edge_of(side: Side, right_to_left: bool) -> Edge { + match (side, right_to_left) { + (Side::Start, false) | (Side::End, true) => Edge::Left, + (Side::Start, true) | (Side::End, false) => Edge::Right, + } +} + +fn side_at(edge: Edge, right_to_left: bool) -> Side { + match (edge, right_to_left) { + (Edge::Left, false) | (Edge::Right, true) => Side::Start, + (Edge::Left, true) | (Edge::Right, false) => Side::End, + } +} + +pub(in crate::docking) fn column_width

(column: &Column

) -> f32 { + if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width } +} + +/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved +/// height, minimum). Minimized groups take `strip`, groups with a saved height keep it, and the +/// rest share what is left; when every open group has a height, the last one takes the rest. +/// Saved heights shrink towards their minimums before the column overflows and scrolls. +pub(in crate::docking) fn group_heights(groups: &[(bool, Option, f32)], strip: f32, room: f32) -> Vec { + let open: Vec = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.0)).collect(); + let mut shared: Vec = open.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| g.1.is_none_or(|h| !h.is_finite()))).collect(); + if shared.is_empty() { + shared.extend(open.last()); + } + let mut heights: Vec = groups + .iter() + .enumerate() + .map(|(i, (minimized, height, min))| { + if *minimized { + strip + } else if shared.contains(&i) { + 0.0 + } else { + height.unwrap_or(*min).max(*min) + } + }) + .collect(); + let mins: Vec = groups.iter().map(|g| if g.0 { strip } else { g.2 }).collect(); + let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum(); + let deficit = heights.iter().sum::() + shared_min - room; + let slack: f32 = open.iter().filter(|i| !shared.contains(i)).filter_map(|i| Some(heights.get(*i)? - mins.get(*i)?)).sum(); + if deficit > 0.0 && slack > 0.0 { + let take = deficit.min(slack) / slack; + for i in open.iter().filter(|i| !shared.contains(i)) { + if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { + *height -= (*height - min) * take; + } + } + } + let free = (room - heights.iter().sum::()).max(0.0); + let share = free / shared.len().max(1) as f32; + for i in &shared { + if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { + *height = share.max(*min); + } + } + heights +} + +impl Renderer<'_, P> { + /// A horizontal split with a dock child: the dock takes its columns' width on its physical + /// edge, the sibling the rest. False when neither child is a dock. + pub(super) fn dock_split(&mut self, ui: &mut Ui, first: &Node

, second: &Node

, rect: Rect, path: &mut Vec) -> bool { + let (dock_first, side, columns, other) = match (first, second) { + (Node::Dock { side, columns }, other) => (true, *side, columns, other), + (other, Node::Dock { side, columns }) => (false, *side, columns, other), + _ => return false, + }; + let gap = self.style.gap; + let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width()); + let width = columns.iter().map(column_width).sum::().clamp(0.0, room); + let (dock, rest) = match edge_of(side, self.style.right_to_left) { + Edge::Left => ( + Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), + Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max), + ), + Edge::Right => ( + Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max), + Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())), + ), + }; + self.dock(ui, side, columns, dock, room); + path.push(dock_first); + self.node(ui, other, rest, path); + path.pop(); + true + } + + /// One dock in `rect`, columns from its outer edge. `room` is the widest the dock may get. + pub(super) fn dock(&mut self, ui: &mut Ui, side: Side, columns: &[Column

], rect: Rect, room: f32) { + let edge = edge_of(side, self.style.right_to_left); + let (outer, inward) = match edge { + Edge::Left => (rect.left(), 1.0), + Edge::Right => (rect.right(), -1.0), + }; + if columns.is_empty() { + let (band, line) = column_edge(outer, inward, rect.y_range()); + let band = band.union(Rect::from_x_y_ranges( + outer.min(outer + inward * self.style.edge_band)..=outer.max(outer + inward * self.style.edge_band), + rect.y_range(), + )); + self.dock_targets.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + return; + } + if usable(rect) { + ui.painter().rect_filled(rect, 0.0, self.style.background); + } + let total: f32 = columns.iter().map(column_width).sum(); + let mut x = outer; + let mut drawn = Vec::new(); + for (index, column) in columns.iter().enumerate() { + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; + let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0)); + let inner = x + inward * width; + let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); + self.column(ui, column, first, column_rect, edge); + if index == 0 { + let (band, line) = column_edge(x, inward, rect.y_range()); + self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: false }, band, line }); + } + let (band, line) = column_edge(inner, -inward, rect.y_range()); + self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line }); + drawn.push((column, first, width, inner)); + x = inner; + } + // Registered after the columns, so the narrow hit areas win at the edges. + for (column, first, width, inner) in drawn { + let others = total - column_width(column); + let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) }; + let hi = hi.min(room - others).max(lo.min(room - others).max(0.0)); + let lo = lo.min(hi); + let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range()); + let response = ResizeHandle::new( + self.id.with(("column-resize", Some(first))), + SplitAxis::Horizontal, + divider, + &self.style.resize_label, + self.handle_style(), + ) + .value(width, lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + let next = (width + inward * response.delta).clamp(lo, hi); + if response.delta != 0.0 && next != width { + self.output.actions.push(if column.iconic { + Action::ResizeIconic { panel: first.clone(), width: Some(next) } + } else { + Action::ResizeColumn { panel: first.clone(), width: next } + }); + } + } + } + + fn handle_style(&self) -> ResizeHandleStyle { + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent } + } + + /// Width bounds of an expanded column from its panels' limits. + fn column_bounds(&self, column: &Column

) -> (f32, f32) { + let mut lo = self.style.min_pane; + let mut hi = f32::INFINITY; + for panel in column.groups.iter().flat_map(|g| g.panels.iter()) { + let limits = self.limits(panel); + lo = lo.max(limits.min.x); + hi = hi.min(limits.max.x); + } + (lo, hi.max(lo)) + } + + /// Least height of an open group: its strip and the tallest minimum of its panels. + fn group_min(&self, group: &Group

) -> f32 { + let body = group.panels.iter().map(|p| self.limits(p).min.y).fold(self.style.min_pane, f32::max); + self.style.tab_height + body + } + + fn column(&mut self, ui: &mut Ui, column: &Column

, first: &P, rect: Rect, edge: Edge) { + if !usable(rect) { + return; + } + let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); + self.column_bar(ui, column, first, bar, rect, edge); + let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic || !usable(body) { + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + return; + } + let sizes: Vec<_> = column.groups.iter().map(|g| (g.minimized, g.height, self.group_min(g))).collect(); + let heights = group_heights(&sizes, self.style.tab_height, body.height()); + let total: f32 = heights.iter().sum(); + let start = self.targets.len(); + if total > body.height() + 0.5 { + let mut child = + ui.new_child(UiBuilder::new().id_salt(("dock-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(body.intersect(ui.clip_rect())); + egui::ScrollArea::vertical() + .id_salt(self.id.with(("column-scroll", first))) + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL) + .show(&mut child, |ui| { + let (content, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); + self.column_groups(ui, column, &heights, content, body); + }); + } else { + self.column_groups(ui, column, &heights, body, body); + } + // A group scrolled out of view takes nothing. + for target in self.targets.iter_mut().skip(start) { + target.rect = target.rect.intersect(body); + target.tab_strip = target.tab_strip.map(|strip| strip.intersect(body)); + for (_, tab) in &mut target.tabs { + *tab = tab.intersect(body); + } + } + ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); + } + + /// Groups top to bottom in `area` (the scrolled content when the column scrolls), their + /// seams and bands clipped to the visible `view`, and the height dividers. + fn column_groups(&mut self, ui: &mut Ui, column: &Column

, heights: &[f32], area: Rect, view: Rect) { + let mut top = area.top(); + let mut rects = Vec::new(); + for (group, height) in column.groups.iter().zip(heights) { + let rect = Rect::from_min_max(pos2(area.left(), top), pos2(area.right(), top + height)); + self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Dock); + rects.push(rect); + top += height; + } + let xs = view.x_range(); + let half = self.style.seam_band * 0.5; + let push = |targets: &mut Vec>, target: DockTarget

, band: Rect, line: Rect| { + let band = band.intersect(view); + if usable(band) { + targets.push(DockDrop { target, band, line: line.intersect(view.expand(2.0)) }); + } + }; + let mut previous: Option<&P> = None; + for (group, rect) in column.groups.iter().zip(&rects) { + let Some(anchor) = group.panels.first() else { continue }; + if let Some(above) = previous { + let band = Rect::from_x_y_ranges(xs, (rect.top() - half)..=(rect.top() + half)); + push(&mut self.dock_targets, DockTarget::Seam { anchor: above.clone() }, band, horizontal_line(rect.top(), xs)); + } + if !group.minimized { + let body = Rect::from_min_max(pos2(rect.left(), (rect.top() + self.style.tab_height).min(rect.bottom())), rect.max); + let quarter = body.height() * 0.25; + let upper = Rect::from_x_y_ranges(xs, body.top()..=(body.top() + quarter)); + let lower = Rect::from_x_y_ranges(xs, (body.bottom() - quarter)..=body.bottom()); + push(&mut self.dock_targets, DockTarget::GroupBand { anchor: anchor.clone(), below: false }, upper, horizontal_line(rect.top(), xs)); + push( + &mut self.dock_targets, + DockTarget::GroupBand { anchor: anchor.clone(), below: true }, + lower, + horizontal_line(rect.bottom(), xs), + ); + } + previous = Some(anchor); + } + // The room under the last group, when every group is minimized, stacks below it. + if let Some(last) = previous + && top < view.bottom() + { + let band = Rect::from_x_y_ranges(xs, top..=view.bottom()); + push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs)); + } + // Height dividers between an open group and the next open one, after the groups so + // their hit areas win at the boundary. Both groups' heights are saved. + let open: Vec = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect(); + for pair in open.windows(2) { + let &[i, j] = pair else { continue }; + let (Some(upper), Some(lower), Some(rect)) = (column.groups.get(i), column.groups.get(j), rects.get(i)) else { continue }; + let (Some(a), Some(b), Some(first), Some(second)) = (heights.get(i), heights.get(j), upper.panels.first(), lower.panels.first()) else { + continue; + }; + let (lo, hi) = (self.group_min(upper), a + b - self.group_min(lower)); + let hi = hi.max(lo); + let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0)); + let response = ResizeHandle::new( + self.id.with(("group-resize", Some(first))), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + self.handle_style(), + ) + .value(*a, lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + let next = (a + response.delta).clamp(lo, hi); + if response.delta != 0.0 && next != *a { + self.output.actions.push(Action::ResizeGroup { panel: first.clone(), height: Some(next) }); + self.output.actions.push(Action::ResizeGroup { panel: second.clone(), height: Some(a + b - next) }); + } + } + } + + /// The column bar: the »/« button at its outer end, the rest drags the column (S6), + /// toggles iconic on double-click (M2) and opens the column menu (U1). + fn column_bar(&mut self, ui: &mut Ui, column: &Column

, first: &P, bar: Rect, rect: Rect, edge: Edge) { + ui.painter().rect_filled(bar, 0.0, self.style.tab_background); + let width = COLLAPSE_WIDTH.min(bar.width()); + let (button, grip) = match edge { + Edge::Left => ( + Rect::from_min_max(bar.min, pos2(bar.left() + width, bar.bottom())), + Rect::from_min_max(pos2(bar.left() + width, bar.top()), bar.max), + ), + Edge::Right => ( + Rect::from_min_max(pos2(bar.right() - width, bar.top()), bar.max), + Rect::from_min_max(bar.min, pos2(bar.right() - width, bar.bottom())), + ), + }; + let label = if column.iconic { self.style.expand_panels_label.clone() } else { self.style.collapse_to_icons_label.clone() }; + if usable(button) { + let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click()); + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &label)); + // The arrows point where the column goes: towards the window edge when it collapses. + let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" }; + let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text }; + ui.painter().with_clip_rect(button).text(button.center(), egui::Align2::CENTER_CENTER, glyph, self.style.font.clone(), color); + if response.clicked() { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + } + if !usable(grip) { + return; + } + let movable = column.groups.iter().flat_map(|g| g.panels.iter()).all(|p| self.permissions(p).movable); + let response = ui.interact(grip, self.id.with(("column-bar", Some(first))), Sense::click_and_drag()); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_column_label)); + if movable && response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Column, + members: column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), + origin, + source: rect, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if response.double_clicked() { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + for k in 0..2 { + let y = grip.center().y - 1.5 + k as f32 * 3.0; + let x = grip.center().x; + ui.painter().with_clip_rect(grip).line_segment([pos2(x - 9.0, y), pos2(x + 9.0, y)], Stroke::new(1.0, self.style.inactive_text)); + } + if response.has_focus() || response.hovered() { + ui.painter().with_clip_rect(grip).rect_stroke( + grip.shrink(1.0), + 0.0, + Stroke::new(1.0, self.style.accent.gamma_multiply(0.5)), + StrokeKind::Inside, + ); + } + let keyboard = response.has_focus() && ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space)); + let command = if response.secondary_clicked() || keyboard { + Some(egui::SetOpenCommand::Bool(true)) + } else if response.clicked() { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + egui::Popup::menu(&response).open_memory(command).show(|ui| { + if self.menu_action(ui, &label) { + self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); + } + }); + } + + /// Whether a floating window (other than the one following the pointer) or a host overlay + /// covers `pointer`. Covered dock targets take nothing. + fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool { + self.blockers.iter().any(|r| r.contains(pointer)) + || self.layout.floating.iter().any(|window| { + let Some(first) = window.panels().next() else { return false }; + !moving.is_some_and(|p| window.contains(p)) + && ctx.memory(|m| m.area_rect(self.id.with(("floating", first)))).is_some_and(|r| r.contains(pointer)) + }) + } + + /// New docks are offered only where the dock profile is in use: in a layout with a dock. + fn has_dock(&self) -> bool { + self.layout.dock_path(Side::Start).is_some() || self.layout.dock_path(Side::End).is_some() + } + + /// Dock targets, with the new-dock bands along the docked area's left and right edges that + /// have no dock yet. + fn dock_candidates(&self) -> Vec> { + let mut candidates = self.dock_targets.clone(); + if !self.has_dock() || !usable(self.workspace) { + return candidates; + } + let rtl = self.style.right_to_left; + let ys = self.workspace.y_range(); + for edge in [Edge::Left, Edge::Right] { + let side = side_at(edge, rtl); + if self.layout.dock_path(side).is_some() { + continue; + } + let (x, inward) = match edge { + Edge::Left => (self.workspace.left(), self.style.edge_band), + Edge::Right => (self.workspace.right(), -self.style.edge_band), + }; + let band = Rect::from_x_y_ranges(x.min(x + inward)..=x.max(x + inward), ys); + let line = vertical_line(x + inward.signum() * 2.0, ys); + candidates.push(DockDrop { target: DockTarget::NewDock { side }, band, line }); + } + candidates + } + + /// The dock target a whole group or column drag would take at `pointer`, with the action + /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. + pub(super) fn dock_group_proposal(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { + let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel); + if self.dock_covered(ctx, pointer, moving) { + return None; + } + let candidates = self.dock_candidates(); + let drop = resolve_dock(pointer, &candidates)?; + let action = group_action(drag, drop.target.to()); + self.checked(action).map(|action| (action, drop.line)) + } + + /// The dock target a single tab drag would take: a new group or column of that panel. A new + /// dock only takes a panel that is alone in its group. + pub(super) fn dock_tab_proposal(&self, ctx: &Context, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { + if self.dock_covered(ctx, pointer, None) { + return None; + } + let candidates = self.dock_candidates(); + let drop = resolve_dock(pointer, &candidates)?; + let action = match drop.target.to() { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to @ Target::NewDock { .. } + if self.layout.place(panel).and_then(|place| self.layout.group(&place)).is_some_and(|g| g.panels.len() == 1) => + { + Action::MoveGroup { panel: panel.clone(), to } + } + Target::NewDock { .. } | Target::Float { .. } => return None, + }; + self.checked(action).map(|action| (action, drop.line)) + } + + /// `Some(None)` when the action is valid but changes nothing, `None` when it is invalid. + fn checked(&self, action: Action

) -> Option>> { + let mut candidate = self.layout.clone(); + candidate.apply_with_permissions(action.clone(), |p| self.permissions(p)).ok()?; + Some((candidate != *self.layout).then_some(action)) + } + + /// The insertion line of a dock target. + pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) { + if usable(line) { + self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent); + } + } +} + +/// The move a group gesture makes: the column for a column bar, else the group. +pub(super) fn group_action(drag: &GroupDrag

, to: Target

) -> Action

{ + match drag.gesture { + GroupGesture::Column => Action::MoveColumn { panel: drag.panel.clone(), to }, + GroupGesture::Window | GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 3b80dfb3..3c5d33ce 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -251,6 +251,9 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } + if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { + self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); + } if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { @@ -281,7 +284,7 @@ impl Renderer<'_, P> { /// A stock menu button with shaped text and its logical accessibility label; true when /// clicked, after closing the menu. - fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 7539a1c0..3b16feed 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -6,6 +6,7 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; +mod dock; mod floating; pub(in crate::docking) use group::GroupKind; @@ -24,6 +25,8 @@ pub(super) struct Renderer<'a, P> { /// The docked area, for host drop requests. pub(super) workspace: Rect, pub(super) targets: Vec>, + /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. + pub(super) dock_targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, } @@ -63,6 +66,7 @@ impl Renderer<'_, P> { return; } match node { + Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {} Node::Split { axis, size, first, second } => { let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); @@ -93,17 +97,7 @@ impl Renderer<'_, P> { } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), - Node::Dock { columns, .. } => { - // Interim rendering: columns side by side, groups stacked evenly. - let total: f32 = columns.iter().map(|c| c.width).sum(); - let mut left = rect.left(); - for column in columns { - let width = if total > 0.0 { rect.width() * column.width / total } else { 0.0 }; - let column_rect = Rect::from_min_max(pos2(left, rect.top()), pos2(left + width, rect.bottom())); - left += width; - self.stacked_groups(ui, &column.groups, column_rect, GroupKind::Dock); - } - } + Node::Dock { side, columns } => self.dock(ui, *side, columns, rect, rect.width()), Node::Stack { entries } => { let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); let remaining = (rect.height() - total_header).max(0.0); @@ -220,7 +214,10 @@ pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { - Node::Dock { .. } => PanelLimits { min: Vec2::splat(style.min_pane), ..PanelLimits::default() }, + Node::Dock { columns, .. } => { + let width: f32 = columns.iter().map(dock::column_width).sum(); + PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } + } Node::Tabs { panels, .. } => { let mut limits = PanelLimits::default(); for panel in panels { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index de2aa445..010401de 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; -use super::{Renderer, clamp_float}; +use super::{Renderer, clamp_float, dock::group_action}; use crate::docking::{ Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, }; @@ -32,7 +32,7 @@ impl Renderer<'_, P> { && (down || released) && let Some(pointer) = pointer && !self.blockers.iter().any(|r| r.contains(pointer)) - && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture == GroupGesture::Group)) + && self.host_drop(ctx, &drag.panel, pointer, true, released, ghost.filter(|_| drag.gesture != GroupGesture::Window)) { if released { ctx.data_mut(|d| d.remove::>(key)); @@ -59,11 +59,11 @@ impl Renderer<'_, P> { let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), - GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - if drag.gesture == GroupGesture::Group { + if drag.gesture != GroupGesture::Window { self.paint_group_drag(ctx, &drag, pointer, float_only); } } @@ -83,10 +83,7 @@ impl Renderer<'_, P> { }); } let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; - let action = Action::MoveGroup { - panel: drag.panel.clone(), - to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, - }; + let action = group_action(drag, Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }); self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) } @@ -97,11 +94,15 @@ impl Renderer<'_, P> { if pointer.distance(drag.origin) < 2.0 { return; } + if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) { + self.output.actions.extend(action); + return; + } let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. })); let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; - self.output.actions.push(Action::MoveGroup { panel: drag.panel.clone(), to }); + self.output.actions.push(group_action(drag, to)); return; } if proposal.is_some() && !floating { @@ -110,8 +111,7 @@ impl Renderer<'_, P> { // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); - let action = - Action::MoveGroup { panel: drag.panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) } }; + let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); } @@ -125,6 +125,12 @@ impl Renderer<'_, P> { if float_only { return; } + if let Some((action, line)) = self.dock_group_proposal(ctx, drag, pointer) { + if action.is_some() { + self.paint_dock_line(ctx, line); + } + return; + } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; @@ -170,6 +176,19 @@ impl Renderer<'_, P> { } return; } + let dock = if float_only || gone { None } else { self.dock_tab_proposal(ctx, &drag.panel, pointer) }; + if let Some((action, line)) = dock { + if released { + cancel_drag::

(ctx, self.id); + if let Some(action) = action.filter(|_| pointer.distance(drag.origin) >= 2.0) { + self.output.actions.push(action); + self.output.focused = Some(drag.panel); + } + } else if action.is_some() { + self.paint_dock_line(ctx, line); + } + return; + } let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; if released { cancel_drag::

(ctx, self.id); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 901fc273..d721c44d 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -45,6 +45,19 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colours are not. pub floating_background: Color32, pub floating_stroke: Stroke, + /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. + pub column_bar_height: f32, + /// [D] Height of the band around the seam between two stacked groups. + pub seam_band: f32, + /// [D] Width of the band along the window's left and right edges that makes a new dock. + pub edge_band: f32, + /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide + /// layout direction, so the host sets this from its locale. Frame splits keep their sides. + pub right_to_left: bool, + /// [D] Accessibility label of a column bar. + pub move_column_label: String, + pub collapse_to_icons_label: String, + pub expand_panels_label: String, } impl DockStyle { @@ -77,12 +90,20 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, + column_bar_height: 14.0, + seam_band: 8.0, + edge_band: 12.0, + right_to_left: false, + move_column_label: "Move panel column".into(), + collapse_to_icons_label: "Collapse to Icons".into(), + expand_panels_label: "Expand Panels".into(), } } pub(super) fn valid(&self) -> bool { [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] .iter() + .chain(&[self.column_bar_height, self.seam_band, self.edge_band]) .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 @@ -148,6 +169,8 @@ pub(super) enum GroupGesture { Window, /// A drag bar or top border: a ghost follows the pointer, the release picks the target. Group, + /// [D] A column bar: like `Group`, for the whole dock column. + Column, } #[derive(Clone)] @@ -378,7 +401,16 @@ impl DockArea

{ while let Some((node, path)) = nodes.pop() { match node { Node::Tabs { panels, .. } if group(panels) => return true, - Node::Dock { columns, .. } if columns.iter().flat_map(|c| c.groups.iter()).any(|g| group(&g.panels)) => return true, + Node::Dock { columns, .. } + if columns.iter().flat_map(|c| c.groups.iter()).any(|g| { + group(&g.panels) + || ["column-bar", "column-collapse", "column-resize", "group-resize"] + .iter() + .any(|name| matches(self.id.with((*name, g.panels.first())))) + }) => + { + return true; + } Node::Stack { entries } => { if entries.iter().any(|entry| { matches(self.id.with(("tab", &entry.panel))) @@ -485,6 +517,7 @@ impl DockArea

{ layout, workspace: rect, targets: Vec::new(), + dock_targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), }; diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index a4c8b631..cd370e5a 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,6 +1,6 @@ -use egui::{Pos2, Rect, pos2}; +use egui::{Pos2, Rangef, Rect, pos2}; -use super::{Placement, Zone}; +use super::{Placement, Side, Target, Zone}; /// One visible group, supplied in back-to-front order to [`resolve_drop`]. An empty tab strip /// or a group that rejects tabs may still accept side splits, e.g. a protected document canvas. @@ -149,3 +149,75 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg noop: false, }) } + +/// [D] A dock drop target, built once per frame next to the [`DropTarget`]s. The pointer selects +/// it inside `band`; the preview is the line `line`. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct DockDrop

{ + pub(super) target: DockTarget

, + pub(super) band: Rect, + pub(super) line: Rect, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum DockTarget

{ + /// Column edge: a new column beside the column of `anchor`, further from the window edge + /// when `after` (P5). + ColumnEdge { anchor: P, after: bool }, + /// The seam between two stacked groups: a new group below the group of `anchor` (P4). + Seam { anchor: P }, + /// The top or bottom quarter of a group body, or the free room under a column's last group (P3). + GroupBand { anchor: P, below: bool }, + /// A window edge without a dock, or an empty dock (P5, P6). + NewDock { side: Side }, +} + +impl DockTarget

{ + pub(super) fn to(&self) -> Target

{ + match self { + Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } }, + Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true } }, + Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below } }, + Self::NewDock { side } => Target::NewDock { side: *side }, + } + } + + /// Rank in resolution order: column edges reach past their columns, so they come first. + fn rank(&self) -> u8 { + match self { + Self::ColumnEdge { .. } => 0, + Self::Seam { .. } => 1, + Self::GroupBand { .. } => 2, + Self::NewDock { .. } => 3, + } + } +} + +/// The dock target under the pointer: column edges, then seams, then group bands, then new docks. +pub(super) fn resolve_dock(pointer: Pos2, targets: &[DockDrop

]) -> Option<&DockDrop

> { + if !pointer.is_finite() { + return None; + } + targets.iter().filter(|t| usable(t.band) && t.band.contains(pointer)).min_by_key(|t| t.target.rank()) +} + +/// Width of the insertion lines dock targets preview with. +const LINE: f32 = 4.0; +/// How far a column edge band reaches into the column, and past the edge out of it. +const EDGE_IN: f32 = 6.0; +const EDGE_OUT: f32 = 10.0; + +/// Band and line of a column edge at `x`. `inward` is +1 when the column lies right of the edge, +/// -1 when it lies left of it. The band reaches further out of the column than into it. +pub(super) fn column_edge(x: f32, inward: f32, ys: Rangef) -> (Rect, Rect) { + let (a, b) = (x + inward * EDGE_IN, x - inward * EDGE_OUT); + (Rect::from_x_y_ranges(a.min(b)..=a.max(b), ys), vertical_line(x, ys)) +} + +pub(super) fn vertical_line(x: f32, ys: Rangef) -> Rect { + Rect::from_x_y_ranges((x - LINE * 0.5)..=(x + LINE * 0.5), ys) +} + +pub(super) fn horizontal_line(y: f32, xs: Rangef) -> Rect { + Rect::from_x_y_ranges(xs, (y - LINE * 0.5)..=(y + LINE * 0.5)) +} From c07a0debb409a24a672a79d473a738ef4c38770e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:15:14 +0000 Subject: [PATCH 015/160] docking: test dock columns, seams, column drags and RTL docks Behaviour tests with real egui input: seam, band, column edge and new dock drops with their line previews, tab drops, a column taking groups anywhere, scrolling, column bar drags and their cancellation, dividers, minimize, iconic toggles, occlusion by floating windows and the mirrored right-to-left layout. Behaviour follows vectorcraft crates/ui-egui/src/floating.rs tests. --- crates/ui/src/docking/dock_tests.rs | 471 ++++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + 2 files changed, 473 insertions(+) create mode 100644 crates/ui/src/docking/dock_tests.rs diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs new file mode 100644 index 00000000..b719d3a7 --- /dev/null +++ b/crates/ui/src/docking/dock_tests.rs @@ -0,0 +1,471 @@ +//! [D] Dock profile: columns, stacked groups, seams, column bars and their drops. + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Context, Event, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, pos2, vec2}; + +const AREA: &str = "dock-area"; + +fn group(panels: &[u32]) -> Group { + Group::new(panels.to_vec(), 0) +} + +/// A dock on `side` with `columns` (each a list of groups, 240 wide) beside canvas 0. +fn docked(side: Side, columns: &[&[&[u32]]]) -> Layout { + let dock = Node::Dock { side, columns: columns.iter().map(|groups| Column::new(groups.iter().map(|g| group(g)).collect(), 240.0)).collect() }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + let (first, second, size) = match side { + Side::Start => (dock, canvas, SplitSize::FixedFirst(240.0)), + Side::End => (canvas, dock, SplitSize::FixedSecond(240.0)), + }; + Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }), floating: Vec::new() } +} + +fn with_floating(mut layout: Layout, windows: &[(u32, [f32; 4])]) -> Layout { + layout.floating.extend(windows.iter().map(|(panel, rect)| Floating::single(vec![*panel], 0, *rect))); + layout +} + +fn columns(layout: &Layout, side: Side) -> Vec>> { + fn find(node: &Node, side: Side) -> Option>>> { + match node { + Node::Dock { side: s, columns } if *s == side => { + Some(columns.iter().map(|c| c.groups.iter().map(|g| g.panels.clone()).collect()).collect()) + } + Node::Split { first, second, .. } => find(first, side).or_else(|| find(second, side)), + _ => None, + } + } + layout.root.as_ref().and_then(|root| find(root, side)).unwrap_or_default() +} + +fn column(layout: &Layout, index: usize) -> Column { + fn find(node: &Node) -> Option<&Vec>> { + match node { + Node::Dock { columns, .. } => Some(columns), + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, + } + } + layout.root.as_ref().and_then(find).and_then(|c| c.get(index)).cloned().unwrap() +} + +#[derive(Default)] +struct TestContent { + min_height: f32, +} + +impl DockContent for TestContent { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + } + fn limits(&self, _: &u32) -> PanelLimits { + PanelLimits { min: vec2(0.0, self.min_height), ..PanelLimits::default() } + } +} + +/// Renders a layout in a 600x600 area of an 800x600 window and applies what it emits. +struct Driver { + ctx: Context, + layout: Layout, + content: TestContent, + focused: bool, + right_to_left: bool, + shapes: Vec, + time: f64, +} + +impl Driver { + fn new(layout: Layout) -> Self { + Self::with(layout, TestContent::default(), false) + } + + fn with(layout: Layout, content: TestContent, right_to_left: bool) -> Self { + let mut driver = Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }; + // Floating areas settle after their sizing pass. + for _ in 0..3 { + driver.step(vec![]); + } + driver + } + + fn frame(&mut self, events: Vec) -> DockOutput { + let mut output = DockOutput::default(); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + events, + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, rtl) = (&self.layout, &mut self.content, self.right_to_left); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + style.right_to_left = rtl; + ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 600.0))), |ui| { + output = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + self.shapes = full.shapes; + output + } + + fn step(&mut self, events: Vec) -> DockOutput { + let output = self.frame(events); + assert!(output.error.is_none(), "{output:?}"); + let permissions = |p: &u32| self.content.permissions(p); + for action in &output.actions { + self.layout.apply_with_permissions(action.clone(), permissions).unwrap(); + } + output + } + + fn rect(&self, name: &str, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with((name, Some(&panel)))).unwrap().rect + } + + fn tab(&self, panel: u32) -> Rect { + self.ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect + } + + fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(from)]); + self.step(vec![button(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + self.step(vec![Event::PointerMoved(to)]) + } + + fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + fn click(&mut self, at: Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(at), button(at, true)]); + self.step(vec![button(at, false)]) + } + + /// Two frames: egui reports a moved widget's rect a frame late. + fn settle(&mut self) { + self.step(vec![]); + self.step(vec![]); + } + + fn wait(&mut self) { + for _ in 0..45 { + self.step(vec![]); + } + } + + /// Whether the last frame painted the filled insertion line `line`. + fn painted(&self, line: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == line && shape.fill != egui::Color32::TRANSPARENT, + _ => false, + }) + } +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: egui::Key) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +fn moved(actions: &[Action]) -> Option<(u32, &Target)> { + actions.iter().find_map(|a| match a { + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } => Some((*panel, to)), + _ => None, + }) +} + +fn hline(y: f32, x: std::ops::RangeInclusive) -> Rect { + Rect::from_x_y_ranges(x, (y - 2.0)..=(y + 2.0)) +} + +fn vline(x: f32, y: std::ops::RangeInclusive) -> Rect { + Rect::from_x_y_ranges((x - 2.0)..=(x + 2.0), y) +} + +/// A floating group's drag bar. +fn bar(d: &Driver, panel: u32) -> Pos2 { + d.rect("drag-bar", panel).center() +} + +// Geometry of `docked(Start, ..)`: the dock is 0..240 wide, its column bar 14 high, and two open +// groups share 14..600, meeting at y = 307. + +#[test] +fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() { + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::new(layout); + assert_eq!(d.tab(3).top(), 307.0); + let seam = pos2(120.0, 309.0); + d.hold(bar(&d, 5), seam); + assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam"); + let out = d.step(vec![button(seam, false)]); + assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true } }))); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]); + assert!(d.layout.floating.is_empty()); +} + +#[test] +fn a_tab_dropped_on_a_seam_or_column_edge_moves_only_that_panel() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]])); + let out = d.drag(d.tab(3).center(), pos2(120.0, 309.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true } }]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![4]]]); + d.settle(); + d.drag(d.tab(4).center(), pos2(246.0, 200.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![4]]]); +} + +#[test] +fn group_dropped_on_a_top_or_bottom_band_stacks_above_or_below() { + for (at, expected) in [ + // The top quarter of group 2's body, and the bottom quarter of group 3's. + (pos2(120.0, 14.0 + 26.0 + 20.0), vec![vec![5], vec![2], vec![3]]), + (pos2(120.0, 590.0), vec![vec![2], vec![3], vec![5]]), + // The middle of a body joins as tabs. + (pos2(120.0, 450.0), vec![vec![2], vec![3, 5]]), + ] { + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::new(layout); + d.drag(bar(&d, 5), at); + assert_eq!(columns(&d.layout, Side::Start), vec![expected], "{at:?}"); + } +} + +#[test] +fn group_dropped_on_a_column_edge_makes_a_new_column() { + // The dock's inner edge: a column after the existing one, previewed full height. + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [400.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::new(layout.clone()); + let edge = pos2(246.0, 200.0); + d.hold(bar(&d, 5), edge); + assert!(d.painted(vline(240.0, 0.0..=600.0)), "a vertical line the full height of the dock"); + let out = d.step(vec![button(edge, false)]); + assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: true } }))); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]], vec![vec![5]]]); + assert_eq!(column(&d.layout, 1).width, 150.0, "the new column keeps the window's width"); + + // The window edge of the outermost column: a column before it. + let mut d = Driver::new(layout); + d.drag(bar(&d, 5), pos2(3.0, 200.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]); +} + +#[test] +fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() { + for (side, at, new) in [(Side::Start, pos2(595.0, 200.0), Side::End), (Side::End, pos2(4.0, 200.0), Side::Start)] { + let layout = with_floating(docked(side, &[&[&[2]]]), &[(5, [300.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::new(layout); + d.hold(bar(&d, 5), at); + let x = if new == Side::End { 598.0 } else { 2.0 }; + assert!(d.painted(vline(x, 0.0..=600.0)), "{new:?}: a vertical line at the window edge"); + let out = d.step(vec![button(at, false)]); + assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: new }))); + assert_eq!(columns(&d.layout, new), vec![vec![vec![5]]]); + assert_eq!(columns(&d.layout, side), vec![vec![vec![2]]]); + // The new dock is drawn on its edge, its »/« button at the window edge. + d.settle(); + let button = d.rect("column-collapse", 5); + assert!(if new == Side::End { button.right() == 600.0 } else { button.left() == 0.0 }, "{button:?}"); + } +} + +#[test] +fn a_column_takes_groups_anywhere_in_it_and_as_tabs() { + let windows = [(5, [400.0, 40.0, 150.0, 120.0]), (6, [400.0, 220.0, 150.0, 120.0]), (7, [400.0, 400.0, 150.0, 120.0])]; + let mut d = Driver::new(with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows)); + // Onto group 3's tab strip: joins as a tab. + d.drag(bar(&d, 5), pos2(d.tab(3).right() + 20.0, d.tab(3).center().y)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3, 5]]]); + // Onto the middle of group 2's body: joins as the last tab. + d.drag(bar(&d, 6), pos2(120.0, 160.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5]]]); + // Into the room under the groups, every group minimized: below the last group. + d.step(vec![Event::PointerMoved(pos2(700.0, 10.0))]); + let mut minimized = d.layout.clone(); + for panel in [2, 3] { + minimized.apply(Action::SetMinimized { panel, minimized: true }).unwrap(); + } + d.layout = minimized; + d.step(vec![]); + d.drag(bar(&d, 7), pos2(120.0, 500.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2, 6], vec![3, 5], vec![7]]]); +} + +#[test] +fn a_column_too_tall_for_the_window_scrolls() { + let groups: [&[u32]; 5] = [&[2], &[3], &[4], &[5], &[6]]; + let mut d = Driver::with(docked(Side::Start, &[&groups]), TestContent { min_height: 160.0 }, false); + let last = d.tab(6); + assert!(last.top() > 600.0, "the last group starts below the window: {last:?}"); + let over = d.tab(2).center(); + for _ in 0..6 { + d.step(vec![ + Event::PointerMoved(over), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }, + ]); + } + d.wait(); + let moved = d.tab(6); + assert!(moved.top() < last.top() - 100.0, "scrolled up: {moved:?} from {last:?}"); +} + +#[test] +fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]], &[&[5]]])); + // The second column to the window edge: it becomes the first. + let out = d.drag(d.rect("column-bar", 5).center(), pos2(3.0, 200.0)); + assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Column { after: false } }))); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]], vec![vec![2], vec![3]]]); + d.settle(); + // Away from the dock: a floating window holding the column's groups. + let source = Rect::from_min_max(pos2(240.0, 0.0), pos2(480.0, 600.0)); + assert_eq!(d.rect("column-bar", 2).left(), 240.0 + 18.0); + let grab = d.rect("column-bar", 2).center(); + d.hold(grab, pos2(560.0, 300.0)); + let ghost = source.translate(pos2(560.0, 300.0) - grab); + let ghost = egui::Rect::from_min_size(ghost.min.min(pos2(800.0, 600.0) - source.size()).max(Pos2::ZERO), source.size()); + assert!( + d.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == ghost && r.fill == egui::Color32::TRANSPARENT)), + "a ghost of the column follows the pointer" + ); + let out = d.step(vec![button(pos2(560.0, 300.0), false)]); + assert!(matches!(moved(&out.actions), Some((2, Target::Float { .. }))), "{:?}", out.actions); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]]]); + assert_eq!(d.layout.floating[0].groups, vec![group(&[2]), group(&[3])]); +} + +#[test] +fn column_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() { + let layout = docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]); + for case in ["escape", "focus", "exit", "cancel"] { + let mut d = Driver::new(layout.clone()); + let target = pos2(3.0, 200.0); + let held = d.hold(d.rect("column-bar", 5).center(), target); + assert_eq!(held.group_drag, Some(5), "{case}"); + let out = match case { + "escape" => d.step(vec![key(egui::Key::Escape)]), + "focus" => { + d.focused = false; + let out = d.step(vec![]); + d.focused = true; + out + } + "exit" => d.step(vec![Event::PointerGone]), + _ => { + cancel_drag::(&d.ctx, Id::new(AREA)); + d.step(vec![Event::PointerMoved(target + vec2(1.0, 0.0))]) + } + }; + assert!(out.actions.is_empty(), "{case}: {out:?}"); + let out = d.step(vec![Event::PointerMoved(target), button(target, false)]); + assert!(out.actions.is_empty() && out.group_drag.is_none(), "{case}: {out:?}"); + assert_eq!(d.layout, layout, "{case}"); + assert!(!is_dragging::(&d.ctx, Id::new(AREA)), "{case}"); + } +} + +#[test] +fn column_and_group_dividers_resize_and_minimized_groups_keep_their_height() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[4]]])); + // The dock's inner edge widens its column; the canvas gives way. + d.drag(pos2(240.0, 300.0), pos2(280.0, 300.0)); + assert_eq!(column(&d.layout, 0).width, 280.0); + d.settle(); + assert_eq!(d.rect("column-bar", 2).right(), 280.0); + // The divider between the groups saves both heights. + d.wait(); + d.drag(pos2(120.0, 307.0), pos2(120.0, 357.0)); + let groups = column(&d.layout, 0).groups; + assert_eq!((groups[0].height, groups[1].height), (Some(343.0), Some(243.0))); + d.settle(); + assert_eq!(d.tab(4).top(), 357.0); + // Minimized, the lower group keeps its saved height and the upper one takes the room. + d.layout.apply(Action::SetMinimized { panel: 4, minimized: true }).unwrap(); + d.settle(); + assert_eq!(column(&d.layout, 0).groups[1].height, Some(243.0)); + assert_eq!(d.tab(4).top(), 600.0 - 26.0); +} + +#[test] +fn double_click_on_a_docked_tab_minimizes_and_clicking_another_tab_expands() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]])); + let minimized = |d: &Driver| column(&d.layout, 0).groups[0].minimized; + let tab = d.tab(2).center(); + d.click(tab); + d.click(tab); + assert!(minimized(&d)); + d.settle(); + assert_eq!(d.tab(4).top(), 14.0 + 26.0, "the minimized group takes its strip"); + d.wait(); + d.click(d.tab(3).center()); + assert!(!minimized(&d)); + assert_eq!(column(&d.layout, 0).groups[0].active_panel(), Some(&3)); +} + +#[test] +fn collapse_button_and_column_bar_double_click_emit_set_iconic() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3]]])); + let iconic = |d: &Driver| column(&d.layout, 0).iconic; + let button = d.rect("column-collapse", 2).center(); + let out = d.click(button); + assert_eq!(out.actions, vec![Action::SetIconic { panel: 2, iconic: true }]); + assert!(iconic(&d)); + d.wait(); + d.click(d.rect("column-collapse", 2).center()); + assert!(!iconic(&d)); + d.wait(); + let bar = d.rect("column-bar", 2).center(); + d.click(bar); + d.click(bar); + assert!(iconic(&d)); +} + +#[test] +fn dock_targets_respect_occlusion_by_floating_windows() { + // Window 5 covers the column's seam at x 150..240; window 6 is dragged. + let windows = [(5, [150.0, 250.0, 200.0, 150.0]), (6, [400.0, 450.0, 150.0, 120.0])]; + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &windows); + let mut d = Driver::new(layout.clone()); + let out = d.drag(bar(&d, 6), pos2(200.0, 309.0)); + assert!(matches!(moved(&out.actions), Some((6, Target::At { anchor: 5, .. }))), "joins the window in front: {:?}", out.actions); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3]]]); + // Left of the window, the seam takes it. + let mut d = Driver::new(layout); + d.drag(bar(&d, 6), pos2(100.0, 309.0)); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![6], vec![3]]]); +} + +#[test] +fn right_to_left_mirrors_docks_their_edges_and_new_docks() { + let layout = with_floating(docked(Side::Start, &[&[&[2], &[3]]]), &[(5, [100.0, 300.0, 150.0, 150.0])]); + let mut d = Driver::with(layout.clone(), TestContent::default(), true); + // The start dock is on the right, its »/« button at the window edge. + let column_bar = d.rect("column-bar", 2); + assert_eq!((column_bar.left(), column_bar.right()), (360.0, 600.0 - 18.0)); + // Its inner edge is on the left: dragging it left widens the column. + d.drag(pos2(360.0, 300.0), pos2(320.0, 300.0)); + assert_eq!(column(&d.layout, 0).width, 280.0); + // The left window edge has no dock: a new end dock. + d.wait(); + let out = d.drag(bar(&d, 5), pos2(4.0, 200.0)); + assert_eq!(moved(&out.actions), Some((5, &Target::NewDock { side: Side::End }))); + d.settle(); + assert_eq!(d.rect("column-collapse", 5).left(), 0.0); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 849a5e59..842dbb82 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -21,6 +21,8 @@ pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(test)] +mod dock_tests; #[cfg(test)] mod group_tests; #[cfg(test)] From 17cf8cd07bbf1726c1e4d92caeff26fdb9ccc02d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:19:33 +0000 Subject: [PATCH 016/160] docking: public floating window chrome docking::chrome holds the parts of a floating window that hosts other than DockArea need, such as office task panes: FloatingFrame (opaque frame on its own layer, anchored resize from both sides, the bottom and both bottom corners, arrow keys on a focused handle) and TitleBar (two grip lines or a title, an optional menu button, and a close button 10 pt in from the right). They return responses and requested rects; the caller turns them into its own commands. Behaviour follows vectorcraft floating.rs (show_set: TITLE 14, grip lines, close position). --- crates/ui/src/docking/chrome.rs | 334 ++++++++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 1 + 2 files changed, 335 insertions(+) create mode 100644 crates/ui/src/docking/chrome.rs diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs new file mode 100644 index 00000000..2e2b14be --- /dev/null +++ b/crates/ui/src/docking/chrome.rs @@ -0,0 +1,334 @@ +//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with a +//! grip, an optional ▾ menu button and ×. [`super::DockArea`] draws its floating windows with +//! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes +//! a layout: the caller turns the responses into its own commands. + +use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2}; + +use super::{DockStyle, PanelLimits, resize::AnchoredResize}; + +/// Height of a floating window's title bar. +pub const TITLE_HEIGHT: f32 = 14.0; + +/// Names of the resize handles, appended to the frame id: `id.with(name)`. +pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; + +/// Colours and font of the frame and title bar. +#[derive(Clone, Debug)] +pub struct ChromeStyle { + /// Painted under the contents, so the window stays opaque when the contents are not. + pub background: Color32, + pub stroke: Stroke, + pub title_background: Color32, + pub text: Color32, + pub weak_text: Color32, + pub accent: Color32, + pub font: FontId, +} + +impl ChromeStyle { + pub fn from_ui(ui: &Ui) -> Self { + Self::from_dock(&DockStyle::from_ui(ui)) + } + + pub fn from_dock(style: &DockStyle) -> Self { + Self { + background: style.floating_background, + stroke: style.floating_stroke, + title_background: style.tab_background, + text: style.text, + weak_text: style.inactive_text, + accent: style.accent, + font: style.font.clone(), + } + } +} + +/// Resize handle hit rects and the edges they move: both sides, the bottom and both bottom +/// corners. The top edge belongs to the title bar. +fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { + [ + ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), + ( + "resize-left", + Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)), + vec2(-1.0, 0.0), + ), + ( + "resize-right", + Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)), + vec2(1.0, 0.0), + ), + ( + "resize-bottom", + Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())), + vec2(0.0, 1.0), + ), + ( + "resize-bottom-left", + Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2((rect.left() + 12.0).min(rect.right()), rect.bottom())), + vec2(-1.0, 1.0), + ), + ] +} + +/// An opaque in-window frame on its own `Order::Middle` layer, `id`, at `rect`. +pub struct FloatingFrame<'a> { + id: Id, + rect: Rect, + bounds: Option, + limits: PanelLimits, + resizable: bool, + resize_label: &'a str, +} + +/// What a [`FloatingFrame`] did this frame. +pub struct FrameResponse { + pub inner: R, + /// Where the frame was drawn, inside its bounds. + pub rect: Rect, + pub layer: LayerId, + /// The rect a resize handle asks for: pointer drags anchored at the opposite edges, or + /// arrow keys on a focused handle (8 pt per press). Escape and focus loss end a resize. + pub resized: Option, + /// The handle whose pointer resize started this frame. + pub resize_started: Option, + /// Every handle's response, for focus and gesture bookkeeping. + pub handles: Vec, +} + +impl<'a> FloatingFrame<'a> { + pub fn new(id: Id, rect: Rect) -> Self { + Self { id, rect, bounds: None, limits: PanelLimits::default(), resizable: true, resize_label: "Resize window" } + } + + /// The frame stays inside `bounds`; the default is the viewport's content rect. + pub fn bounds(mut self, bounds: Rect) -> Self { + self.bounds = Some(bounds); + self + } + + /// Size limits of a resize. The frame itself is drawn at the given rect. + pub fn limits(mut self, limits: PanelLimits) -> Self { + self.limits = limits; + self + } + + pub fn resizable(mut self, resizable: bool) -> Self { + self.resizable = resizable; + self + } + + /// Accessibility label of the resize handles. + pub fn resize_label(mut self, label: &'a str) -> Self { + self.resize_label = label; + self + } + + pub fn layer(id: Id) -> LayerId { + LayerId::new(Order::Middle, id) + } + + /// Draw the frame, then `add_contents` with the frame's rect, then the outline and handles. + pub fn show(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { + let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect()); + let rect = clamp(self.rect, bounds); + let area = egui::Area::new(self.id).fade_in(false).order(Order::Middle).fixed_pos(rect.min) + .default_size(rect.size()) + .movable(false) + .constrain_to(bounds); + let shown = area.show(ctx, |ui| { + // Drawn at `rect` itself, also in egui's first (sizing) pass of a new area. + ui.set_min_size(rect.size()); + ui.allocate_rect(rect, Sense::hover()); + ui.painter().rect_filled(rect, 0.0, style.background); + let inner = add_contents(ui, rect); + ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); + let (resized, resize_started, handles) = + if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; + FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles } + }); + shown.inner + } + + fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option, Option, Vec) { + let mut resized = None; + let mut started = None; + let mut responses = Vec::new(); + let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; + let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + for (name, handle, direction) in handles(rect) { + if !super::targets::usable(handle) { + continue; + } + let cursor = if direction.x == 0.0 { + egui::CursorIcon::ResizeVertical + } else if direction.y == 0.0 { + egui::CursorIcon::ResizeHorizontal + } else if direction.x == direction.y { + egui::CursorIcon::ResizeNwSe + } else { + egui::CursorIcon::ResizeNeSw + }; + let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label)); + let anchor_id = response.id.with("pointer-anchor"); + if response.drag_started() { + response.request_focus(); + if let Some(origin) = ui.input(|i| i.pointer.press_origin()) + && let Some(anchor) = AnchoredResize::new(rect, origin, direction) + { + ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor)); + started = Some(response.id); + } + } + let mut delta = Vec2::ZERO; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, step) in [(Key::ArrowLeft, vec2(-8.0, 0.0)), (Key::ArrowRight, vec2(8.0, 0.0)), (Key::ArrowUp, vec2(0.0, -8.0)), (Key::ArrowDown, vec2(0.0, 8.0))] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += step; + } + } + }); + } + let next = if response.dragged() || response.drag_stopped() { + ui.ctx() + .data(|data| data.get_temp::(anchor_id)) + .and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, bounds, limits))) + } else if delta != Vec2::ZERO { + AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, bounds, limits)) + } else { + None + }; + if response.enabled() + && !cancelled + && let Some(next) = next.filter(|next| *next != rect) + { + resized = Some(next); + } + if response.drag_stopped() { + response.surrender_focus(); + } + if cancelled || !ui.input(|input| input.pointer.primary_down()) { + ui.ctx().data_mut(|data| data.remove::(anchor_id)); + } + if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { + ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent)); + } + responses.push(response); + } + (resized, started, responses) + } +} + +/// Keep `rect` inside `bounds`, shrinking it when it is larger. +pub fn clamp(rect: Rect, bounds: Rect) -> Rect { + if !super::targets::usable(bounds) { + return rect; + } + let size = rect.size().max(vec2(1.0, 1.0)).min(bounds.size()); + let min = rect.min.max(bounds.min).min(bounds.max - size); + Rect::from_min_size(min, size) +} + +/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt +/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's. +pub struct TitleBar<'a> { + id: Id, + move_label: &'a str, + title: Option, + menu: Option<&'a str>, + close: Option<&'a str>, + movable: bool, +} + +/// The parts of a [`TitleBar`] that were shown. +pub struct TitleResponse { + /// Drag to move; double-click is the caller's (docking minimizes every group). + pub bar: Response, + pub menu: Option, + pub close: Option, +} + +impl<'a> TitleBar<'a> { + /// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`. + /// `move_label` names the bar for accessibility. + pub fn new(id: Id, move_label: &'a str) -> Self { + Self { id, move_label, title: None, menu: None, close: None, movable: true } + } + + /// Text at the left instead of the grip lines. + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + /// Show a ▾ button with this accessibility label. + pub fn menu(mut self, label: &'a str) -> Self { + self.menu = Some(label); + self + } + + /// Show a × button with this accessibility label. + pub fn close(mut self, label: &'a str) -> Self { + self.close = Some(label); + self + } + + /// Whether the bar shows the grab cursor. It senses drags either way. + pub fn movable(mut self, movable: bool) -> Self { + self.movable = movable; + self + } + + pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse { + let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); + painter.rect_filled(rect, 0.0, style.title_background); + let side = rect.height(); + let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side))); + let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0); + let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom()))); + let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left()); + let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())); + let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag()); + let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar }; + bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label)); + if let Some(title) = self.title { + let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone()); + painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text); + } else { + let grip = Stroke::new(0.6, style.weak_text); + for k in 0..2 { + let y = rect.center().y - 1.5 + k as f32 * 3.0; + painter.line_segment([pos2(rect.center().x - 9.0, y), pos2(rect.center().x + 9.0, y)], grip); + } + } + if bar.has_focus() { + painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside); + } + let button = |ui: &mut Ui, rect: Rect, name: &str, label: &str| { + let response = ui.interact(rect, self.id.with(name), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + let color = if response.hovered() || response.has_focus() { style.text } else { style.weak_text }; + (response, Stroke::new(1.2, color)) + }; + let menu = menu_rect.zip(self.menu).map(|(menu_rect, label)| { + let (response, stroke) = button(ui, menu_rect, "menu", label); + let c = menu_rect.center(); + painter.add(egui::Shape::convex_polygon(vec![c + vec2(-3.5, -1.5), c + vec2(3.5, -1.5), c + vec2(0.0, 2.5)], stroke.color, Stroke::NONE)); + response + }); + let close = close_rect.zip(self.close).map(|(close_rect, label)| { + let (response, stroke) = button(ui, close_rect, "close", label); + let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))); + painter.line_segment([r.left_top(), r.right_bottom()], stroke); + painter.line_segment([r.right_top(), r.left_bottom()], stroke); + response + }); + TitleResponse { bar, menu, close } + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 849a5e59..2c58dbff 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -5,6 +5,7 @@ //! Validate deserialized layouts before rendering them. Every transaction checks its complete //! input and commits only a valid result; a rejected move cannot lose the source panel. +pub mod chrome; mod dock_model; mod model; mod render; From 40fefd9c7bd1fd1c06893210b07ca3a06923a3bd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:19:44 +0000 Subject: [PATCH 017/160] docking: floating title bars and stacks, frame profile, native windows Floating windows: - A title bar (TITLE 14) drags the whole window live; Escape and focus loss put it back. A release over a tab strip, a floating stack's band or seam, or the outer edge emits MoveWindow; elsewhere the window stays and hosts get group_drop. Double-click minimizes or expands every group; the close button closes every panel (CloseWindow). - Stacked groups with a 3 pt bar between them and dividers that resize both neighbours (ResizeGroup). The top and bottom quarter of a group and the seam between two groups insert a new group there, with a line preview (P3, P4). The title bar occludes what lies behind it. - The only group of a window carries the window rect when dragged by its bar, so floating it again keeps the window's size. Frame profile (DockStyle::profile): - Ctrl/Cmd held at the start of a tab drag undocks the panel (S9). - The outer edge band splits at the root: top and bottom in both profiles, left and right in the frame profile (P8). - Menu tail: Undock Panel, Close Panel Group, Undock Panel Group, Close Other Panels in Group and Maximize Frame (DockEvent::ToggleMaximize). DockArea::maximized draws only the docked frame holding the panel; other frames and floating windows do not render or take input. DockArea::hide_panels leaves out every panel, or every panel except a host-chosen set; protected panels stay and the layout is unchanged. Feature native-windows: a floating window with a native rect gets its own OS window through show_viewport_immediate when the backend does not embed viewports, and stays in-window otherwise. Closing it emits SetNative None, so the group returns to its in-window rect; moves and resizes are read back as SetNative with bounded sizes and positions. Tabs and groups do not drag between OS windows. Move Panel to New Window is in the menu tail where native windows are available. A floating panel whose window closed as a whole restores at its last rect. Behaviour follows vectorcraft floating.rs (show_set, draw_groups, track), effectcraft dock_ui.rs (panel_window, set_panel_window, toggle_maximize) and dock.rs (PanelWindow::sanitized), filmcraft dock_ui.rs (workspace_edge, EDGE_BAND, maximized) and storytold/filmcraft#2118 (saved_native_rect, bounded_inner_size). --- crates/ui/Cargo.toml | 2 + crates/ui/src/docking/floating_tests.rs | 534 +++++++++++++++++++++++ crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/mod.rs | 7 +- crates/ui/src/docking/model.rs | 9 +- crates/ui/src/docking/render/floating.rs | 426 +++++++++++------- crates/ui/src/docking/render/frame.rs | 231 ++++++++++ crates/ui/src/docking/render/group.rs | 59 +-- crates/ui/src/docking/render/mod.rs | 16 +- crates/ui/src/docking/render/native.rs | 154 +++++++ crates/ui/src/docking/render/preview.rs | 60 ++- crates/ui/src/docking/surface.rs | 110 ++++- crates/ui/src/docking/tests.rs | 8 +- 13 files changed, 1374 insertions(+), 244 deletions(-) create mode 100644 crates/ui/src/docking/floating_tests.rs create mode 100644 crates/ui/src/docking/render/frame.rs create mode 100644 crates/ui/src/docking/render/native.rs diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 0b8edc2f..dd85edd4 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"], optional = true } [features] serde = ["dep:serde"] +# Floating windows may move to their own OS windows (desktop egui backends with viewports). +native-windows = [] [dev-dependencies] egui = { workspace = true, features = ["default_fonts"] } diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs new file mode 100644 index 00000000..8d8dc758 --- /dev/null +++ b/crates/ui/src/docking/floating_tests.rs @@ -0,0 +1,534 @@ +//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize, +//! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome. + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Context, Event, Id, Modifiers, PointerButton, RawInput, Rect, Vec2, pos2, vec2}; + +const AREA: &str = "floating-area"; + +fn tabs(panels: &[u32], active: usize) -> Node { + Node::Tabs { panels: panels.to_vec(), active } +} + +/// `[1] | [2, 3]` with panel 3 selected. +fn frames() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(300.0), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2, 3], 1)), + }), + floating: Vec::new(), + } +} + +/// A start dock (`[2, 3]` above `[4]`) beside protected canvas 0; window W1 stacks `[5]` above +/// `[6, 7]`; window W2 holds `[8]`. +fn workspace() -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(Node::Dock { + side: Side::Start, + columns: vec![Column::new(vec![Group::new(vec![2, 3], 1), Group::new(vec![4], 0)], 240.0)], + }), + second: Box::new(tabs(&[0], 0)), + }), + floating: vec![ + Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [300.0, 80.0, 240.0, 300.0], native: None }, + Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]), + ], + } +} + +#[derive(Default)] +struct Content { + native: bool, + keep: Vec, +} + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + } + fn allow_native(&self, _: &u32) -> bool { + self.native + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Hide { + None, + All, + Keep, +} + +struct Driver { + ctx: Context, + layout: Layout, + content: Content, + profile: Profile, + maximized: Option, + hide: Hide, + modifiers: Modifiers, + time: f64, +} + +impl Driver { + fn new(layout: Layout, profile: Profile) -> Self { + let mut driver = Self { + ctx: Context::default(), + layout, + content: Content::default(), + profile, + maximized: None, + hide: Hide::None, + modifiers: Modifiers::NONE, + time: 0.0, + }; + driver.step(vec![]); + driver.step(vec![]); + driver + } + + fn frame(&mut self, mut events: Vec) -> DockOutput { + let mut output = DockOutput::default(); + events.insert(0, Event::ModifiersChanged(self.modifiers)); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), + events, + focused: true, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide); + let keep: Vec = content.keep.clone(); + let keep = move |p: &u32| keep.contains(p); + let full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + style.profile = profile; + let hide = match hide { + Hide::None => HidePanels::None, + Hide::All => HidePanels::All, + Hide::Keep => HidePanels::Except(&keep), + }; + output = DockArea::new(Id::new(AREA)).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content); + }); + let mut full = full; + full.textures_delta.clear(); + output + } + + fn step(&mut self, events: Vec) -> DockOutput { + let output = self.frame(events); + assert!(output.error.is_none(), "{output:?}"); + for action in &output.actions { + self.layout.apply_with_permissions(action.clone(), |p| self.content.permissions(p)).unwrap(); + } + output + } + + fn response(&self, id: Id) -> Option { + self.ctx.read_response(id).map(|r| r.rect) + } + + fn title(&self, first: u32) -> Rect { + self.response(Id::new(AREA).with(("move-group", Some(&first)))).unwrap() + } + + fn tab(&self, panel: u32) -> Option { + self.response(Id::new(AREA).with(("tab", &panel))) + } + + fn hold(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(from), button(from, true)]); + self.step(vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + self.step(vec![Event::PointerMoved(to)]) + } + + fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) -> DockOutput { + self.hold(from, to); + self.step(vec![button(to, false)]) + } + + fn click(&mut self, at: egui::Pos2) -> DockOutput { + self.step(vec![Event::PointerMoved(at), button(at, true)]); + self.step(vec![button(at, false)]) + } + + fn wait(&mut self) { + for _ in 0..45 { + self.step(vec![]); + } + } +} + +fn done(mut full: egui::FullOutput) { + full.textures_delta.clear(); +} + +fn button(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn window_of(layout: &Layout, panel: u32) -> Option<&Floating> { + layout.floating.iter().find(|w| w.contains(&panel)) +} + +fn groups_of(window: &Floating) -> Vec> { + window.groups.iter().map(|g| g.panels.clone()).collect() +} + +#[test] +fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() { + // The window follows the pointer with every group; Escape puts it back. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + d.hold(title, title + vec2(40.0, 30.0)); + assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]); + assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![5], vec![6, 7]]); + d.step(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + d.step(vec![button(title + vec2(40.0, 30.0), false)]); + assert_eq!(d.layout, workspace()); + + // Released over another window's lower band: its groups stack below that window's. + let out = d.drag(title, pos2(660.0, 480.0)); + assert!( + out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }), + "{:?}", + out.actions + ); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]); + + // Released over a docked group's strip: every panel joins it as tabs. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + let strip = d.tab(4).unwrap().center(); + let out = d.drag(title, strip); + assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 4, .. } })), "{:?}", out.actions); + assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 7).is_none()); + assert_eq!(d.layout.place(&4), d.layout.place(&6)); + + // Released over the canvas: the window stays where it was moved and hosts get the release. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + let out = d.drag(title, pos2(500.0, 300.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions); + assert_eq!(out.group_drop, Some((5, pos2(500.0, 300.0)))); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]); +} + +#[test] +fn floating_stack_seams_and_bands_insert_groups() { + // A group dragged by its bar to another window's top band stacks above that group. + let mut d = Driver::new(workspace(), Profile::Dock); + let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center(); + let out = d.drag(bar, pos2(660.0, 360.0)); + assert!( + out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }), + "{:?}", + out.actions + ); + assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]); + + // A tab dropped on the seam between two stacked groups becomes a group between them. + let mut d = Driver::new(workspace(), Profile::Dock); + let tab = d.tab(8).unwrap().center(); + let seam = pos2(420.0, 237.0); + let out = d.drag(tab, seam); + assert!( + out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), + "{:?}", + out.actions + ); + assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); + + // The divider between two open groups resizes both. + let mut d = Driver::new(workspace(), Profile::Dock); + let divider = d.response(Id::new(AREA).with(("floating-divider", Some(&5u32)))).unwrap().center(); + d.drag(divider, divider + vec2(0.0, 40.0)); + let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); + assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}"); +} + +#[test] +fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() { + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + d.click(title); + d.click(title); + assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| g.minimized)); + d.wait(); + d.click(title); + d.click(title); + assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| !g.minimized)); + + let close = d.response(Id::new(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap(); + assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge"); + let before = d.layout.clone(); + let out = d.click(close.center()); + assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]); + assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7])); + assert!(window_of(&d.layout, 6).is_none() && d.layout.contains(&8)); +} + +#[test] +fn closed_floating_window_reopens_each_panel_at_its_last_rect() { + let mut layout = workspace(); + let locations: Vec<_> = [5, 6, 7].map(|p| (p, layout.location(&p).unwrap())).into(); + layout.apply(Action::CloseWindow { panel: 6 }).unwrap(); + for (panel, location) in locations { + layout.restore(panel, &location).unwrap(); + assert_eq!(window_of(&layout, panel).unwrap().rect, [300.0, 80.0, 240.0, 300.0], "{panel}"); + } +} + +#[test] +fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() { + let mut d = Driver::new(frames(), Profile::Frame); + let tab = d.tab(3).unwrap().center(); + let other = d.tab(1).unwrap().center(); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, other); + d.modifiers = Modifiers::NONE; + let out = d.step(vec![button(other, false)]); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); + assert!(window_of(&d.layout, 3).is_some()); + + // Without the modifier the same drag joins the other frame. + let mut d = Driver::new(frames(), Profile::Frame); + let out = d.drag(tab, other); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions); +} + +#[test] +fn outer_edge_band_splits_at_the_root() { + // Frame profile: all four edges. + let mut d = Driver::new(frames(), Profile::Frame); + let tab = d.tab(3).unwrap().center(); + let out = d.drag(tab, pos2(3.0, 300.0)); + assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions); + let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + assert_eq!(**first, tabs(&[3], 0)); + + // Dock profile: the bottom edge of the part between the docks, for a Timeline. + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + let tab = d.tab(2).unwrap().center(); + let out = d.drag(tab, pos2(500.0, 597.0)); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions); + let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; + assert!(matches!(**first, Node::Dock { .. })); + assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0))); + + // Its left and right edges are the docks' (new columns and docks), not root splits. + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + let tab = d.tab(4).unwrap().center(); + let out = d.drag(tab, pos2(246.0, 300.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions); +} + +#[test] +fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() { + let mut layout = frames(); + layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0])); + let mut d = Driver::new(layout.clone(), Profile::Frame); + let window_title = d.title(5).center(); + d.maximized = Some(2); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(2).unwrap().left(), 0.0, "the maximized frame starts at the area's edge"); + assert!(d.tab(1).is_none()); + assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none()); + let out = d.drag(window_title, window_title + vec2(30.0, 30.0)); + assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions); + + // A floating panel does not maximize. + let mut d = Driver::new(layout, Profile::Frame); + d.maximized = Some(5); + d.step(vec![]); + d.step(vec![]); + assert!(d.tab(1).is_some()); +} + +#[test] +fn close_other_panels_keeps_only_the_chosen_tab() { + use egui_kittest::{Harness, kittest::Queryable}; + type State = (Content, Vec>, Vec>); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, (content, actions, events): &mut State| { + let mut style = DockStyle::from_ui(ui); + style.profile = Profile::Frame; + let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content); + actions.extend(out.actions); + events.extend(out.events); + }, + (Content::default(), Vec::new(), Vec::new()), + ); + h.run_steps(2); + let at = h.ctx.read_response(Id::new(AREA).with(("tab", &3u32))).unwrap().rect.center(); + let open_menu = |h: &mut Harness<'_, State>| { + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + open_menu(&mut h); + for label in ["Undock Panel", "Close Panel Group", "Undock Panel Group", "Close Other Panels in Group", "Maximize Frame"] { + assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); + } + assert_eq!(h.query_all_by_label("Minimize").count(), 0); + h.get_by_label("Close Other Panels in Group").click(); + h.run_steps(2); + assert_eq!(h.state().1, vec![Action::CloseOthers { panel: 3 }]); + let mut layout = frames(); + layout.apply(Action::CloseOthers { panel: 3 }).unwrap(); + assert!(layout.contains(&3) && !layout.contains(&2)); + + open_menu(&mut h); + h.get_by_label("Maximize Frame").click(); + h.run_steps(2); + assert_eq!(h.state().2, vec![DockEvent::ToggleMaximize { panel: 3 }]); +} + +#[test] +fn hide_panels_keeps_protected_panels_and_the_except_set() { + let mut d = Driver::new(workspace(), Profile::Dock); + d.hide = Hide::All; + d.step(vec![]); + d.step(vec![]); + for panel in [2, 3, 4, 5, 6, 8] { + assert!(d.tab(panel).is_none(), "{panel}"); + } + assert!(d.layout == workspace(), "hiding leaves the layout unchanged"); + + d.hide = Hide::Keep; + d.content.keep = vec![4, 6]; + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(4).map(|r| r.left()), Some(0.0)); + assert!(d.tab(6).is_some()); + for panel in [2, 3, 5, 7, 8] { + assert!(d.tab(panel).is_none(), "{panel}"); + } + // A window keeps its rect with its hidden groups left out. + d.step(vec![]); + assert_eq!(d.title(6).min, pos2(300.0, 80.0)); +} + +#[test] +fn native_window_falls_back_in_window_when_viewports_are_embedded() { + let mut layout = workspace(); + layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); + let mut d = Driver::new(layout.clone(), Profile::Dock); + d.content.native = true; + assert!(d.ctx.embed_viewports()); + let out = d.step(vec![]); + assert!(out.actions.is_empty(), "{out:?}"); + assert_eq!(d.title(5).min, pos2(300.0, 80.0)); + assert_eq!(d.layout, layout); +} + +#[cfg(feature = "native-windows")] +#[test] +fn native_window_close_returns_the_group_to_its_in_window_rect() { + use std::{cell::Cell, rc::Rc}; + let close = Rc::new(Cell::new(false)); + let requested = close.clone(); + // A backend with real viewports: the child reports its place and, when asked, a close. + Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { + let mut info = egui::ViewportInfo { + outer_rect: Some(Rect::from_min_size(pos2(1210.0, 905.0), vec2(250.0, 330.0))), + inner_rect: Some(Rect::from_min_size(pos2(1210.0, 935.0), vec2(250.0, 300.0))), + ..Default::default() + }; + if requested.get() { + info.events.push(egui::ViewportEvent::Close); + } + let input = RawInput { + viewport_id: viewport.ids.this, + viewports: std::iter::once((viewport.ids.this, info)).collect(), + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(250.0, 300.0))), + ..Default::default() + }; + done(ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui))); + }); + let mut layout = workspace(); + layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); + let mut d = Driver::new(Layout::default(), Profile::Dock); + d.ctx.set_embed_viewports(false); + d.content.native = true; + d.layout = layout; + let out = d.step(vec![]); + assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window"); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 905.0, 250.0, 300.0]) }), "{:?}", out.actions); + + close.set(true); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: None }), "{:?}", out.actions); + assert_eq!(d.layout.floating[0].native, None); + assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.title(5).min, pos2(300.0, 80.0)); +} + +#[test] +fn chrome_frame_and_title_bar_work_without_a_dock_area() { + use chrome::{ChromeStyle, FloatingFrame, TitleBar}; + let ctx = Context::default(); + let id = Id::new("task-pane"); + let rect = Rect::from_min_size(pos2(100.0, 100.0), vec2(300.0, 200.0)); + let mut time = 0.0; + let mut frame = |events: Vec| { + time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; + let mut result = None; + done(ctx.run_ui(input, |ui| { + let style = ChromeStyle::from_ui(ui); + let shown = FloatingFrame::new(id, rect).show(ui.ctx(), &style, |ui, inner| { + let title = Rect::from_min_size(inner.min, vec2(inner.width(), 22.0)); + let bar = TitleBar::new(id.with("title"), "Move pane").title("Styles").menu("Pane menu").close("Close pane").show(ui, title, &style); + (bar.bar.dragged(), bar.bar.drag_delta(), bar.menu.is_some_and(|r| r.clicked()), bar.close.is_some_and(|r| r.clicked())) + }); + result = Some((shown.inner, shown.resized)); + })); + result.unwrap() + }; + frame(vec![]); + frame(vec![]); + let click = |frame: &mut dyn FnMut(Vec) -> ((bool, Vec2, bool, bool), Option), at| { + frame(vec![Event::PointerMoved(at), button(at, true)]); + frame(vec![button(at, false)]) + }; + let ((_, _, _, closed), _) = click(&mut frame, pos2(390.0, 111.0)); + assert!(closed, "× 10 pt in from the right"); + let ((_, _, menu, _), _) = click(&mut frame, pos2(374.0, 111.0)); + assert!(menu, "▾ left of ×"); + + frame(vec![Event::PointerMoved(pos2(200.0, 110.0)), button(pos2(200.0, 110.0), true)]); + frame(vec![Event::PointerMoved(pos2(210.0, 115.0))]); + let ((dragged, delta, ..), _) = frame(vec![Event::PointerMoved(pos2(230.0, 125.0))]); + assert!(dragged && delta == vec2(20.0, 10.0), "{delta:?}"); + frame(vec![button(pos2(230.0, 125.0), false)]); + + let edge = pos2(398.0, 200.0); + frame(vec![Event::PointerMoved(edge), button(edge, true)]); + frame(vec![Event::PointerMoved(edge + vec2(10.0, 0.0))]); + let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]); + assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0)))); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 7edcf28f..673b2133 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -527,7 +527,7 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { let mut d = Driver::new(layout); let from = d.rect("drag-bar", 6).center(); let out = d.drag(from, pos2(700.0, 300.0)); - assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 200.0]) })), "{:?}", out.actions); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 191.5]) })), "{:?}", out.actions); assert_eq!(d.layout.floating.len(), 2); assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 2c58dbff..eae01b86 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -16,12 +16,17 @@ mod workspace; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; -pub use surface::{DockArea, DockContent, DockOutput, DockStyle, DropFeedback, DropRequest, PanelLimits, begin_drag, cancel_drag, is_dragging}; +pub use surface::{ + DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag, + is_dragging, +}; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(test)] +mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 952092ea..6800e565 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -576,7 +576,8 @@ impl Layout

{ } /// Restore an absent panel at a captured location. Missing anchors are reported without - /// changes, allowing the app to choose an explicit default. Unrelated tabs retain selection. + /// changes, allowing the app to choose an explicit default; a floating panel whose + /// window is gone floats at its last rect instead. Unrelated tabs retain selection. pub fn restore(&mut self, panel: P, location: &Location

) -> Result<(), DockError> { self.validate()?; if self.contains(&panel) { @@ -608,10 +609,8 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - if let Some(anchor) = &location.anchor { - if !proposed.floating.iter().any(|g| g.contains(anchor)) { - return Err(DockError::MissingTarget); - } + // A window that closed as a whole reopens at its last rect. + if let Some(anchor) = location.anchor.as_ref().filter(|anchor| proposed.floating.iter().any(|g| g.contains(anchor))) { if let Placement::Group { .. } = location.placement { proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; } else { diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 43d18e25..383a68b4 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -1,176 +1,308 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Id, Key, Rect, Sense, Stroke, Ui, Vec2, pos2, vec2}; +use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2}; -use super::{GroupKind, Renderer, clamp_float}; -use crate::docking::{Action, Floating, Node, PanelLimits, resize::AnchoredResize}; +use super::{GroupKind, Renderer, clamp_float, node_limits}; +use crate::docking::chrome::{ChromeStyle, FloatingFrame, TitleBar}; +use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; -/// Floating window resize handles: id suffix, hit rect and the edges they move. -fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { - [ - ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), - ( - "resize-left", - Rect::from_min_max(pos2(rect.left(), rect.top() + 12.0), pos2((rect.left() + 4.0).min(rect.right()), rect.bottom() - 12.0)), - vec2(-1.0, 0.0), - ), - ( - "resize-right", - Rect::from_min_max(pos2((rect.right() - 4.0).max(rect.left()), rect.top() + 12.0), pos2(rect.right(), rect.bottom() - 12.0)), - vec2(1.0, 0.0), - ), - ( - "resize-bottom", - Rect::from_min_max(pos2(rect.left() + 12.0, (rect.bottom() - 4.0).max(rect.top())), pos2(rect.right() - 12.0, rect.bottom())), - vec2(0.0, 1.0), - ), - ( - "resize-bottom-left", - Rect::from_min_max( - pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), - pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), - ), - vec2(-1.0, 1.0), - ), - ] +pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES; + +/// Height of the bar between two stacked groups of a floating window. +const GAP: f32 = 3.0; +/// Height of the band around a seam between stacked groups that inserts a new group there. +const SEAM_BAND: f32 = 8.0; + +/// A floating stack as drawn, for its band and seam targets. +pub(in crate::docking) struct StackHit

{ + pub(super) window: Rect, + /// Each group's members, rect and tab strip, top to bottom. + pub(super) groups: Vec<(Vec

, Rect, Rect)>, + /// The upper group's index and the band of each seam. + pub(super) seams: Vec<(usize, Rect)>, } -/// Names of every floating window resize handle, for focus classification. -pub(in crate::docking) const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; - impl Renderer<'_, P> { - pub(in crate::docking) fn floating(&mut self, ctx: &Context) { + pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating

]) { let screen = ctx.content_rect(); if !crate::docking::targets::usable(screen) { return; } let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); - let mut windows: Vec<_> = self.layout.floating.iter().collect(); + let mut windows: Vec<_> = windows.iter().collect(); windows.sort_by_key(|window| { let id = window.panels().next().map(|panel| self.id.with(("floating", panel))); layers.iter().position(|layer| Some(layer.id) == id).unwrap_or(usize::MAX) }); + #[cfg(feature = "native-windows")] + let windows = self.native_windows(ctx, windows); for window in windows { - let Some(first) = window.panels().next() else { continue }; - let [x, y, width, height] = window.rect; - let limits = self.node_limits(&Node::Tabs { panels: window.panels().cloned().collect(), active: 0 }); - let mut size = vec2(width, height).max(limits.min).min(limits.max); - if window.groups.iter().all(|group| group.minimized) { - size.y = size.y.min(window.groups.len() as f32 * self.style.tab_height); - } - let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen); - let id = self.id.with(("floating", first)); - if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { - ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); - ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); - self.output.focused = self.activation.clone(); - } - egui::Area::new(id).fade_in(false).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let rect = Rect::from_min_size(ui.min_rect().min, rect.size()); - ui.allocate_rect(rect, Sense::hover()); - // Opaque even when the host's panel colours are transparent. - ui.painter().rect_filled(rect, 0.0, self.style.floating_background); - self.stacked_groups(ui, &window.groups, rect, GroupKind::Floating { window: rect }); - ui.painter().rect_stroke(rect, 0.0, self.style.floating_stroke, egui::StrokeKind::Outside); - self.resize_handles(ui, window, id, rect, limits, screen); - }); + self.floating_window(ctx, window, screen); } } - /// Anchored resize from the side edges, the bottom edge and both bottom corners: the - /// opposite edges stay where they are. - fn resize_handles(&mut self, ui: &mut Ui, window: &Floating

, id: Id, rect: Rect, limits: PanelLimits, screen: Rect) { + /// Size limits of a floating window: its title bar, each group's strip and the bodies of + /// the groups that are not minimized. + pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { + let mut limits = PanelLimits { min: vec2(0.0, self.style.title_height), max: vec2(1_000_000.0, self.style.title_height) }; + for (index, group) in window.groups.iter().enumerate() { + let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content); + let gap = if index > 0 { GAP } else { 0.0 }; + limits.min.x = limits.min.x.max(policy.min.x); + limits.max.x = limits.max.x.min(policy.max.x); + let (min, max) = if group.minimized { (self.style.tab_height, self.style.tab_height) } else { (policy.min.y, policy.max.y) }; + limits.min.y += min + gap; + limits.max.y += max + gap; + } + let min = limits.min.min(vec2(1_000_000.0, 1_000_000.0)); + PanelLimits { min, max: limits.max.max(min).min(vec2(1_000_000.0, 1_000_000.0)) } + } + + fn floating_window(&mut self, ctx: &Context, window: &Floating

, screen: Rect) { let Some(first) = window.panels().next() else { return }; - for (name, handle, direction) in handles(rect) { - if !crate::docking::targets::usable(handle) { - continue; + let [x, y, width, height] = window.rect; + let limits = self.window_limits(window); + let size = vec2(width, height).max(limits.min).min(limits.max); + let rect = Rect::from_min_size(pos2(x, y), size); + let id = self.id.with(("floating", first)); + if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + ctx.move_to_top(FloatingFrame::layer(id)); + ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); + self.output.focused = self.activation.clone(); + } + let movable = window.panels().all(|panel| self.permissions(panel).movable); + let style = ChromeStyle::from_dock(self.style); + let resize_label = self.style.resize_window_label.clone(); + let response = FloatingFrame::new(id, rect) + .bounds(screen) + .limits(limits) + .resizable(movable) + .resize_label(&resize_label) + .show(ctx, &style, |ui, rect| self.window_contents(ui, window, rect, &style)); + for handle in &response.handles { + self.register_interaction(ctx, handle.id); + } + let down = ctx.input(|i| i.pointer.primary_down()); + ctx.data_mut(|data| { + let key = self.id.with("pointer-resize"); + if let Some(started) = response.resize_started { + data.insert_temp(key, started); + } else if !down && data.get_temp::(key).is_some_and(|id| response.handles.iter().any(|h| h.id == id)) { + data.remove::(key); } - let cursor = if direction.x == 0.0 { - egui::CursorIcon::ResizeVertical - } else if direction.y == 0.0 { - egui::CursorIcon::ResizeHorizontal - } else if direction.x == direction.y { - egui::CursorIcon::ResizeNwSe - } else { - egui::CursorIcon::ResizeNeSw + }); + if let Some(resized) = response.resized { + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); + } + } + + /// The title bar, then the stacked groups below it. + pub(super) fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, rect: Rect, style: &ChromeStyle) { + let title = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.title_height).min(rect.bottom()))); + self.title_bar(ui, window, title, rect, style); + let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max); + self.floating_stack(ui, &window.groups, body, rect); + } + + /// Drag moves the whole window live, double-click minimizes or expands every group, × + /// closes every panel in it. + fn title_bar(&mut self, ui: &mut Ui, window: &Floating

, title: Rect, rect: Rect, style: &ChromeStyle) { + let Some(first) = window.panels().next() else { return }; + let movable = window.panels().all(|p| self.permissions(p).movable); + let closable = window.panels().all(|p| self.permissions(p).closable); + let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); + let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable); + if closable { + bar = bar.close(&close_label); + } + let response = bar.show(ui, title, style); + self.register_interaction(ui.ctx(), response.bar.id); + if movable && response.bar.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(title.center()); + let drag = GroupDrag { + panel: first.clone(), + original: [rect.left(), rect.top(), rect.width(), rect.height()], + gesture: GroupGesture::Window, + members: window.panels().cloned().collect(), + origin, + source: rect, }; - let response = ui.interact(handle, id.with(name), Sense::drag()).on_hover_cursor(cursor); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.resize_window_label)); - let anchor_id = response.id.with("pointer-anchor"); - if response.drag_started() { - response.request_focus(); - if let Some(origin) = ui.input(|i| i.pointer.press_origin()) - && let Some(anchor) = AnchoredResize::new(rect, origin, direction) - { - ui.ctx().data_mut(|data| { - if let Some(previous) = data.get_temp::(self.id.with("pointer-resize")) { - data.remove::(previous.with("pointer-anchor")); - } - data.insert_temp(self.id.with("pointer-resize"), response.id); - data.insert_temp(anchor_id, anchor); - }); + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + } + if movable + && response.bar.dragged() + && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) + && drag.gesture == GroupGesture::Window + && let Some(pointer) = ui.ctx().pointer_interact_pos() + && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + { + // From the press origin, so fractional pointer steps do not accumulate rounding. + let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); + } + if response.bar.double_clicked() { + let minimized = !window.groups.iter().all(|g| g.minimized); + for group in window.groups.iter().filter(|g| g.minimized != minimized) { + if let Some(panel) = group.active_panel() { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized }); } } - let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); - let mut delta = Vec2::ZERO; - if response.has_focus() { - ui.memory_mut(|memory| { - memory.set_focus_lock_filter( - response.id, - egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, - ) - }); - ui.input_mut(|input| { - if input.consume_key(egui::Modifiers::NONE, Key::ArrowLeft) { - delta.x -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowRight) { - delta.x += 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowUp) { - delta.y -= 8.0; - } - if input.consume_key(egui::Modifiers::NONE, Key::ArrowDown) { - delta.y += 8.0; - } - }); - } - let pointer_resize = response.dragged() || response.drag_stopped(); - let limits = PanelLimits { min: limits.min.max(vec2(40.0, self.style.tab_height + 20.0)).min(limits.max), ..limits }; - let next = if pointer_resize { - ui.ctx() - .data(|data| data.get_temp::(anchor_id)) - .and_then(|anchor| ui.ctx().pointer_interact_pos().and_then(|pointer| anchor.rect(pointer, screen, limits))) - } else if delta != Vec2::ZERO { - AnchoredResize::new(rect, rect.max, direction).and_then(|anchor| anchor.rect(rect.max + delta, screen, limits)) - } else { - None - }; - let permitted = response.enabled() && window.panels().all(|panel| self.permissions(panel).movable); - if permitted - && !cancelled - && let Some(resized) = next.filter(|next| *next != rect) - { - self.output - .actions - .push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); - } - if response.drag_stopped() { - response.surrender_focus(); - } - if cancelled || !permitted || !ui.input(|input| input.pointer.primary_down()) { - ui.ctx().data_mut(|data| { - data.remove::(anchor_id); - if data.get_temp::(self.id.with("pointer-resize")) == Some(response.id) { - data.remove::(self.id.with("pointer-resize")); - } - }); - } - if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { - ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, self.style.accent)); + } + if let Some(close) = response.close { + self.register_interaction(ui.ctx(), close.id); + if close.clicked() { + self.output.actions.push(Action::CloseWindow { panel: first.clone() }); } } } + + /// Groups top to bottom with a bar between them. Minimized groups take their strip; the + /// others share the rest by their preferred heights, equally when none is set. + pub(super) fn floating_stack(&mut self, ui: &mut Ui, groups: &[Group

], body: Rect, window: Rect) { + let strip = self.style.tab_height; + let open = groups.iter().filter(|g| !g.minimized).count(); + let fixed = groups.len().saturating_sub(open) as f32 * strip + groups.len().saturating_sub(1) as f32 * GAP; + let available = (body.height() - fixed).max(0.0); + let equal = if open > 0 { available / open as f32 } else { 0.0 }; + let wanted = |g: &Group

| g.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(equal).max(1.0); + let total: f32 = groups.iter().filter(|g| !g.minimized).map(wanted).sum(); + let scale = if total > 0.0 { available / total } else { 0.0 }; + let mut hit = StackHit { window, groups: Vec::new(), seams: Vec::new() }; + let mut top = body.top(); + let mut rects = Vec::new(); + for (index, group) in groups.iter().enumerate() { + if index > 0 { + let gap = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + GAP).min(body.bottom()))); + ui.painter().rect_filled(gap, 0.0, self.style.tab_background); + hit.seams.push((index - 1, Rect::from_center_size(gap.center(), vec2(gap.width(), SEAM_BAND)))); + top = gap.bottom(); + } + let height = if group.minimized { strip } else { wanted(group) * scale }; + let rect = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + height).min(body.bottom()))); + self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Floating { window }); + let strip_rect = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + strip).min(rect.bottom()))); + hit.groups.push((group.panels.clone(), rect, strip_rect)); + rects.push(rect); + top = rect.bottom(); + } + // Dividers between two open groups resize both; registered after the strips, so their + // hit region wins at the shared edge. + for (index, pair) in groups.windows(2).enumerate() { + let ([upper, lower], Some(a), Some(b)) = (pair, rects.get(index), rects.get(index + 1)) else { continue }; + if upper.minimized || lower.minimized || scale <= 0.0 { + continue; + } + let (Some(upper_panel), Some(lower_panel)) = (upper.active_panel(), lower.active_panel()) else { continue }; + let divider = Rect::from_min_max(pos2(body.left(), a.bottom()), pos2(body.right(), b.top())); + let least = strip + self.style.min_pane; + let pair_size = a.height() + b.height(); + let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) }; + let response = ResizeHandle::new( + self.id.with(("floating-divider", upper.panels.first())), + SplitAxis::Vertical, + divider, + &self.style.resize_label, + ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, + ) + .value(a.height(), lo..=hi) + .show(ui); + self.register_interaction(ui.ctx(), response.response.id); + if response.delta != 0.0 { + let next = (a.height() + response.delta).clamp(lo, hi); + self.output.actions.push(Action::ResizeGroup { panel: upper_panel.clone(), height: Some(next / scale) }); + self.output.actions.push(Action::ResizeGroup { panel: lower_panel.clone(), height: Some((pair_size - next) / scale) }); + } + } + self.frame.stacks.push(hit); + } + + /// Targets the group targets do not cover: the bands and seams of a floating stack (P3, + /// P4), then the layout's outer edge (P8). None leaves the pointer to the group targets. + /// `skip_own` looks through the window holding `members` (a window moving live). + pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option> { + let own = |panels: &[P]| panels.iter().any(|p| members.contains(p)); + let window = self.frame.stacks.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && w.groups.iter().any(|(g, ..)| own(g)))); + if let Some(window) = window { + let in_group = window.groups.iter().any(|(_, rect, _)| rect.contains(pointer)) || window.seams.iter().any(|(_, b)| b.contains(pointer)); + if !in_group { + // The title bar occludes what lies behind the window. + let anchor = members.first()?.clone(); + return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true }); + } + return self.stack_proposal(window, members, pointer); + } + let (anchor, zone, preview) = self.edge_proposal(members, pointer)?; + Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false }) + } + + fn stack_proposal(&self, hit: &StackHit

, members: &[P], pointer: Pos2) -> Option> { + let line = |y: f32| Rect::from_min_max(pos2(hit.window.left(), y - 1.0), pos2(hit.window.right(), y + 1.0)); + let same = |panels: &[P]| panels == members; + // An anchor in the group that is not itself moving. + let anchor = |panels: &[P]| panels.iter().find(|p| !members.contains(p)).cloned(); + for (upper, band) in &hit.seams { + if !band.contains(pointer) { + continue; + } + let (Some((a, rect, _)), Some((b, ..))) = (hit.groups.get(*upper), hit.groups.get(upper + 1)) else { return None }; + let line = line(rect.bottom() + GAP * 0.5); + if same(a) || same(b) { + let anchor = members.first()?.clone(); + return Some(DropProposal { anchor, placement: Placement::Group { below: true }, preview: Rect::NOTHING, insertion: None, noop: true }); + } + let (anchor, below) = match anchor(a) { + Some(panel) => (panel, true), + None => (anchor(b)?, false), + }; + return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false }); + } + let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?; + if strip.contains(pointer) || same(panels) { + return None; + } + let band = rect.height() * 0.25; + let below = if pointer.y < rect.top() + band { + false + } else if pointer.y > rect.bottom() - band { + true + } else { + return None; + }; + let insertion = Some(line(if below { rect.bottom() } else { rect.top() })); + Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false }) + } + + /// What a group drag carries: the whole window rect for the only group of a floating + /// window, so floating it again keeps the window's size; else the group's rect. + pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect { + match owner.kind { + GroupKind::Floating { window } + if owner.panels.first().is_some_and(|p| self.layout.floating.iter().any(|w| w.groups.len() == 1 && w.contains(p))) => + { + window + } + _ => owner.rect, + } + } + + /// The action a whole-group or whole-window gesture emits for `to`. + pub(super) fn group_action(&self, drag: &GroupDrag

, to: Target

) -> Action

{ + match drag.gesture { + GroupGesture::Window => Action::MoveWindow { panel: drag.panel.clone(), to }, + GroupGesture::Group => Action::MoveGroup { panel: drag.panel.clone(), to }, + } + } + + /// Release of a title bar drag. The window already sits where it was dragged; a target + /// docks or merges every group in it, else hosts get the release in `group_drop`. + pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, proposal: Option>) { + match proposal.filter(|p| !p.noop) { + Some(proposal) => { + let to = Target::At { anchor: proposal.anchor, placement: proposal.placement }; + self.output.actions.push(self.group_action(drag, to)); + } + None => self.output.group_drop = Some((drag.panel.clone(), pointer)), + } + } } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs new file mode 100644 index 00000000..8ad50589 --- /dev/null +++ b/crates/ui/src/docking/render/frame.rs @@ -0,0 +1,231 @@ +//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items. + +use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Pos2, Rect, pos2, vec2}; + +use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup}; +use crate::docking::{ + Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Permissions, Profile, Zone, + dock_model::{Place, node_at}, +}; + +/// What the renderer draws when it is not the layout itself, and the targets that depend on it. +pub(in crate::docking) struct FrameState

{ + /// View path to layout path of each split, when the view is not the layout. + paths: Option, Vec>>, + /// The view node whose rect is the outer edge area; none while a frame is maximized. + edge_path: Option>, + pub(super) edge: Option, + pub(super) maximized: Option

, + /// Floating stacks as drawn, back to front. + pub(super) stacks: Vec>, +} + +/// The layout as shown: a maximized frame alone, and hidden panels left out. +pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( + layout: &'a Layout

, + maximized: Option<&P>, + hide: HidePanels<'_, P>, + content: &dyn DockContent

, +) -> (Cow<'a, Layout

>, FrameState

) { + let protected = |p: &P| content.permissions(p) == Permissions::PROTECTED; + let hidden = |p: &P| match hide { + HidePanels::None => false, + HidePanels::All => !protected(p), + HidePanels::Except(keep) => !protected(p) && !keep(p), + }; + // Only a docked panel maximizes its frame. + let maximized = maximized.and_then(|panel| match layout.place(panel)? { + Place::Node(path) => Some((panel.clone(), path.clone(), layout.root.as_ref().and_then(|root| node_at(root, &path))?.clone())), + place @ Place::Dock { .. } => { + let group = layout.group(&place)?; + Some((panel.clone(), path_of(&place), Node::Tabs { panels: group.panels.clone(), active: group.active })) + } + Place::Floating { .. } => None, + }); + let mut state = FrameState { paths: None, edge_path: None, edge: None, maximized: None, stacks: Vec::new() }; + if maximized.is_none() && matches!(hide, HidePanels::None) { + state.edge_path = Some(layout.edge_path()); + return (Cow::Borrowed(layout), state); + } + let mut paths = HashMap::new(); + let view = match maximized { + Some((panel, path, node)) => { + state.maximized = Some(panel); + Layout { root: prune(&node, path, Vec::new(), &hidden, &mut paths), floating: Vec::new() } + } + None => { + let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths)); + let floating = layout + .floating + .iter() + .filter_map(|window| { + let groups: Vec<_> = window.groups.iter().filter_map(|g| prune_group(g, &hidden)).collect(); + (!groups.is_empty()).then(|| Floating { groups, rect: window.rect, native: window.native }) + }) + .collect(); + Layout { root, floating } + } + }; + if state.maximized.is_none() { + state.edge_path = Some(view.edge_path()); + } + state.paths = Some(paths); + (Cow::Owned(view), state) +} + +fn path_of(place: &Place) -> Vec { + match place { + Place::Node(path) | Place::Dock { path, .. } => path.clone(), + Place::Floating { .. } => Vec::new(), + } +} + +fn visible

(node: &Node

, hidden: &dyn Fn(&P) -> bool) -> bool { + match node { + Node::Split { first, second, .. } => visible(first, hidden) || visible(second, hidden), + Node::Tabs { panels, .. } => panels.iter().any(|p| !hidden(p)), + Node::Stack { entries } => entries.iter().any(|e| !hidden(&e.panel)), + Node::Dock { columns, .. } => columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).any(|p| !hidden(p)), + } +} + +/// `node` without hidden panels; a split with one visible side becomes that side. Records the +/// layout path of every split kept. +fn prune( + node: &Node

, + path: Vec, + at: Vec, + hidden: &dyn Fn(&P) -> bool, + paths: &mut HashMap, Vec>, +) -> Option> { + let child = |path: &[bool], side: bool| [path, &[side]].concat(); + match node { + Node::Split { axis, size, first, second } => match (visible(first, hidden), visible(second, hidden)) { + (true, true) => { + let first = prune(first, child(&path, false), child(&at, false), hidden, paths)?; + let second = prune(second, child(&path, true), child(&at, true), hidden, paths)?; + paths.insert(at, path); + Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) }) + } + (true, false) => prune(first, child(&path, false), at, hidden, paths), + (false, true) => prune(second, child(&path, true), at, hidden, paths), + (false, false) => None, + }, + Node::Tabs { panels, active } => { + let group = prune_group(&Group::new(panels.clone(), *active), hidden)?; + Some(Node::Tabs { panels: group.panels, active: group.active }) + } + Node::Stack { entries } => { + let entries: Vec<_> = entries.iter().filter(|e| !hidden(&e.panel)).cloned().collect(); + (!entries.is_empty()).then_some(Node::Stack { entries }) + } + Node::Dock { side, columns } => { + let columns: Vec<_> = columns + .iter() + .filter_map(|c| { + let groups: Vec<_> = c.groups.iter().filter_map(|g| prune_group(g, hidden)).collect(); + (!groups.is_empty()).then(|| Column { groups, ..c.clone() }) + }) + .collect(); + (!columns.is_empty()).then_some(Node::Dock { side: *side, columns }) + } + } +} + +/// A group without hidden panels; the selected panel stays selected when it is shown. +fn prune_group(group: &Group

, hidden: &dyn Fn(&P) -> bool) -> Option> { + let selected = group.active_panel(); + let panels: Vec

= group.panels.iter().filter(|p| !hidden(p)).cloned().collect(); + let active = selected.and_then(|s| panels.iter().position(|p| p == s)).unwrap_or(0); + (!panels.is_empty()).then(|| Group { panels, active, ..group.clone() }) +} + +impl Renderer<'_, P> { + pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) { + if self.frame.edge_path.as_deref() == Some(path) { + self.frame.edge = Some(rect); + } + } + + /// The layout path of a split drawn at view path `path`. + pub(super) fn original_path(&self, path: &[bool]) -> Vec { + self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) + } + + /// The outer edge band under the pointer (P8): top and bottom in both profiles, left and + /// right in the frame profile. Returns a docked anchor that is not moving, the zone and + /// the preview of the new frame. + pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> { + let area = self.frame.edge.filter(|area| area.contains(pointer))?; + let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0); + let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)]; + if self.style.profile == Profile::Frame { + edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]); + } + let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; + if distance > band { + return None; + } + let anchor = self + .layout + .panels() + .into_iter() + .find(|p| !members.contains(p) && !matches!(self.layout.place(p), Some(Place::Floating { .. }) | None))? + .clone(); + let size = area.size() * 0.25; + let preview = match zone { + Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())), + Zone::Right => Rect::from_min_max(pos2(area.right() - size.x, area.top()), area.max), + Zone::Top => Rect::from_min_size(area.min, vec2(area.width(), size.y)), + Zone::Bottom | Zone::Center => Rect::from_min_max(pos2(area.left(), area.bottom() - size.y), area.max), + }; + Some((anchor, zone, preview)) + } + + pub(super) fn float_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.undock_label } else { &self.style.float_label } + } + + pub(super) fn float_group_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.undock_group_label } else { &self.style.float_group_label } + } + + pub(super) fn close_group_label(&self) -> &str { + if self.style.profile == Profile::Frame { &self.style.close_panel_group_label } else { &self.style.close_group_label } + } + + /// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9). + pub(super) fn latch_undock(&self, ctx: &Context, kind: Option) { + let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd); + if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command { + ctx.data_mut(|d| { + if let Some(drag) = d.get_temp::>(self.id.with("drag")) { + d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag }); + } + }); + } + } + + /// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame + /// profile), and Move Panel to New Window where native windows are available. + pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { + if self.style.profile == Profile::Frame { + let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); + if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) { + self.output.actions.push(Action::CloseOthers { panel: panel.clone() }); + } + if owner.kind == GroupKind::Frame { + let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m)); + let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() }; + if self.menu_action(ui, &label) { + let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() }; + self.output.events.push(DockEvent::ToggleMaximize { panel }); + } + } + } + #[cfg(feature = "native-windows")] + self.new_window_item(ui, owner, panel); + } +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 3b80dfb3..b8f4ed8a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -26,8 +26,6 @@ pub(super) struct TabGroup<'a, P> { pub(super) rect: Rect, } -/// Width of a floating window's move grip at the left of each strip. -const GRIP_WIDTH: f32 = 12.0; /// The top band of a strip that drags the whole group. const BORDER_BAND: f32 = 4.0; @@ -65,13 +63,8 @@ impl Renderer<'_, P> { return; } let movable = panels.iter().all(|p| self.permissions(p).movable); - let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); + let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); ui.painter().rect_filled(strip, 0.0, self.style.tab_background); - if let GroupKind::Floating { window } = kind { - let grip = Rect::from_min_max(strip.min, pos2((strip.left() + GRIP_WIDTH).min(strip.right()), strip.bottom())); - self.window_grip(ui, panels, grip, window, movable); - strip.min.x = grip.right(); - } let floating = matches!(kind, GroupKind::Floating { .. }); let requested = self.content.group_header_width(panels, selected, floating); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; @@ -149,38 +142,6 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } - /// The floating window's own move: the window follows the pointer, every group included. - fn window_grip(&mut self, ui: &mut Ui, panels: &[P], grip: Rect, window: Rect, movable: bool) { - let Some(first) = panels.first() else { return }; - let response = ui.interact(grip, self.id.with(("move-group", Some(first))), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); - self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); - if movable && response.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); - let drag = GroupDrag { - panel: first.clone(), - original: [window.left(), window.top(), window.width(), window.height()], - gesture: GroupGesture::Window, - members: panels.to_vec(), - origin, - source: window, - }; - ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); - } - if movable - && response.dragged() - && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) - && drag.gesture == GroupGesture::Window - && let Some(pointer) = ui.ctx().pointer_interact_pos() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) - { - // From the press origin, so fractional pointer steps do not accumulate rounding. - let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); - } - ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border); - } - /// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band /// (`drag-border`: drag only). Both move the whole group. fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { @@ -194,7 +155,7 @@ impl Renderer<'_, P> { self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); if movable && response.drag_started() { - self.start_group_drag(ui, owner.panels, owner.rect); + self.start_group_drag(ui, owner.panels, self.drag_source(owner)); } if !bar { return; @@ -242,7 +203,7 @@ impl Renderer<'_, P> { let closable = all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); - if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { + if closable && self.menu_action(ui, &self.close_group_label().to_owned()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -251,7 +212,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label().to_owned()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), @@ -277,11 +238,12 @@ impl Renderer<'_, P> { }); response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); } + self.frame_items(ui, owner, panel); } /// A stock menu button with shaped text and its logical accessibility label; true when /// clicked, after closing the menu. - fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { + pub(super) fn menu_action(&mut self, ui: &mut Ui, logical: &str) -> bool { let text = self.content.action_label(ui, logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), logical)); @@ -353,15 +315,16 @@ impl Renderer<'_, P> { { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } - if policy.movable && response.drag_started() { - begin_drag(ui.ctx(), self.id, panel.clone(), group); + if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { + self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); } response.context_menu(|ui| { self.content.panel_menu(ui, panel); if policy.movable && policy.floatable { - let text = self.content.action_label(ui, &self.style.float_label, &self.style.font, self.style.text); + let logical = self.float_label().to_owned(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.button(text); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.float_label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); if response.clicked() { let size = self.float_size(panel, group.size()); let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 7539a1c0..fb299a07 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -7,6 +7,11 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; mod floating; +mod frame; +#[cfg(feature = "native-windows")] +mod native; + +pub(in crate::docking) use frame::{FrameState, view}; pub(in crate::docking) use group::GroupKind; @@ -26,6 +31,7 @@ pub(super) struct Renderer<'a, P> { pub(super) targets: Vec>, pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, + pub(super) frame: FrameState

, } impl Renderer<'_, P> { @@ -62,6 +68,7 @@ impl Renderer<'_, P> { if !crate::docking::targets::usable(rect) { return; } + self.note_edge(path, rect); match node { Node::Split { axis, size, first, second } => { let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; @@ -89,7 +96,7 @@ impl Renderer<'_, P> { if response.delta != 0.0 && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) { - self.output.actions.push(Action::ResizeSplit { path: path.clone(), size }); + self.output.actions.push(Action::ResizeSplit { path: self.original_path(path), size }); } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), @@ -266,12 +273,7 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont } pub(super) fn clamp_float(rect: Rect, screen: Rect) -> Rect { - if !crate::docking::targets::usable(screen) { - return rect; - } - let size = rect.size().max(vec2(1.0, 1.0)).min(screen.size()); - let min = rect.min.max(screen.min).min(screen.max - size); - Rect::from_min_size(min, size) + crate::docking::chrome::clamp(rect, screen) } pub(super) fn split_bounds(available: f32, axis: SplitAxis, first: PanelLimits, second: PanelLimits) -> (f32, f32) { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs new file mode 100644 index 00000000..05fe8196 --- /dev/null +++ b/crates/ui/src/docking/render/native.rs @@ -0,0 +1,154 @@ +//! Floating windows in their own OS windows (feature `native-windows`). A window with a native +//! rect is drawn with `show_viewport_immediate` where the backend has viewports; where egui +//! embeds viewports (wasm, tests) it stays in-window at its in-window rect. Tabs and groups do +//! not drag between OS windows. + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Pos2, Rect, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, vec2}; + +use super::{Renderer, clamp_float, group::TabGroup}; +use crate::docking::{Action, Drag, Floating, GroupDrag}; + +/// Smallest and largest native window, and the farthest it may sit from the desktop origin. +const MIN_SIZE: [f32; 2] = [160.0, 100.0]; +const MAX_SIZE: f32 = 16_384.0; +const MAX_POSITION: f32 = 100_000.0; + +/// A rect safe to hand to the windowing system: finite, its size and position bounded. +/// Hostile workspace files and bad monitor reports included. +pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE), + finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE), + ] +} + +/// The desktop rect a native viewport reports: outer position, inner size. None when the +/// backend reports nothing usable. +fn placed(outer: Option, inner: Option) -> Option<[f32; 4]> { + let (outer, inner) = (outer?, inner?); + (outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()])) +} + +impl Renderer<'_, P> { + /// Show the native windows and return the windows to draw in-window. + pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { + let native = !ctx.embed_viewports(); + let mut shown = Vec::new(); + let mut in_window = Vec::new(); + for window in windows { + match window.native { + Some(rect) if native && window.panels().all(|p| self.content.allow_native(p)) => { + if let Some(id) = self.native_window(ctx, window, rect) { + shown.push(id); + } + } + _ => in_window.push(window), + } + } + // Forget where windows that are no longer native were told to open. + let key = self.id.with("native-shown"); + let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); + ctx.data_mut(|d| { + for id in previous.iter().filter(|id| !shown.contains(id)) { + d.remove::<[f32; 4]>(*id); + } + d.insert_temp(key, shown); + }); + in_window + } + + /// One floating window in its own viewport. Returns the key of its spawn rect. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4]) -> Option { + let first = window.panels().next()?.clone(); + let viewport = ViewportId::from_hash_of((self.id, "native", &first)); + // The window opens at its saved rect; after that the window system owns its position + // and size, which are read back below. + let spawn_key = self.id.with(("native-spawn", &first)); + let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect))); + let title = window.groups.first().and_then(|g| g.active_panel()).map(|p| self.label_of(p)).unwrap_or_default(); + let builder = ViewportBuilder::default() + .with_title(title) + .with_position([spawn[0], spawn[1]]) + .with_inner_size([spawn[2], spawn[3]]) + .with_min_inner_size(MIN_SIZE); + if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); + self.output.focused = self.activation.clone(); + } + let (targets, stacks) = (self.targets.len(), self.frame.stacks.len()); + let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| { + let rect = ui.max_rect(); + ui.painter().rect_filled(rect, 0.0, self.style.floating_background); + self.floating_stack(ui, &window.groups, rect, rect); + // An embedded fallback reads the main window's input: its close request and place + // are not this window's. + if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { + ui.input(|i| (i.viewport().close_requested(), placed(i.viewport().outer_rect, i.viewport().inner_rect))) + } else { + (false, None) + } + }); + // Targets in another OS window are in its coordinates, and no drag crosses windows. + self.targets.truncate(targets); + self.frame.stacks.truncate(stacks); + ctx.data_mut(|d| { + if d.get_temp::>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) { + d.remove::>(self.id.with("drag")); + } + if d.get_temp::>(self.id.with("group-drag")).is_some_and(|drag| window.contains(&drag.panel)) { + d.remove::>(self.id.with("group-drag")); + } + }); + if closed { + ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_key)); + self.output.actions.push(Action::SetNative { panel: first, rect: None }); + return None; + } + if let Some(placed) = placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) { + self.output.actions.push(Action::SetNative { panel: first, rect: Some(placed) }); + } + Some(spawn_key) + } + + /// Move Panel to New Window: a floating panel's window, or the panel floated on its own. + pub(super) fn new_window_item(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { + let ctx = ui.ctx().clone(); + if ctx.embed_viewports() { + return; + } + let window = self.layout.floating.iter().find(|w| w.contains(panel)); + if window.is_some_and(|w| w.native.is_some()) { + return; + } + let movers: Vec

= window.map_or_else(|| vec![panel.clone()], |w| w.panels().cloned().collect()); + let allowed = |p: &P| self.content.allow_native(p) && self.permissions(p).floatable && self.permissions(p).movable; + if !movers.iter().all(allowed) { + return; + } + let in_window = match window { + Some(window) => window.rect, + None => { + let size = self.float_size(panel, owner.rect.size()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect()); + [rect.left(), rect.top(), rect.width(), rect.height()] + } + }; + if !self.menu_action(ui, &self.style.new_window_label.clone()) { + return; + } + // Over the panel's place in the app window: its content's position on the desktop. + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let [x, y, w, h] = in_window; + if window.is_none() { + self.output.actions.push(Action::Float { panel: panel.clone(), rect: in_window }); + } + let native = sanitized([origin.x + x, origin.y + y, w, h]); + self.output.actions.push(Action::SetNative { panel: panel.clone(), rect: Some(native) }); + } +} diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index de2aa445..45afb5de 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -58,22 +58,34 @@ impl Renderer<'_, P> { } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { - GroupGesture::Window => self.output.group_drop = Some((drag.panel, pointer)), + GroupGesture::Window => { + let proposal = if float_only { None } else { self.group_proposal(&drag, pointer) }; + self.drop_window(&drag, pointer, proposal); + } GroupGesture::Group => self.drop_group(ctx, &drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - if drag.gesture == GroupGesture::Group { - self.paint_group_drag(ctx, &drag, pointer, float_only); - } + self.paint_group_drag(ctx, &drag, pointer, float_only); } } /// The valid target under the pointer for a whole group. A target holding the group - /// itself is a no-op proposal; a target the model would reject is no target. + /// itself is a no-op proposal; a target the model would reject is no target. A window + /// moving live looks through itself and does not split frames or join a body's centre. fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { - let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; - if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { + let window = drag.gesture == GroupGesture::Window; + let valid = |proposal: DropProposal

| { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) + }; + if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { + return valid(proposal); + } + let own = |t: &crate::docking::DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); + let target = + self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?; + if own(target) { return Some(DropProposal { anchor: drag.panel.clone(), placement: Placement::Tab { before: None }, @@ -83,11 +95,10 @@ impl Renderer<'_, P> { }); } let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; - let action = Action::MoveGroup { - panel: drag.panel.clone(), - to: Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }, - }; - self.layout.clone().apply_with_permissions(action, |p| self.permissions(p)).is_ok().then_some(proposal) + if window && matches!(proposal.placement, Placement::Split(_)) { + return None; + } + valid(proposal) } /// Release of a group drag: join or split at the target, or float at the ghost with the @@ -117,18 +128,23 @@ impl Renderer<'_, P> { } } - /// Ghost outline at the pointer and one destination preview. + /// Ghost outline at the pointer (a window moving live is its own ghost) and one + /// destination preview. fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { let painter = self.preview_painter(ctx); - let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); - painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + if drag.gesture == GroupGesture::Group { + let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + } if float_only { return; } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { - let split = matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center); + let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; - painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + if crate::docking::targets::usable(proposal.preview) { + painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + } if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } @@ -160,7 +176,7 @@ impl Renderer<'_, P> { } return; } - let float_only = float_only(ctx); + let float_only = float_only(ctx) || drag.undock; let floatable = self.permissions(&drag.panel).floatable && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); @@ -170,7 +186,11 @@ impl Renderer<'_, P> { } return; } - let proposal = if float_only { None } else { resolve_drop(&drag.panel, pointer, &self.targets) }; + let proposal = if float_only { + None + } else { + self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| resolve_drop(&drag.panel, pointer, &self.targets)) + }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { @@ -199,7 +219,7 @@ impl Renderer<'_, P> { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop { + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) { painter.rect( proposal.preview.shrink(1.0), 0.0, diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 901fc273..2c11c4cd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -45,6 +45,32 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colours are not. pub floating_background: Color32, pub floating_stroke: Stroke, + /// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with + /// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels. + pub profile: Profile, + /// Height of a floating window's title bar. + pub title_height: f32, + /// Width of the band along the layout's outer edge that splits at the root. + pub edge_band: f32, + pub move_window_label: String, + pub close_window_label: String, + /// Frame profile menu labels. + pub undock_label: String, + pub undock_group_label: String, + pub close_panel_group_label: String, + pub close_others_label: String, + pub maximize_label: String, + pub restore_frame_label: String, + /// Shown where native windows are available (feature `native-windows`). + pub new_window_label: String, +} + +/// Which arrangement a [`DockStyle`] renders and labels. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Profile { + #[default] + Dock, + Frame, } impl DockStyle { @@ -77,11 +103,23 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, + profile: Profile::Dock, + title_height: super::chrome::TITLE_HEIGHT, + edge_band: 8.0, + move_window_label: "Move panel window".into(), + close_window_label: "Close panel window".into(), + undock_label: "Undock Panel".into(), + undock_group_label: "Undock Panel Group".into(), + close_panel_group_label: "Close Panel Group".into(), + close_others_label: "Close Other Panels in Group".into(), + maximize_label: "Maximize Frame".into(), + restore_frame_label: "Restore Frame Size".into(), + new_window_label: "Move Panel to New Window".into(), } } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width] + [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width, self.title_height, self.edge_band] .iter() .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 @@ -98,14 +136,41 @@ pub struct DockOutput

{ pub group_drag: Option

, /// Whole-group release for host-owned atomic docking policy. pub group_drop: Option<(P, Pos2)>, + /// Requests that are not layout actions. + pub events: Vec>, } impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None } + Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new() } } } +/// A request for host state outside the layout. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DockEvent

{ + /// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one + /// passed to [`DockArea::maximized`]. + ToggleMaximize { panel: P }, +} + +/// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. +pub enum HidePanels<'a, P> { + None, + All, + /// Every panel except those for which the function returns true. + Except(&'a dyn Fn(&P) -> bool), +} + +impl

Clone for HidePanels<'_, P> { + fn clone(&self) -> Self { + *self + } +} + +impl

Copy for HidePanels<'_, P> {} + /// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing /// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. #[derive(Clone, Copy, Debug, PartialEq)] @@ -139,6 +204,8 @@ pub(super) struct Drag

{ pub(super) origin: Pos2, pub(super) source: Rect, pub(super) grab: Vec2, + /// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats. + pub(super) undock: bool, } /// What a group gesture moves. @@ -171,7 +238,7 @@ pub fn begin_drag(ctx: &Context, area_id: Id, return false; } let grab = (origin - source_rect.min).clamp(Vec2::ZERO, source_rect.size()); - ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab })); + ctx.data_mut(|d| d.insert_temp(area_id.with("drag"), Drag { panel, origin, source: source_rect, grab, undock: false })); true } @@ -339,15 +406,31 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { /// Optional renderer for applications without an existing docking surface. Apps with custom /// chrome can instead use the model, target resolver and resize/tab primitives independently. -pub struct DockArea

{ +pub struct DockArea<'a, P = ()> { id: Id, activation: Option

, blockers: Vec, + maximized: Option

, + hide: HidePanels<'a, P>, } -impl DockArea

{ +impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new() } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None } + } + + /// Maximize Frame: the docked frame holding `panel` fills the area; other frames and + /// floating windows do not render or take input. A floating or absent panel is ignored. + pub fn maximized(mut self, panel: Option

) -> Self { + self.maximized = panel; + self + } + + /// Hide panels (Tab, Shift+Tab); their space goes to the panels that stay. Protected + /// panels always stay. The layout is unchanged. + pub fn hide_panels(mut self, hide: HidePanels<'a, P>) -> Self { + self.hide = hide; + self } /// Whether keyboard focus belongs to a rendered docking control in this layout. @@ -369,7 +452,11 @@ impl DockArea

{ || floating .panels() .next() - .is_some_and(|first| RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name)))) + .is_some_and(|first| { + RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) + || matches(self.id.with(("move-group", Some(first))).with("close")) + }) + || floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first())))) { return true; } @@ -420,7 +507,8 @@ impl DockArea

{ /// Render a validated snapshot and return requested actions for the app's command handler. /// No action is applied here. Panel contents get a global stable egui ID across moves. - /// Floating groups are egui areas inside this viewport; this does not create native windows. + /// Floating windows are egui areas inside this viewport; with feature `native-windows`, a + /// window with a native rect gets its own OS window where the backend has viewports. pub fn show( self, ui: &mut Ui, @@ -476,6 +564,7 @@ impl DockArea

{ cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); let mut renderer = Renderer { id: self.id, style, @@ -487,12 +576,13 @@ impl DockArea

{ targets: Vec::new(), owned_interactions: Vec::new(), output: DockOutput::default(), + frame, }; - if let Some(root) = &layout.root { + if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); ui.allocate_rect(rect, Sense::hover()); } - renderer.floating(ui.ctx()); + renderer.floating(ui.ctx(), &view.floating); renderer.finish_group_drag(ui.ctx()); renderer.finish_drag(ui.ctx()); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index cd5adafc..618ca268 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -904,7 +904,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { assert!(out.actions.is_empty()); assert!(content.headers >= 3 && content.bodies >= 3); assert!(content.labels.iter().any(|s| s == "Logical 5")); - let at = pos2(130.0, 82.0); + let at = pos2(130.0, 96.0); custom_frame( &ctx, &layout, @@ -1249,9 +1249,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))), "custom action must paint: {logical}" ); - // This sample also has a floating-group grip labeled Move to group. - let expected = if logical == "Move to group" { 2 } else { 1 }; - assert_eq!(h.query_all_by_label(logical).count(), expected, "popup action must retain its own logical accessibility: {logical}"); + assert_eq!(h.query_all_by_label(logical).count(), 1, "popup action must retain its own logical accessibility: {logical}"); assert_eq!( h.query_all_by_label(&format!("Action visual: {logical}")).count(), 0, @@ -1368,7 +1366,7 @@ fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() { } ctx.memory_mut(|memory| memory.request_focus(id)); frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); - assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 26.0]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 40.0]); } #[test] From e235c4c1832d53d87e9ce0ec50664a355c955405 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:21:35 +0000 Subject: [PATCH 018/160] docking: rustfmt and clippy for the floating and frame changes --- crates/ui/src/docking/chrome.rs | 25 ++++++++++++++----- crates/ui/src/docking/dock_model.rs | 3 +-- crates/ui/src/docking/floating_tests.rs | 12 ++++----- crates/ui/src/docking/render/floating.rs | 31 +++++++++++++++++------- crates/ui/src/docking/render/frame.rs | 12 ++++----- crates/ui/src/docking/render/group.rs | 6 ++--- crates/ui/src/docking/render/preview.rs | 3 ++- crates/ui/src/docking/surface.rs | 27 +++++++++++++-------- 8 files changed, 75 insertions(+), 44 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2e2b14be..c8fe2ddd 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -66,7 +66,10 @@ fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { ), ( "resize-bottom-left", - Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2((rect.left() + 12.0).min(rect.right()), rect.bottom())), + Rect::from_min_max( + pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), + pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), + ), vec2(-1.0, 1.0), ), ] @@ -133,7 +136,10 @@ impl<'a> FloatingFrame<'a> { pub fn show(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect()); let rect = clamp(self.rect, bounds); - let area = egui::Area::new(self.id).fade_in(false).order(Order::Middle).fixed_pos(rect.min) + let area = egui::Area::new(self.id) + .fade_in(false) + .order(Order::Middle) + .fixed_pos(rect.min) .default_size(rect.size()) .movable(false) .constrain_to(bounds); @@ -144,8 +150,7 @@ impl<'a> FloatingFrame<'a> { ui.painter().rect_filled(rect, 0.0, style.background); let inner = add_contents(ui, rect); ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); - let (resized, resize_started, handles) = - if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; + let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles } }); shown.inner @@ -185,10 +190,18 @@ impl<'a> FloatingFrame<'a> { let mut delta = Vec2::ZERO; if response.has_focus() { ui.memory_mut(|memory| { - memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + memory.set_focus_lock_filter( + response.id, + egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }, + ) }); ui.input_mut(|input| { - for (key, step) in [(Key::ArrowLeft, vec2(-8.0, 0.0)), (Key::ArrowRight, vec2(8.0, 0.0)), (Key::ArrowUp, vec2(0.0, -8.0)), (Key::ArrowDown, vec2(0.0, 8.0))] { + for (key, step) in [ + (Key::ArrowLeft, vec2(-8.0, 0.0)), + (Key::ArrowRight, vec2(8.0, 0.0)), + (Key::ArrowUp, vec2(0.0, -8.0)), + (Key::ArrowDown, vec2(0.0, 8.0)), + ] { if input.consume_key(egui::Modifiers::NONE, key) { delta += step; } diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 05b3409f..7049543f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -526,8 +526,7 @@ impl Layout

{ let new = Node::Tabs { panels, active }; let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let (first, second, size) = - if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; + let (first, second, size) = if matches!(zone, Zone::Top | Zone::Left) { (new, old, 0.25) } else { (old, new, 0.75) }; *node = Node::Split { axis, size: SplitSize::Ratio(size), first: Box::new(first), second: Box::new(second) }; Ok(()) } diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 8d8dc758..b2782233 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -248,11 +248,7 @@ fn floating_stack_seams_and_bands_insert_groups() { let tab = d.tab(8).unwrap().center(); let seam = pos2(420.0, 237.0); let out = d.drag(tab, seam); - assert!( - out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), - "{:?}", - out.actions - ); + assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions); assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); // The divider between two open groups resizes both. @@ -496,7 +492,8 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { let mut time = 0.0; let mut frame = |events: Vec| { time += 1.0 / 60.0; - let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, time: Some(time), ..Default::default() }; let mut result = None; done(ctx.run_ui(input, |ui| { let style = ChromeStyle::from_ui(ui); @@ -511,7 +508,8 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { }; frame(vec![]); frame(vec![]); - let click = |frame: &mut dyn FnMut(Vec) -> ((bool, Vec2, bool, bool), Option), at| { + type Shown = ((bool, Vec2, bool, bool), Option); + let click = |frame: &mut dyn FnMut(Vec) -> Shown, at| { frame(vec![Event::PointerMoved(at), button(at, true)]); frame(vec![button(at, false)]) }; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 383a68b4..f40da2c8 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -74,12 +74,11 @@ impl Renderer<'_, P> { let movable = window.panels().all(|panel| self.permissions(panel).movable); let style = ChromeStyle::from_dock(self.style); let resize_label = self.style.resize_window_label.clone(); - let response = FloatingFrame::new(id, rect) - .bounds(screen) - .limits(limits) - .resizable(movable) - .resize_label(&resize_label) - .show(ctx, &style, |ui, rect| self.window_contents(ui, window, rect, &style)); + let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show( + ctx, + &style, + |ui, rect| self.window_contents(ui, window, rect, &style), + ); for handle in &response.handles { self.register_interaction(ctx, handle.id); } @@ -93,7 +92,9 @@ impl Renderer<'_, P> { } }); if let Some(resized) = response.resized { - self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); + self.output + .actions + .push(Action::MoveFloating { panel: first.clone(), rect: [resized.left(), resized.top(), resized.width(), resized.height()] }); } } @@ -228,7 +229,13 @@ impl Renderer<'_, P> { if !in_group { // The title bar occludes what lies behind the window. let anchor = members.first()?.clone(); - return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true }); + return Some(DropProposal { + anchor, + placement: Placement::Tab { before: None }, + preview: Rect::NOTHING, + insertion: None, + noop: true, + }); } return self.stack_proposal(window, members, pointer); } @@ -249,7 +256,13 @@ impl Renderer<'_, P> { let line = line(rect.bottom() + GAP * 0.5); if same(a) || same(b) { let anchor = members.first()?.clone(); - return Some(DropProposal { anchor, placement: Placement::Group { below: true }, preview: Rect::NOTHING, insertion: None, noop: true }); + return Some(DropProposal { + anchor, + placement: Placement::Group { below: true }, + preview: Rect::NOTHING, + insertion: None, + noop: true, + }); } let (anchor, below) = match anchor(a) { Some(panel) => (panel, true), diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 8ad50589..d39235bc 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -184,16 +184,16 @@ impl Renderer<'_, P> { Some((anchor, zone, preview)) } - pub(super) fn float_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.undock_label } else { &self.style.float_label } + pub(super) fn float_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() } } - pub(super) fn float_group_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.undock_group_label } else { &self.style.float_group_label } + pub(super) fn float_group_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() } } - pub(super) fn close_group_label(&self) -> &str { - if self.style.profile == Profile::Frame { &self.style.close_panel_group_label } else { &self.style.close_group_label } + pub(super) fn close_group_label(&self) -> String { + if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() } } /// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9). diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index b8f4ed8a..fbdf35ae 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -203,7 +203,7 @@ impl Renderer<'_, P> { let closable = all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); - if closable && self.menu_action(ui, &self.close_group_label().to_owned()) { + if closable && self.menu_action(ui, &self.close_group_label()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -212,7 +212,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); } } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label().to_owned()) { + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), @@ -321,7 +321,7 @@ impl Renderer<'_, P> { response.context_menu(|ui| { self.content.panel_menu(ui, panel); if policy.movable && policy.floatable { - let logical = self.float_label().to_owned(); + let logical = self.float_label(); let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 45afb5de..a253a8bf 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -77,7 +77,8 @@ impl Renderer<'_, P> { let window = drag.gesture == GroupGesture::Window; let valid = |proposal: DropProposal

| { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; - (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) + (proposal.noop || self.layout.clone().apply_with_permissions(self.group_action(drag, to), |p| self.permissions(p)).is_ok()) + .then_some(proposal) }; if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { return valid(proposal); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 2c11c4cd..b51bf258 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -119,9 +119,19 @@ impl DockStyle { } pub(super) fn valid(&self) -> bool { - [self.tab_height, self.gap, self.min_pane, self.border.width, self.font.size, self.drag_bar_width, self.floating_stroke.width, self.title_height, self.edge_band] - .iter() - .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + [ + self.tab_height, + self.gap, + self.min_pane, + self.border.width, + self.font.size, + self.drag_bar_width, + self.floating_stroke.width, + self.title_height, + self.edge_band, + ] + .iter() + .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -449,13 +459,10 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { }; for floating in &layout.floating { if floating.groups.iter().any(|g| group(&g.panels)) - || floating - .panels() - .next() - .is_some_and(|first| { - RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) - || matches(self.id.with(("move-group", Some(first))).with("close")) - }) + || floating.panels().next().is_some_and(|first| { + RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) + || matches(self.id.with(("move-group", Some(first))).with("close")) + }) || floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first())))) { return true; From a842d330e0d9ef14bf860a7c0f1328f0d64e7dd8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:22:03 +0000 Subject: [PATCH 019/160] docking: test that hidden panels map split resizes to layout paths --- crates/ui/src/docking/floating_tests.rs | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index b2782233..fa486324 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -530,3 +530,32 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() { let (_, resized) = frame(vec![Event::PointerMoved(edge + vec2(50.0, 0.0))]); assert_eq!(resized, Some(Rect::from_min_size(rect.min, vec2(350.0, 200.0)))); } + +#[test] +fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() { + let layout = Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(200.0), + first: Box::new(tabs(&[9], 0)), + second: Box::new(Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[1], 0)), + second: Box::new(tabs(&[2], 0)), + }), + }), + floating: Vec::new(), + }; + let mut d = Driver::new(layout, Profile::Frame); + d.hide = Hide::Keep; + d.content.keep = vec![1, 2]; + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others"); + let divider = d.response(Id::new(AREA).with(("split", &Vec::::new()))).unwrap().center(); + let out = d.hold(divider, divider + vec2(0.0, 60.0)); + assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions); + let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; + assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5))); +} From 315fde3ed8d45fecb8a088895415835b13fe707d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:27:17 +0000 Subject: [PATCH 020/160] docking: test window title drags onto dock seams and new-dock bands --- crates/ui/src/docking/floating_tests.rs | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index fa486324..2f7cfb58 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -229,6 +229,32 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![6, 7]]); } +#[test] +fn title_bar_drag_onto_dock_targets_docks_the_window() { + // Released on the seam between two docked groups: the window's group stacks between them. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(8).center(); + let seam = pos2(120.0, d.tab(4).unwrap().top()); + let out = d.drag(title, seam); + assert!( + out.actions.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true } } }), + "{:?}", + out.actions + ); + assert!(window_of(&d.layout, 8).is_none()); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) }; + let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") }; + let stacked: Vec<_> = columns[0].groups.iter().map(|g| g.panels.clone()).collect(); + assert_eq!(stacked, vec![vec![2, 3], vec![8], vec![4]]); + + // Released on the new-dock band along the right edge: the window becomes an end dock. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(5).center(); + let out = d.drag(title, pos2(796.0, 300.0)); + assert!(out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions); + assert!(window_of(&d.layout, 5).is_none() && window_of(&d.layout, 6).is_none()); +} + #[test] fn floating_stack_seams_and_bands_insert_groups() { // A group dragged by its bar to another window's top band stacks above that group. From 87e6562a5e8967abd1781f3c5ae653ef1833bf6f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:36:18 +0000 Subject: [PATCH 021/160] ui: chrome live_move for title-bar moves Moves a rect with a title-bar drag from the rect and press position kept at drag start, so the window follows the pointer without drift. Escape or window focus loss ends the move and returns the start rect. --- crates/ui/src/docking/chrome.rs | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index c8fe2ddd..4c8985a9 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -3,7 +3,7 @@ //! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes //! a layout: the caller turns the responses into its own commands. -use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2}; +use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2}; use super::{DockStyle, PanelLimits, resize::AnchoredResize}; @@ -345,3 +345,45 @@ impl<'a> TitleBar<'a> { TitleResponse { bar, menu, close } } } + +/// One step of a title-bar move, from [`live_move`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MoveStep { + /// The rect follows the pointer, inside the bounds. + Moving(Rect), + /// Escape or window focus loss ended the move: the rect it started from. + Cancelled(Rect), + /// Released over `pointer`, with the rect where it was dropped. + Dropped { rect: Rect, pointer: Pos2 }, +} + +/// Move `rect` with a drag on `bar` (usually [`TitleResponse::bar`]). The rect and the press +/// position at drag start are kept in egui memory under the bar's id, so the window follows the +/// pointer without drift. Escape or window focus loss ends the move and returns the start rect; +/// the rest of that drag is ignored. `None` when the bar is not being moved. +pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Option { + let key = bar.id.with("live-move"); + if bar.drag_started() + && let Some(origin) = ctx.input(|input| input.pointer.press_origin()) + { + ctx.data_mut(|data| data.insert_temp(key, (rect, origin))); + } + let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?; + let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key)); + if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + forget(); + return Some(MoveStep::Cancelled(start)); + } + let pointer = ctx.pointer_latest_pos().unwrap_or(origin); + let moved = clamp(start.translate(pointer - origin), bounds); + if bar.drag_stopped() { + forget(); + return Some(MoveStep::Dropped { rect: moved, pointer }); + } + if bar.dragged() { + return Some(MoveStep::Moving(moved)); + } + // The drag ended without a release on this bar (the bar was not shown). + forget(); + None +} From 484717644c4271b291f2ad60fea32c2a6b20ff60 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:36:18 +0000 Subject: [PATCH 022/160] panes: task-pane host for document apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PaneHost draws the open panes as egui side and bottom panels before the app's CentralPanel, or as floating frames, each with a title, a ▾ menu (app items, Move to Start/End, Float, Close), × and a resize edge. Edges are logical (Start/End) with an RTL flag. Columns shows every pane side by side; OneSlot shows one closable pane per edge. The app owns open state and pane state: the host returns Close, Place, Resize, FloatRect and Focus requests, the focused pane, and cycles focus with F6. Drag-out-to-float and edge snap follow wordcraft#2584. --- crates/ui/src/lib.rs | 1 + crates/ui/src/panes/mod.rs | 732 +++++++++++++++++++++++++++++++++++ crates/ui/src/panes/tests.rs | 308 +++++++++++++++ 3 files changed, 1041 insertions(+) create mode 100644 crates/ui/src/panes/mod.rs create mode 100644 crates/ui/src/panes/tests.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index fcb60829..f54d1bbc 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -9,6 +9,7 @@ pub mod buttons; pub mod docking; pub mod icons; pub mod layout; +pub mod panes; pub mod printing; pub mod tabs; pub mod theme; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs new file mode 100644 index 00000000..c90fb9f4 --- /dev/null +++ b/crates/ui/src/panes/mod.rs @@ -0,0 +1,732 @@ +//! Task panes for document apps: side and bottom panes around the app's own central document. +//! +//! [`PaneHost`] draws each open pane as an egui side or bottom panel, or as a floating frame, with +//! a title bar (title, ▾ menu, ×) and a resizable inner edge. Call it before the app's +//! `CentralPanel`; the document never becomes a panel. The app owns which panes are open and +//! where they are: the host takes the open list and each pane's [`PaneState`] every frame and +//! returns [`PaneRequest`]s for the app to run through its own commands. Docking chrome +//! ([`crate::docking::chrome`]) draws the floating frames and title bars. + +use std::fmt::Debug; +use std::hash::Hash; +use std::ops::RangeInclusive; + +use egui::{ + Color32, Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, + vec2, +}; + +use crate::docking::PanelLimits; +use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// A title dragged this far out of a docked pane floats it; a pane dropped this close to the +/// host's left or right edge docks there. +pub const DOCK_SNAP: f32 = 32.0; +/// Height of a pane's title bar. +pub const TITLE_BAR_HEIGHT: f32 = 24.0; +/// Size of a pane floated for the first time. +pub const FLOAT_DEFAULT: Vec2 = vec2(270.0, 480.0); +const FLOAT_MIN_HEIGHT: f32 = 160.0; +/// Where the title bar is held when a dragged-out pane floats at the pointer. +const FLOAT_GRAB: Vec2 = vec2(40.0, 12.0); + +/// A logical edge of the host area. `Start` is left in left-to-right interfaces and right in +/// right-to-left ones. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Edge { + Start, + End, + Bottom, +} + +/// Where a pane is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Place { + Docked(Edge), + Floating, +} + +/// The persisted layout of one pane. The app stores it where it keeps its preferences. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct PaneState { + pub place: Place, + /// Width of a side pane or height of a bottom pane, in points. `None`: the pane's default. + pub size: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + pub float_rect: Option<[f32; 4]>, +} + +impl PaneState { + pub fn docked(edge: Edge, size: f32) -> Self { + Self { place: Place::Docked(edge), size: Some(size), float_rect: None } + } + + fn apply

(&mut self, request: &PaneRequest

) { + match request { + PaneRequest::Place(_, place) => self.place = *place, + PaneRequest::Resize(_, size) => self.size = Some(*size), + PaneRequest::FloatRect(_, rect) => self.float_rect = Some(*rect), + PaneRequest::Close(_) | PaneRequest::Focus(_) => {} + } + } +} + +/// How docked panes share an edge. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SidePolicy { + /// Every pane on an edge is shown, side by side in `open` order, the first outermost. + #[default] + Columns, + /// One pane per edge: the last of the edge's closable panes in `open`. Panes that are not + /// closable (context panes the app shows from its own state) are shown beside the slot. + OneSlot, +} + +/// A change the user asked for. The host applies none of them to the app's state. +#[derive(Clone, Debug, PartialEq)] +pub enum PaneRequest

{ + Close(P), + Place(P, Place), + Resize(P, f32), + FloatRect(P, [f32; 4]), + /// The pane took focus from a click, keyboard focus or F6. + Focus(P), +} + +impl

PaneRequest

{ + pub fn pane(&self) -> &P { + match self { + Self::Close(p) | Self::Place(p, _) | Self::Resize(p, _) | Self::FloatRect(p, _) | Self::Focus(p) => p, + } + } +} + +/// What [`PaneHost::show`] returns. +#[derive(Clone, Debug, PartialEq)] +pub struct PaneOutput

{ + /// In the order they happened; apply them in order. Resize and FloatRect arrive every + /// frame a gesture changes the size or rect. + pub requests: Vec>, + /// The pane holding keyboard focus or last clicked, if it is still shown. + pub focused: Option

, + /// F6 moved focus past the last pane (or Shift+F6 past the first): focus the document. + pub to_document: bool, +} + +/// The app's side of a pane: its contents and defaults. +pub trait PaneContent

{ + /// May follow the selection (Format Shape, Format Picture). + fn title(&self, pane: &P) -> String; + fn body(&mut self, ui: &mut Ui, pane: &P); + /// Used when the state callback returns `None` and the host has none in memory. + fn default_state(&self, pane: &P) -> PaneState; + /// Width (or bottom height) limits of the docked pane. + fn size_range(&self, _pane: &P) -> RangeInclusive { + 200.0..=520.0 + } + /// `false`: no Move or Float items and no title drag. + fn movable(&self, _pane: &P) -> bool { + true + } + /// `false`: no × and no Close item (context panes such as PivotTable Fields). + fn closable(&self, _pane: &P) -> bool { + true + } + /// App items, shown above the Move, Float and Close items of the ▾ menu. + fn menu(&mut self, _ui: &mut Ui, _pane: &P) {} +} + +/// Labels of the chrome; translate them in the app. +#[derive(Clone, Debug)] +pub struct PaneLabels { + pub menu: String, + pub move_start: String, + pub move_end: String, + pub move_bottom: String, + pub float: String, + pub close: String, + /// Accessibility label of the title bar. + pub move_pane: String, + /// Accessibility label of the resize edges. + pub resize: String, +} + +impl Default for PaneLabels { + fn default() -> Self { + Self { + menu: "Pane options".into(), + move_start: "Move to Start".into(), + move_end: "Move to End".into(), + move_bottom: "Move to Bottom".into(), + float: "Float".into(), + close: "Close".into(), + move_pane: "Move pane".into(), + resize: "Resize pane".into(), + } + } +} + +/// Draws the open panes. Build it every frame. +pub struct PaneHost { + id: Id, + rtl: bool, + policy: SidePolicy, + max_fraction: f32, + f6: bool, + labels: PaneLabels, + frame: Option, + margin: Margin, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Side { + Left, + Right, + Bottom, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Memory { + active: Option, + last_focus: Option, + /// F6 pressed or [`focus_next`] called: `true` cycles backwards. + cycle: Option, + drag_out: Option, +} + +#[derive(Clone, Copy, Debug)] +struct DragOut { + pane: Id, + cancelled: bool, +} + +struct Shown

{ + pane: P, + id: Id, + state: PaneState, +} + +/// Where a pane was drawn, for focus tracking. +struct Region { + pane: Id, + rect: Rect, + floating: Option, +} + +fn memory_id(id: Id) -> Id { + id.with("panes-memory") +} + +/// Move focus to the next pane (the previous one with `reverse`) at the host's next frame. For +/// apps that route F6 through their own commands; see [`PaneHost::f6`]. +pub fn focus_next(ctx: &Context, id: impl Into, reverse: bool) { + let key = memory_id(id.into()); + ctx.data_mut(|data| data.get_temp_mut_or_default::(key).cycle = Some(reverse)); + ctx.request_repaint(); +} + +impl PaneHost { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + rtl: false, + policy: SidePolicy::Columns, + max_fraction: 1.0, + f6: true, + labels: PaneLabels::default(), + frame: None, + margin: Margin::same(8), + } + } + + /// Right-to-left interface: `Start` is the right edge. + pub fn rtl(mut self, rtl: bool) -> Self { + self.rtl = rtl; + self + } + + pub fn policy(mut self, policy: SidePolicy) -> Self { + self.policy = policy; + self + } + + /// Largest share of the host's width (height for bottom panes) one docked pane may take. + pub fn max_fraction(mut self, fraction: f32) -> Self { + self.max_fraction = if fraction.is_finite() { fraction.clamp(0.0, 1.0) } else { 1.0 }; + self + } + + /// Whether the host takes F6 and Shift+F6 to cycle focus through the panes and back to the + /// document. Default on. Apps that bind F6 themselves call [`focus_next`]. + pub fn f6(mut self, f6: bool) -> Self { + self.f6 = f6; + self + } + + pub fn labels(mut self, labels: PaneLabels) -> Self { + self.labels = labels; + self + } + + /// Frame of the docked panels. Default: the panel fill and a hairline border, no margin. + pub fn frame(mut self, frame: Frame) -> Self { + self.frame = Some(frame); + self + } + + /// Margin around each pane's body. Default 8. + pub fn body_margin(mut self, margin: Margin) -> Self { + self.margin = margin; + self + } + + fn pane_id(&self, pane: &P) -> Id { + self.id.with(("pane", pane)) + } + + fn side(&self, edge: Edge) -> Side { + match (edge, self.rtl) { + (Edge::Bottom, _) => Side::Bottom, + (Edge::Start, false) | (Edge::End, true) => Side::Left, + (Edge::Start, true) | (Edge::End, false) => Side::Right, + } + } + + /// The edge a pane dropped at `pointer` docks against, if it is within [`DOCK_SNAP`] of the + /// host's left or right edge. + fn snap(&self, pointer: Pos2, bounds: Rect) -> Option { + let left = if self.rtl { Edge::End } else { Edge::Start }; + let right = if self.rtl { Edge::Start } else { Edge::End }; + if pointer.x <= bounds.left() + DOCK_SNAP { + Some(left) + } else if pointer.x >= bounds.right() - DOCK_SNAP { + Some(right) + } else { + None + } + } + + /// Draw the panes in `open` with the state `states` returns for each (`None`: the state the + /// host kept from earlier requests, else the pane's default). Call between the app's top + /// and bottom bars and its `CentralPanel`. + pub fn show>( + self, + ui: &mut Ui, + open: &[P], + states: impl Fn(&P) -> Option, + content: &mut C, + ) -> PaneOutput

{ + let ctx = ui.ctx().clone(); + let bounds = ui.available_rect_before_wrap(); + let style = ChromeStyle::from_ui(ui); + let key = memory_id(self.id); + let mut memory = ctx.data(|data| data.get_temp::(key)).unwrap_or_default(); + let mut requests = Vec::new(); + let mut to_document = false; + + if self.f6 { + ui.input_mut(|input| { + if input.consume_key(Modifiers::SHIFT, Key::F6) { + memory.cycle = Some(true); + } else if input.consume_key(Modifiers::NONE, Key::F6) { + memory.cycle = Some(false); + } + }); + } + + let mut shown: Vec> = Vec::new(); + for pane in open { + let id = self.pane_id(pane); + if shown.iter().any(|s| s.id == id) { + continue; + } + let state = + states(pane).or_else(|| ctx.data(|data| data.get_temp::(id.with("state")))).unwrap_or_else(|| content.default_state(pane)); + shown.push(Shown { pane: pane.clone(), id, state }); + } + if self.policy == SidePolicy::OneSlot { + let mut taken = Vec::new(); + for index in (0..shown.len()).rev() { + if let Place::Docked(edge) = shown[index].state.place + && content.closable(&shown[index].pane) + { + if taken.contains(&edge) { + shown.remove(index); + } else { + taken.push(edge); + } + } + } + } + + let previous = memory.active.filter(|active| shown.iter().any(|s| s.id == *active)); + memory.active = previous; + let mut focus_target = None; + if let Some(reverse) = memory.cycle.take() { + let current = memory.active.and_then(|active| shown.iter().position(|s| s.id == active)); + let next = match (current, reverse) { + (None, false) => shown.first(), + (None, true) => shown.last(), + (Some(i), false) => shown.get(i + 1), + (Some(i), true) => i.checked_sub(1).and_then(|i| shown.get(i)), + }; + focus_target = next.map(|s| s.id); + memory.active = focus_target; + if focus_target.is_none() { + to_document = true; + ctx.memory_mut(|m| { + if let Some(focused) = m.focused() { + m.surrender_focus(focused); + } + }); + } + } + + let preview = ctx.layer_painter(LayerId::new(Order::Tooltip, self.id.with("drop-preview"))); + let mut regions = Vec::new(); + for edge in [Edge::Start, Edge::End, Edge::Bottom] { + for s in shown.iter().filter(|s| s.state.place == Place::Docked(edge)) { + let rect = self.docked(ui, s, edge, content, &style, focus_target == Some(s.id), bounds, &mut memory, &preview, &mut requests); + regions.push(Region { pane: s.id, rect, floating: None }); + } + } + for s in shown.iter().filter(|s| s.state.place == Place::Floating) { + let (rect, layer) = self.floating(&ctx, s, content, &style, focus_target == Some(s.id), bounds, &mut requests); + regions.push(Region { pane: s.id, rect, floating: Some(layer) }); + } + + if focus_target.is_none() && !to_document { + self.track_focus(&ctx, ui.layer_id(), ®ions, &mut memory); + } + memory.last_focus = ctx.memory(|m| m.focused()); + if memory.active != previous + && let Some(s) = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)) + { + requests.push(PaneRequest::Focus(s.pane.clone())); + } + + for s in &shown { + let mut state = s.state; + let mut changed = false; + for request in requests.iter().filter(|r| self.pane_id(r.pane()) == s.id) { + state.apply(request); + changed = true; + } + if changed { + ctx.data_mut(|data| data.insert_temp(s.id.with("state"), state)); + } + } + let focused = memory.active.and_then(|active| shown.iter().find(|s| s.id == active)).map(|s| s.pane.clone()); + ctx.data_mut(|data| data.insert_temp(key, memory)); + PaneOutput { requests, focused, to_document } + } + + /// The pane under a primary press, or the pane holding a newly focused widget, becomes active. + /// Presses and focus outside every pane (the document) clear it; presses on other layers + /// (menus, the app's own windows) leave it. + fn track_focus(&self, ctx: &Context, layer: LayerId, regions: &[Region], memory: &mut Memory) { + let find = |at_layer: Option, pos: Pos2| -> Option> { + if let Some(region) = regions.iter().find(|r| r.floating.is_some() && r.floating == at_layer) { + return Some(Some(region.pane)); + } + if at_layer.is_none() || at_layer == Some(layer) { + return Some(regions.iter().find(|r| r.floating.is_none() && r.rect.contains(pos)).map(|r| r.pane)); + } + None + }; + if ctx.input(|input| input.pointer.primary_pressed()) + && let Some(pos) = ctx.input(|input| input.pointer.press_origin()) + && let Some(active) = find(ctx.layer_id_at(pos), pos) + { + memory.active = active; + } + let focused = ctx.memory(|m| m.focused()); + if focused != memory.last_focus + && let Some(response) = focused.and_then(|id| ctx.read_response(id)) + && let Some(active) = find(Some(response.layer_id), response.rect.center()) + { + memory.active = active; + } + } + + fn limits>(&self, content: &C, pane: &P, extent: f32) -> (f32, f32) { + let range = content.size_range(pane); + let min = if range.start().is_finite() { range.start().max(0.0) } else { 0.0 }; + let max = if range.end().is_finite() { range.end().max(min) } else { min.max(extent) }; + (min, max.min(self.max_fraction * extent).max(min)) + } + + #[allow(clippy::too_many_arguments)] + fn docked>( + &self, + ui: &mut Ui, + s: &Shown

, + edge: Edge, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + requests: &mut Vec>, + ) -> Rect { + let side = self.side(edge); + let extent = if side == Side::Bottom { bounds.height() } else { bounds.width() }; + let (min, max) = self.limits(content, &s.pane, extent); + let default = content.default_state(&s.pane).size; + let size = s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(min).clamp(min, max); + let panel_id = s.id.with("panel"); + let panel = match side { + Side::Left => egui::Panel::left(panel_id), + Side::Right => egui::Panel::right(panel_id), + Side::Bottom => egui::Panel::bottom(panel_id), + }; + let frame = self.frame.unwrap_or_else(|| Frame::NONE.fill(ui.visuals().panel_fill).stroke(ui.visuals().widgets.noninteractive.bg_stroke)); + let shown = panel.exact_size(size).resizable(false).frame(frame).show(ui, |ui| { + let top = ui.max_rect(); + let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); + ui.allocate_rect(title, Sense::hover()); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + Frame::NONE.inner_margin(self.margin).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + content.body(ui, &s.pane); + }); + bar + }); + let rect = shown.response.rect; + let bar = shown.inner; + + let (axis, strip) = match side { + Side::Left => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.right() - 2.0..=rect.right() + 2.0, rect.y_range())), + Side::Right => (SplitAxis::Horizontal, Rect::from_x_y_ranges(rect.left() - 2.0..=rect.left() + 2.0, rect.y_range())), + Side::Bottom => (SplitAxis::Vertical, Rect::from_x_y_ranges(rect.x_range(), rect.top() - 2.0..=rect.top() + 2.0)), + }; + let handle_style = ResizeHandleStyle { hovered: style.accent.gamma_multiply(0.5), dragged: style.accent, focused: style.accent }; + let resize = ResizeHandle::new(s.id.with("resize"), axis, strip, &self.labels.resize, handle_style).show(ui); + let next = if resize.response.dragged() { + resize.response.interact_pointer_pos().map(|p| match side { + Side::Left => p.x - rect.left(), + Side::Right => rect.right() - p.x, + Side::Bottom => rect.bottom() - p.y, + }) + } else if resize.delta != 0.0 { + // Arrow keys move the edge: right grows a left pane, left grows a right pane. + Some(if side == Side::Left { size + resize.delta } else { size - resize.delta }) + } else { + None + }; + if let Some(next) = next.filter(|v| v.is_finite()).map(|v| v.clamp(min, max).round()) + && next != size + { + requests.push(PaneRequest::Resize(s.pane.clone(), next)); + } + + if content.movable(&s.pane) { + self.drag_out(ui.ctx(), &bar, s, edge, rect, bounds, memory, preview, style, requests); + } + rect + } + + /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's + /// left or right edge it docks there, elsewhere it floats under the pointer. + #[allow(clippy::too_many_arguments)] + fn drag_out( + &self, + ctx: &Context, + bar: &Response, + s: &Shown

, + edge: Edge, + rect: Rect, + bounds: Rect, + memory: &mut Memory, + preview: &egui::Painter, + style: &ChromeStyle, + requests: &mut Vec>, + ) { + if bar.drag_started() { + memory.drag_out = Some(DragOut { pane: s.id, cancelled: false }); + } + let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; + if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + drag.cancelled = true; + } + let near = rect.expand(DOCK_SNAP); + let Some(pointer) = ctx.pointer_latest_pos() else { return }; + let outside = if edge == Edge::Bottom { !near.y_range().contains(pointer.y) } else { !near.x_range().contains(pointer.x) }; + let size = s.state.float_rect.map_or(FLOAT_DEFAULT, |r| vec2(r[2], r[3])); + let float = float_rect(Some(rect_array(Rect::from_min_size(pointer - FLOAT_GRAB, size))), bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + let target = self.snap(pointer, bounds); + if bar.drag_stopped() { + memory.drag_out = None; + if drag.cancelled || !outside { + return; + } + match target { + Some(target) if target != edge => requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(target))), + Some(_) => {} + None => { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Floating)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(float))); + } + } + return; + } + if !bar.dragged() { + memory.drag_out = None; + return; + } + memory.drag_out = Some(drag); + if drag.cancelled || !outside { + return; + } + let shape = match target.map(|t| self.side(t)) { + Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + rect.width(), bounds.y_range()), + Some(_) => Rect::from_x_y_ranges(bounds.right() - rect.width()..=bounds.right(), bounds.y_range()), + None => float, + }; + paint_preview(preview, shape, style.accent); + } + + #[allow(clippy::too_many_arguments)] + fn floating>( + &self, + ctx: &Context, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> (Rect, LayerId) { + let (min_width, _) = self.limits(content, &s.pane, bounds.width()); + let min = vec2(min_width, FLOAT_MIN_HEIGHT); + let rect = float_rect(s.state.float_rect, bounds, min); + let limits = PanelLimits { min, max: bounds.size().max(min) }; + let shown = FloatingFrame::new(s.id.with("float"), rect).bounds(bounds).limits(limits).resize_label(&self.labels.resize).show( + ctx, + style, + |ui, rect| { + let title = Rect::from_min_size(rect.min, vec2(rect.width(), TITLE_BAR_HEIGHT)); + let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); + let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max) - self.margin; + if body.is_positive() { + ui.scope_builder(UiBuilder::new().max_rect(body), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + content.body(ui, &s.pane); + }); + } + bar + }, + ); + if let Some(resized) = shown.resized { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(resized))); + } + if content.movable(&s.pane) { + match chrome::live_move(ctx, &shown.inner, shown.rect, bounds) { + Some(MoveStep::Moving(next) | MoveStep::Cancelled(next)) if next != shown.rect => { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); + } + Some(MoveStep::Dropped { rect, pointer }) => { + if let Some(edge) = self.snap(pointer, bounds) { + requests.push(PaneRequest::Place(s.pane.clone(), Place::Docked(edge))); + } else if rect != shown.rect { + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + _ => {} + } + } + (shown.rect, shown.layer) + } + + /// Title, ▾ menu and ×. Returns the bar, which senses the title drag. + #[allow(clippy::too_many_arguments)] + fn chrome>( + &self, + ui: &mut Ui, + rect: Rect, + s: &Shown

, + content: &mut C, + style: &ChromeStyle, + focus: bool, + bounds: Rect, + requests: &mut Vec>, + ) -> Response { + let movable = content.movable(&s.pane); + let closable = content.closable(&s.pane); + let mut bar = + TitleBar::new(s.id.with("title"), &self.labels.move_pane).title(content.title(&s.pane)).menu(&self.labels.menu).movable(movable); + if closable { + bar = bar.close(&self.labels.close); + } + let title = bar.show(ui, rect, style); + if focus { + title.bar.request_focus(); + } + if title.close.as_ref().is_some_and(Response::clicked) { + requests.push(PaneRequest::Close(s.pane.clone())); + } + if let Some(menu) = &title.menu { + egui::Popup::menu(menu).show(|ui| { + let top = ui.cursor().top(); + content.menu(ui, &s.pane); + if ui.cursor().top() > top && (movable || closable) { + ui.separator(); + } + if movable { + let mut places = vec![(Place::Docked(Edge::Start), &self.labels.move_start), (Place::Docked(Edge::End), &self.labels.move_end)]; + if content.default_state(&s.pane).place == Place::Docked(Edge::Bottom) { + places.push((Place::Docked(Edge::Bottom), &self.labels.move_bottom)); + } + places.push((Place::Floating, &self.labels.float)); + for (place, label) in places { + if ui.selectable_label(s.state.place == place, label.as_str()).clicked() && s.state.place != place { + requests.push(PaneRequest::Place(s.pane.clone(), place)); + if place == Place::Floating { + let rect = float_rect(s.state.float_rect, bounds, vec2(0.0, FLOAT_MIN_HEIGHT)); + requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(rect))); + } + } + } + } + if closable && ui.button(self.labels.close.as_str()).clicked() { + requests.push(PaneRequest::Close(s.pane.clone())); + } + }); + } + title.bar + } +} + +fn paint_preview(painter: &egui::Painter, rect: Rect, accent: Color32) { + painter.rect(rect.shrink(1.0), 0.0, accent.gamma_multiply(0.15), Stroke::new(2.0, accent), StrokeKind::Inside); +} + +fn rect_array(rect: Rect) -> [f32; 4] { + [rect.min.x, rect.min.y, rect.width(), rect.height()] +} + +/// The floating rect for `saved` (`[x, y, width, height]`), at least `min` and at most the +/// bounds in size, inside `bounds`. Without a saved rect: [`FLOAT_DEFAULT`] right of the middle. +/// Non-finite or negative values fall back to the defaults, so the result is always finite. +pub fn float_rect(saved: Option<[f32; 4]>, bounds: Rect, min: Vec2) -> Rect { + let bounds = if bounds.is_finite() && bounds.is_positive() { bounds } else { Rect::from_min_size(Pos2::ZERO, FLOAT_DEFAULT) }; + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let size = saved.map_or(FLOAT_DEFAULT, |r| vec2(finite(r[2], FLOAT_DEFAULT.x), finite(r[3], FLOAT_DEFAULT.y))); + let size = size.max(min.max(Vec2::splat(1.0))).min(bounds.size()); + let free = (bounds.size() - size).max(Vec2::ZERO); + let default = (bounds.min + vec2(free.x * 0.75, free.y * 0.3)).round(); + let pos = saved.map_or(default, |r| pos2(finite(r[0], default.x), finite(r[1], default.y))); + let pos = pos.max(bounds.min).min((bounds.max - size).max(bounds.min)); + Rect::from_min_size(pos, size) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs new file mode 100644 index 00000000..837760be --- /dev/null +++ b/crates/ui/src/panes/tests.rs @@ -0,0 +1,308 @@ +//! Behaviour tests for the pane host, driven by egui pointer and key events in an 800 × 600 +//! window. The harness's root ui has an 8 pt margin, so the host spans x 8..792, y 8..592. + +use std::collections::HashMap; + +use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2, Rect, Ui, pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +use super::*; + +const L: f32 = 8.0; +const R: f32 = 792.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Pane { + Nav, + Styles, + Comments, + /// A context pane, like PivotTable Fields: not closable, not movable. + Pivot, +} + +struct Content; + +impl PaneContent for Content { + fn title(&self, pane: &Pane) -> String { + format!("{pane:?}") + } + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + ui.label(format!("{pane:?} body")); + } + fn default_state(&self, pane: &Pane) -> PaneState { + let edge = if matches!(pane, Pane::Comments | Pane::Pivot) { Edge::End } else { Edge::Start }; + PaneState::docked(edge, 250.0) + } + fn movable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } + fn closable(&self, pane: &Pane) -> bool { + *pane != Pane::Pivot + } +} + +/// The app side: open list, persisted states and every request it received. +struct App { + open: Vec, + states: HashMap, + log: Vec>, + focused: Option, + to_document: usize, + rtl: bool, + policy: SidePolicy, +} + +impl App { + fn new(open: &[Pane]) -> Self { + Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns } + } + + fn frame(&mut self, ui: &mut Ui) { + let states = self.states.clone(); + let out = PaneHost::new("panes").rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content); + egui::CentralPanel::default().show(ui, |ui| ui.label("Document")); + for request in &out.requests { + match request { + PaneRequest::Close(p) => self.open.retain(|o| o != p), + PaneRequest::Focus(_) => {} + other => { + let pane = *other.pane(); + let state = self.states.entry(pane).or_insert_with(|| Content.default_state(&pane)); + state.apply(other); + } + } + } + self.log.extend(out.requests); + self.focused = out.focused; + self.to_document += usize::from(out.to_document); + } + + fn place(&self, pane: Pane) -> Place { + self.states.get(&pane).map_or_else(|| Content.default_state(&pane).place, |s| s.place) + } +} + +fn harness(app: App) -> Harness<'static, App> { + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(|ui, app: &mut App| app.frame(ui), app); + h.input_mut().focused = true; + h.run_steps(2); + h +} + +fn pane_id(pane: Pane) -> Id { + Id::new("panes").with(("pane", &pane)) +} + +fn rect(h: &Harness<'_, App>, id: Id) -> Option { + h.ctx.read_response(id).map(|r| r.rect) +} + +fn title(h: &Harness<'_, App>, pane: Pane) -> Rect { + rect(h, pane_id(pane).with("title")).unwrap() +} + +/// The x of a side pane's inner edge. +fn edge(h: &Harness<'_, App>, pane: Pane) -> f32 { + rect(h, pane_id(pane).with("resize")).unwrap().center().x +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn send(h: &mut Harness<'_, App>, events: Vec) { + h.input_mut().events.extend(events); + h.run_steps(1); +} + +fn click(h: &mut Harness<'_, App>, at: Pos2) { + send(h, vec![Event::PointerMoved(at), button(at, true)]); + send(h, vec![button(at, false)]); + h.run_steps(1); +} + +fn hold(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + send(h, vec![Event::PointerMoved(from), button(from, true)]); + send(h, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + send(h, vec![Event::PointerMoved(to)]); +} + +fn drag(h: &mut Harness<'_, App>, from: Pos2, to: Pos2) { + hold(h, from, to); + send(h, vec![button(to, false)]); + h.run_steps(2); +} + +fn key(h: &mut Harness<'_, App>, key: Key, modifiers: Modifiers) { + send(h, vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + h.run_steps(1); +} + +/// The requests that change a pane's state or open list. +fn changes(app: &App) -> Vec> { + app.log.iter().filter(|r| !matches!(r, PaneRequest::Focus(_))).cloned().collect() +} + +fn last_resize(app: &App, pane: Pane) -> Option { + app.log.iter().rev().find_map(|r| match r { + PaneRequest::Resize(p, size) if *p == pane => Some(*size), + _ => None, + }) +} + +#[test] +fn columns_sit_side_by_side_resize_and_move_to_end() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Styles])); + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Styles), L + 500.0); + assert!(title(&h, Pane::Nav).left() < L + 2.0); + + drag(&mut h, pos2(L + 250.0, 300.0), pos2(L + 300.0, 300.0)); + assert_eq!(last_resize(h.state(), Pane::Nav), Some(300.0)); + assert_eq!(edge(&h, Pane::Nav), L + 300.0); + assert_eq!(edge(&h, Pane::Styles), L + 550.0, "the second column follows"); + + let at = rect(&h, pane_id(Pane::Nav).with("title").with("menu")).unwrap().center(); + click(&mut h, at); + for label in ["Move to Start", "Move to End", "Float", "Close"] { + assert!(h.query_all_by_label(label).count() >= 1, "{label}"); + } + h.get_by_label("Move to End").click(); + h.run_steps(3); + assert!(h.state().log.contains(&PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Nav), R - 300.0, "a 300 pt pane at the right edge"); + assert_eq!(edge(&h, Pane::Styles), L + 250.0); +} + +#[test] +fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { + // Drag-out-to-float and the 32 pt edge snap follow wordcraft#2584. + let mut h = harness(App::new(&[Pane::Styles])); + let from = title(&h, Pane::Styles).center(); + // Within DOCK_SNAP of the pane: nothing happens. + drag(&mut h, from, pos2(L + 270.0, 300.0)); + assert!(changes(h.state()).is_empty(), "{:?}", h.state().log); + + drag(&mut h, from, pos2(400.0, 300.0)); + assert_eq!( + changes(h.state()), + vec![PaneRequest::Place(Pane::Styles, Place::Floating), PaneRequest::FloatRect(Pane::Styles, [360.0, 112.0, 270.0, 480.0])], + "floats under the pointer, clamped to the window" + ); + assert_eq!(title(&h, Pane::Styles).min, pos2(360.0, 112.0)); + + // Dropped within 32 pt of the right edge: docked at End. + h.state_mut().log.clear(); + let from = title(&h, Pane::Styles).center(); + drag(&mut h, from, pos2(780.0, 300.0)); + assert_eq!(h.state().log.last(), Some(&PaneRequest::Place(Pane::Styles, Place::Docked(Edge::End)))); + assert_eq!(edge(&h, Pane::Styles), R - 250.0); + // The floating rect's size is kept for the next float. + assert_eq!(h.state().states[&Pane::Styles].float_rect.map(|r| [r[2], r[3]]), Some([270.0, 480.0])); +} + +#[test] +fn escape_and_focus_loss_restore_a_floating_pane_during_a_move() { + let mut app = App::new(&[Pane::Styles]); + let start = [300.0, 100.0, 270.0, 400.0]; + app.states.insert(Pane::Styles, PaneState { place: Place::Floating, size: None, float_rect: Some(start) }); + let mut h = harness(app); + let from = title(&h, Pane::Styles).center(); + let float_rect = |h: &Harness<'_, App>| h.state().states[&Pane::Styles].float_rect; + + hold(&mut h, from, from + vec2(40.0, 30.0)); + assert_eq!(float_rect(&h), Some([340.0, 130.0, 270.0, 400.0]), "follows the pointer"); + key(&mut h, Key::Escape, Modifiers::NONE); + assert_eq!(float_rect(&h), Some(start)); + send(&mut h, vec![Event::PointerMoved(pos2(790.0, 300.0))]); + send(&mut h, vec![button(pos2(790.0, 300.0), false)]); + h.run_steps(1); + assert_eq!(h.state().place(Pane::Styles), Place::Floating, "the rest of the drag is ignored"); + assert_eq!(float_rect(&h), Some(start)); + + hold(&mut h, from, from + vec2(-60.0, 20.0)); + assert_eq!(float_rect(&h), Some([240.0, 120.0, 270.0, 400.0])); + h.input_mut().focused = false; + h.run_steps(1); + h.input_mut().focused = true; + send(&mut h, vec![button(from + vec2(-60.0, 20.0), false)]); + assert_eq!(float_rect(&h), Some(start)); +} + +#[test] +fn f6_cycles_through_the_panes_and_back_to_the_document() { + let mut h = harness(App::new(&[Pane::Nav, Pane::Comments])); + let focused_widget = |h: &Harness<'_, App>| h.ctx.memory(|m| m.focused()); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Nav)); + assert_eq!(focused_widget(&h), Some(pane_id(Pane::Nav).with("title"))); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!(h.state().focused, Some(Pane::Comments)); + key(&mut h, Key::F6, Modifiers::NONE); + assert_eq!((h.state().focused, h.state().to_document), (None, 1)); + assert_eq!(focused_widget(&h), None); + key(&mut h, Key::F6, Modifiers::SHIFT); + assert_eq!(h.state().focused, Some(Pane::Comments), "Shift+F6 from the document goes to the last pane"); + let focus: Vec<_> = h.state().log.iter().filter(|r| matches!(r, PaneRequest::Focus(_))).cloned().collect(); + assert_eq!(focus, vec![PaneRequest::Focus(Pane::Nav), PaneRequest::Focus(Pane::Comments), PaneRequest::Focus(Pane::Comments)]); + + // A click in the document clears it; a click in a pane sets it. + click(&mut h, pos2(400.0, 300.0)); + assert_eq!(h.state().focused, None); + click(&mut h, pos2(100.0, 300.0)); + assert_eq!(h.state().focused, Some(Pane::Nav)); +} + +#[test] +fn one_slot_shows_the_last_pane_per_edge_beside_context_panes() { + let mut app = App::new(&[Pane::Pivot, Pane::Comments, Pane::Styles]); + app.policy = SidePolicy::OneSlot; + app.states.insert(Pane::Styles, PaneState::docked(Edge::End, 300.0)); + let mut h = harness(app); + assert!(rect(&h, pane_id(Pane::Comments).with("title")).is_none()); + assert_eq!(edge(&h, Pane::Pivot), R - 250.0, "the context pane stays, outermost"); + assert_eq!(edge(&h, Pane::Styles), R - 550.0); + assert!(rect(&h, pane_id(Pane::Pivot).with("title").with("close")).is_none()); + + let at = rect(&h, pane_id(Pane::Styles).with("title").with("close")).unwrap().center(); + click(&mut h, at); + h.run_steps(1); + assert_eq!(h.state().open, vec![Pane::Pivot, Pane::Comments]); + assert_eq!(edge(&h, Pane::Comments), R - 500.0, "the slot shows the remaining pane"); +} + +#[test] +fn rtl_mirrors_sides_and_edge_snaps() { + let mut app = App::new(&[Pane::Nav, Pane::Comments]); + app.rtl = true; + let mut h = harness(app); + assert_eq!(edge(&h, Pane::Nav), R - 250.0, "Start is the right edge"); + assert_eq!(edge(&h, Pane::Comments), L + 250.0); + + let from = title(&h, Pane::Nav).center(); + drag(&mut h, from, pos2(10.0, 300.0)); + assert_eq!(changes(h.state()), vec![PaneRequest::Place(Pane::Nav, Place::Docked(Edge::End))], "the left edge is End"); + // Both on End, outermost first in open order. + assert_eq!(edge(&h, Pane::Nav), L + 250.0); + assert_eq!(edge(&h, Pane::Comments), L + 500.0); +} + +#[test] +fn float_rect_is_finite_and_inside_the_bounds() { + let bounds = Rect::from_min_size(pos2(0.0, 100.0), vec2(800.0, 500.0)); + let min = vec2(200.0, 160.0); + assert_eq!(float_rect(Some([300.0, 150.0, 280.0, 300.0]), bounds, min), Rect::from_min_size(pos2(300.0, 150.0), vec2(280.0, 300.0))); + for junk in [[f32::NAN, 0.0, 1.0, -3.0], [5000.0, -5000.0, 1.0e9, 1.0e9], [f32::NEG_INFINITY, f32::INFINITY, f32::NAN, 0.0]] { + let r = float_rect(Some(junk), bounds, min); + assert!(r.is_finite() && bounds.contains_rect(r) && r.width() >= 200.0, "{junk:?} -> {r:?}"); + } + assert_eq!(float_rect(None, bounds, min).size(), FLOAT_DEFAULT.min(bounds.size())); +} + +#[cfg(feature = "serde")] +#[test] +fn pane_state_round_trips_through_json() { + let state = PaneState { place: Place::Docked(Edge::End), size: Some(280.0), float_rect: Some([1.0, 2.0, 270.0, 480.0]) }; + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), state); +} From a09aa016e3ab3e5f6ff7b35e7835cbc5e0bfe651 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:36:18 +0000 Subject: [PATCH 023/160] panes: docs with adoption steps for WordCraft, DeckCraft, GridCraft --- docs/ui-panes.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/ui-panes.md diff --git a/docs/ui-panes.md b/docs/ui-panes.md new file mode 100644 index 00000000..b88a7d17 --- /dev/null +++ b/docs/ui-panes.md @@ -0,0 +1,106 @@ +# Task panes + +`craft_ui::panes` draws task panes around an app's own document: Navigation, Styles, Comments, +Format Shape and the like. Each pane is an egui side or bottom panel, or a floating frame. The +document stays the app's `CentralPanel`. + +## Panes or docking + +| Use | When | +|---|---| +| `panes` | Office-style apps (WordCraft, DeckCraft, GridCraft). A few panes at the sides of a document the app draws itself; the app's commands own which panes are open. | +| `docking` | Creative apps whose whole window is panels: tab groups, splits, stacked groups, workspaces, a protected canvas panel. See [ui-docking.md](ui-docking.md). | +| plain egui panels | Fixed view parts: slide thumbnails, notes, formula bar, sheet tabs, status bar. | +| `egui::Window` | Free tool windows that never dock (Read Aloud, Power Query). | + +`panes` needs no docking types; it uses only the docking chrome (`FloatingFrame`, `TitleBar`, +`live_move`). + +## API + +```rust +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneRequest, PaneState, Place, SidePolicy}; + +impl PaneContent for MyPanes<'_> { + fn title(&self, pane: &Pane) -> String { tr(pane.title()) } + fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { /* the pane's contents */ } + fn default_state(&self, pane: &Pane) -> PaneState { PaneState::docked(Edge::End, 250.0) } + // Optional: size_range (200..=520), movable, closable, menu (app items in the ▾ menu). +} + +// Every frame, after the top and bottom bars and before the CentralPanel: +let out = PaneHost::new("task-panes") + .rtl(ui_is_rtl) + .policy(SidePolicy::Columns) + .show(ui, &open_panes, |pane| prefs.pane_state(pane), &mut MyPanes { app }); +for request in out.requests { + match request { + PaneRequest::Close(pane) => app.run(pane.close_command()), + PaneRequest::Place(pane, place) => prefs.set_place(pane, place), + PaneRequest::Resize(pane, size) => prefs.set_size(pane, size), + PaneRequest::FloatRect(pane, rect) => prefs.set_float_rect(pane, rect), + PaneRequest::Focus(_) => {} + } +} +egui::CentralPanel::default().show(ui, |ui| document(ui)); +``` + +| Piece | Behaviour | +|---|---| +| `Edge::{Start, End, Bottom}` | Logical edges. `Start` is left, or right with `.rtl(true)`. | +| `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. | +| `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. | +| `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). | +| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. | +| Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. | +| Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it. Rects are clamped to the host area. | +| Focus | `out.focused` is the pane holding keyboard focus or last clicked; a click or focus in the document clears it. F6 and Shift+F6 cycle through the shown panes and then report `out.to_document`: give focus to the document. `.f6(false)` leaves F6 to the app, which calls `panes::focus_next(ctx, id, reverse)` instead. | + +State: + +- The host never stores which panes are open and never writes files. +- `states` returns the app's saved `PaneState` (place, size, float rect; serde with the `serde` + feature). Return `None` to let the host keep the state in egui memory for the session, as + egui panels keep widths today. +- Apply `Resize` and `FloatRect` as they come: they arrive every frame of a gesture, and an + Escape sends the start rect back. +- `panes::float_rect` brings a saved rect back inside the window, whatever the file says. + +## Adoption + +### WordCraft (`Columns`) + +- Add a `Pane` enum for the nine panes and implement `PaneContent` by calling the existing + bodies; drop their `header()` calls, since the host draws the title and ×. +- Build `open` each frame from the `ViewState` flags, in today's order: SmartArt Text Pane (only + while SmartArt is selected), Navigation and Clipboard at `Start`; Styles, Style Inspector, + Accessibility, Alt Text, Restrict Editing and Comments at `End`. Default widths as today. +- `Close(p)` runs the pane's existing command (`view.navigationPane {value: false}`, …). Command + ids and `nav_pane` in `ui.json` stay as they are. +- Styles takes #2584's behaviour through the host: map `StylesPaneDock::{Left, Right, Floating}` + to `Place` (`Left` is `Docked(Start)` in left-to-right interfaces), run `view.stylesPaneDock` + on `Place`, keep the float rect in UI prefs. #2584's own drag, snap and `float_rect` code goes. +- Other panes return `None` from `states`; their widths stay session-only, as now. +- Pane title bars are focusable. Check that Tab in the canvas still stays in the document, so + panes stay off its keyboard focus. Use `.f6(false)` if F6 is bound elsewhere. + +### DeckCraft (`OneSlot`) + +- `open` is `ui.pane` (one entry) at `End`; `states` returns `PaneState::docked(Edge::End, + pane_width)`; `Resize` writes `pane_width`; `Close` clears `ui.pane` as the × does today. + `size_range` is 240..=520. The `ui.json` fields stay. +- Format, Animation and Selection return `movable = true`; the rest `false`. Saving their place + needs new optional `ui.json` fields (`#[serde(default)]`). +- Thumbnails, outline, notes and the #835 canvas shell stay plain panels. +- F6 from the host closes the ui-parity F6 gap. + +### GridCraft (`OneSlot`, `.max_fraction(0.5)`) + +- `open` is the task slot pane (`grid.pane`, or "comments" while `session.comments_pane`) plus + PivotTable Fields while the active cell is in a pivot. PivotTable Fields returns + `closable = false` and `movable = false`, so it stays beside the slot. +- `Close("comments")` runs `review.showComments {on: false}`; agents still drive it through the + engine. +- No pane prefs today: return `None` from `states`. +- Code Editor (#1193) and Queries & Connections (#1203) become slot panes. The formula bar, + sheet tabs and status bar stay plain panels. From 418344c5adb97df9db45766294beea63d4c70300 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:38:00 +0000 Subject: [PATCH 024/160] docking: keyed label hook, closed panels from Workspace::apply, borrowed Floating::contains DockStyle::localized translates every *_label field through one keyed function. Workspace::apply and apply_all return the panels they closed. Floating::contains takes any borrowed form of the panel id. Found in the DesignCraft trial --- crates/ui/src/docking/labels.rs | 82 ++++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 1 + crates/ui/src/docking/model.rs | 8 ++- crates/ui/src/docking/model_tests.rs | 4 +- crates/ui/src/docking/workspace.rs | 20 ++++--- 5 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 crates/ui/src/docking/labels.rs diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs new file mode 100644 index 00000000..cfc82607 --- /dev/null +++ b/crates/ui/src/docking/labels.rs @@ -0,0 +1,82 @@ +//! One keyed hook for every label in [`DockStyle`]. + +use super::DockStyle; + +impl DockStyle { + /// Replace every `*_label` field with `translate(key)`, keeping the English default where it + /// returns `None`. An application translates the docking chrome in one place: + /// + /// ``` + /// fn dock_style(ui: &egui::Ui) -> craft_ui::docking::DockStyle { + /// craft_ui::docking::DockStyle::from_ui(ui).localized(|key| match key { + /// "dock.close" => Some("Fermer le panneau".to_owned()), + /// _ => None, + /// }) + /// } + /// ``` + /// + /// | Key | Field | English default | + /// |---|---|---| + /// | `dock.float` | `float_label` | Float panel | + /// | `dock.close` | `close_label` | Close panel | + /// | `dock.move` | `move_label` | Move to group | + /// | `dock.panels` | `panels_label` | Panels | + /// | `dock.resize` | `resize_label` | Resize panels | + /// | `dock.resizeWindow` | `resize_window_label` | Resize panel window | + /// | `dock.moveGroup` | `move_group_label` | Move tab group | + /// | `dock.closeGroup` | `close_group_label` | Close Tab Group | + /// | `dock.minimize` | `minimize_label` | Minimize | + /// | `dock.expand` | `expand_label` | Expand | + /// | `dock.floatGroup` | `float_group_label` | Float Tab Group | + /// | `dock.groupWith` | `group_with_label` | Group with | + /// | `dock.moveColumn` | `move_column_label` | Move panel column | + /// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons | + /// | `dock.expandPanels` | `expand_panels_label` | Expand Panels | + /// | `dock.moveWindow` | `move_window_label` | Move panel window | + /// | `dock.closeWindow` | `close_window_label` | Close panel window | + /// | `dock.undock` | `undock_label` | Undock Panel | + /// | `dock.undockGroup` | `undock_group_label` | Undock Panel Group | + /// | `dock.closePanelGroup` | `close_panel_group_label` | Close Panel Group | + /// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group | + /// | `dock.maximize` | `maximize_label` | Maximize Frame | + /// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size | + /// | `dock.newWindow` | `new_window_label` | Move Panel to New Window | + #[must_use] + pub fn localized(mut self, translate: impl Fn(&str) -> Option) -> Self { + for (key, label) in self.labels_mut() { + if let Some(text) = translate(key) { + *label = text; + } + } + self + } + + fn labels_mut(&mut self) -> [(&'static str, &mut String); 24] { + [ + ("dock.float", &mut self.float_label), + ("dock.close", &mut self.close_label), + ("dock.move", &mut self.move_label), + ("dock.panels", &mut self.panels_label), + ("dock.resize", &mut self.resize_label), + ("dock.resizeWindow", &mut self.resize_window_label), + ("dock.moveGroup", &mut self.move_group_label), + ("dock.closeGroup", &mut self.close_group_label), + ("dock.minimize", &mut self.minimize_label), + ("dock.expand", &mut self.expand_label), + ("dock.floatGroup", &mut self.float_group_label), + ("dock.groupWith", &mut self.group_with_label), + ("dock.moveColumn", &mut self.move_column_label), + ("dock.collapseToIcons", &mut self.collapse_to_icons_label), + ("dock.expandPanels", &mut self.expand_panels_label), + ("dock.moveWindow", &mut self.move_window_label), + ("dock.closeWindow", &mut self.close_window_label), + ("dock.undock", &mut self.undock_label), + ("dock.undockGroup", &mut self.undock_group_label), + ("dock.closePanelGroup", &mut self.close_panel_group_label), + ("dock.closeOthers", &mut self.close_others_label), + ("dock.maximize", &mut self.maximize_label), + ("dock.restoreFrame", &mut self.restore_frame_label), + ("dock.newWindow", &mut self.new_window_label), + ] + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 6ddeebf6..f2e2a4a1 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -7,6 +7,7 @@ pub mod chrome; mod dock_model; +mod labels; mod model; mod render; mod resize; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 6800e565..3d2da9ba 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -77,11 +77,13 @@ impl

Floating

{ self.groups.iter().flat_map(|group| group.panels.iter()) } - pub fn contains(&self, panel: &P) -> bool + /// True when a group of this window holds `panel`. Takes any borrowed form of `P`, such + /// as `&str` for `String` panels. + pub fn contains(&self, panel: &Q) -> bool where - P: PartialEq, + P: std::borrow::Borrow, { - self.groups.iter().any(|group| group.panels.contains(panel)) + self.panels().any(|p| p.borrow() == panel) } } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 88ca72f6..1309aa71 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -167,7 +167,7 @@ fn window_docks_as_stacked_groups_or_merges_as_tabs() { fn close_group_and_close_window_record_a_restorable_location_per_member() { let mut workspace = Workspace::new(docked()); let allow = |_: &u32| Permissions::default(); - workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(); + assert_eq!(workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(), [2, 3]); assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [2, 3]); assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![4]]); workspace.set_visible(2, true, None).unwrap(); @@ -175,7 +175,7 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![2, 3], vec![4]]); assert_eq!(workspace.layout.closed_by(&Action::CloseWindow { panel: 7 }).unwrap(), [6, 7, 8]); - workspace.apply(Action::CloseWindow { panel: 7 }, allow).unwrap(); + assert_eq!(workspace.apply(Action::CloseWindow { panel: 7 }, allow).unwrap(), [6, 7, 8]); assert!(workspace.layout.floating.is_empty()); assert_eq!(workspace.hidden.len(), 3); for panel in [6, 7, 8] { diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 90fe8d6e..81052e56 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -80,18 +80,19 @@ impl Workspace

{ } /// Apply one action. Closed panels record where they go back, so each one can be reopened - /// with [`Self::set_visible`]. Honours [`Self::locked`]. [`Layout::closed_by`], called - /// before applying, names the panels a close action removes. - pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// with [`Self::set_visible`]. Honours [`Self::locked`]. Returns the panels the action + /// closed, in group order, so the application can release their state. + pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.apply_all([action], permissions) } - /// Apply every action in order, or none. - pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// Apply every action in order, or none. Returns the panels the actions closed, in order. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.validate()?; let locked = self.locked; let policy = |panel: &P| if locked { permissions(panel).locked() } else { permissions(panel) }; let mut proposed = self.clone(); + let mut all_closed = Vec::new(); for action in actions { let closed = proposed.layout.closed_by(&action)?; let locations = close_locations(&proposed.layout, &closed); @@ -100,12 +101,15 @@ impl Workspace

{ proposed.hidden.retain(|(p, _)| p != &panel); proposed.hidden.push((panel, location)); } + all_closed.extend(closed); } let visible: HashSet

= proposed.layout.panels().into_iter().cloned().collect(); proposed.hidden.retain(|(p, _)| !visible.contains(p)); + let mut seen = HashSet::new(); + all_closed.retain(|p| !visible.contains(p) && seen.insert(p.clone())); proposed.validate()?; *self = proposed; - Ok(()) + Ok(all_closed) } /// Show or hide one panel. Showing restores its recorded location, following the chain of @@ -117,10 +121,10 @@ impl Workspace

{ if !self.layout.contains(&panel) { return Ok(()); } - return self.apply(Action::Close { panel }, |_| Permissions::default()); + return self.apply(Action::Close { panel }, |_| Permissions::default()).map(drop); } if self.layout.contains(&panel) { - return self.apply(Action::Activate { panel }, |_| Permissions::default()); + return self.apply(Action::Activate { panel }, |_| Permissions::default()).map(drop); } let mut layout = self.layout.clone(); if self.restore_into(&mut layout, &panel).is_err() { From 3893c590b055b7b4b92ec665c97b9840b455e7e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:38:00 +0000 Subject: [PATCH 025/160] docking: group float size, elided tab labels, filled ghost, drag bar grip, tab focus and double-click fixes A floated group or column keeps its width and takes the tallest member's floating size, else the docked height capped at 320 pt. Tab labels from pre-laid-out galleys are cut with an ellipsis and drawn left-aligned inside the tab, active tabs included. The group drag ghost has a translucent fill, a 2 pt outline and the members' labels. The drag bar draws a grip mark at rest. A click that expands a minimized group does not start a double-click that minimizes it again. A tab focused by the pointer shows no focus outline; keyboard focus still does. Found in the DesignCraft trial --- crates/ui/src/docking/dock_tests.rs | 8 +- crates/ui/src/docking/group_tests.rs | 3 + crates/ui/src/docking/render/group.rs | 79 ++++++++-- crates/ui/src/docking/render/preview.rs | 59 +++++-- crates/ui/src/docking/surface.rs | 2 +- crates/ui/src/docking/trial_tests.rs | 201 ++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 30 deletions(-) create mode 100644 crates/ui/src/docking/trial_tests.rs diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index b719d3a7..b924429f 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -339,10 +339,12 @@ fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() { assert_eq!(d.rect("column-bar", 2).left(), 240.0 + 18.0); let grab = d.rect("column-bar", 2).center(); d.hold(grab, pos2(560.0, 300.0)); - let ghost = source.translate(pos2(560.0, 300.0) - grab); - let ghost = egui::Rect::from_min_size(ghost.min.min(pos2(800.0, 600.0) - source.size()).max(Pos2::ZERO), source.size()); + // The column's width; a docked column floats at most 320 pt tall. + let size = vec2(source.width(), 320.0); + let ghost = egui::Rect::from_min_size(pos2(560.0, 300.0) - (grab - source.min), size); + let ghost = egui::Rect::from_min_size(ghost.min.min(pos2(800.0, 600.0) - size).max(Pos2::ZERO), size); assert!( - d.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == ghost && r.fill == egui::Color32::TRANSPARENT)), + d.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect == ghost && r.fill != egui::Color32::TRANSPARENT)), "a ghost of the column follows the pointer" ); let out = d.step(vec![button(pos2(560.0, 300.0), false)]); diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 673b2133..f9801054 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -6,6 +6,9 @@ use egui::{Color32, Context, Event, Id, Modifiers, PointerButton, RawInput, Rect const AREA: &str = "group-area"; +#[path = "trial_tests.rs"] +mod trial; + fn tabs(panels: &[u32], active: usize) -> Node { Node::Tabs { panels: panels.to_vec(), active } } diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 08243920..4ba0c0b8 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -1,6 +1,6 @@ -use std::{fmt::Debug, hash::Hash}; +use std::{fmt::Debug, hash::Hash, sync::Arc}; -use egui::{Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; +use egui::{Color32, Galley, Id, Key, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; use super::{Renderer, clamp_float, stripless}; use crate::docking::{Action, DropTarget, GroupDrag, GroupGesture, Placement, Target, begin_drag, surface::TAB_PADDING}; @@ -147,7 +147,18 @@ impl Renderer<'_, P> { if owner.kind != GroupKind::Frame && response.double_clicked() { self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); } - if response.has_focus() || response.hovered() { + let active = response.has_focus() || response.hovered(); + if movable { + // A grip mark at rest, centred in the bar's reserved width beside the header. + let grip = Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max); + let stroke = Stroke::new(1.0, if active { self.style.text } else { self.style.inactive_text }); + let half = (hit.height() * 0.2).min(4.0); + for k in -1..=1 { + let x = grip.center().x + k as f32 * 3.0; + ui.painter().with_clip_rect(hit).line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], stroke); + } + } + if active { ui.painter().with_clip_rect(hit).rect_stroke( hit.shrink(1.0), 0.0, @@ -200,7 +211,8 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); } if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { - let rect = clamp_float(owner.rect.translate(vec2(24.0, 24.0)), ui.ctx().content_rect()); + let size = self.group_float_size(owner.panels, owner.rect.size()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, @@ -266,41 +278,76 @@ impl Renderer<'_, P> { response } + /// A tab's label, cut at the end with '…' to fit `width`. egui does not wrap a host's + /// pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job. + pub(super) fn tab_label(&mut self, ui: &Ui, panel: &P, label: &str, color: Color32, width: f32) -> Arc { + let galley = self.content.label(ui, panel, label, &self.style.font, color).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + width, + self.style.font.clone(), + ); + if galley.size().x <= width + 0.5 { + return galley; + } + let mut job = (*galley.job).clone(); + job.wrap = egui::text::TextWrapping::truncate_at_width(width); + ui.painter().layout_job(job) + } + + /// True while a tab's focus came from the pointer, which shows no focus outline. Tab or an + /// arrow key (keyboard navigation) clears it. + fn pointer_focus(&self, ui: &Ui, tab: Id) -> bool { + let key = tab.with("pointer-focus"); + let navigated = ui.input(|i| [Key::Tab, Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|k| i.key_pressed(*k))); + if navigated { + ui.ctx().data_mut(|d| d.remove_temp::(key)); + } + ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(false) + } + pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) { let minimized = owner.filter(|owner| owner.kind != GroupKind::Frame).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); let color = if active { self.style.text } else { self.style.inactive_text }; - // A tab squeezed below its natural width cuts its label with '…' inside the padding its - // natural width was measured with, instead of letting it run under its neighbours. - let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - (rect.width() - TAB_PADDING).max(0.0), - self.style.font.clone(), - ); + let galley = self.tab_label(ui, panel, &label, color, (rect.width() - TAB_PADDING).max(0.0)); let elided = galley.elided; - let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active) + let tab_id = self.id.with(("tab", panel)); + let pointer_focus = self.pointer_focus(ui, tab_id); + let response = tabs::Tab::new(tab_id, &label, active) .sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() }) - .focus_stroke(Stroke::new(1.0, self.style.accent)) + .focus_stroke(if pointer_focus { Stroke::NONE } else { Stroke::new(1.0, self.style.accent) }) .show_at(ui, rect, |ui, response| { let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background }); - painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color); + let at = pos2(rect.left() + TAB_PADDING * 0.5, rect.center().y - galley.size().y * 0.5); + painter.galley(at, galley.clone(), color); }); let response = if elided { response.on_hover_text(&label) } else { response }; self.register_interaction(ui.ctx(), response.id); + if response.is_pointer_button_down_on() || response.clicked() { + ui.ctx().data_mut(|d| d.insert_temp(tab_id.with("pointer-focus"), true)); + } + // The click that expands a minimized group is not the first half of a double-click. + let expand_key = self.id.with(("expanded-by-click", owner.map(|owner| owner.panels.first()))); if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); self.output.focused = Some(panel.clone()); if minimized == Some(true) && !active { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + let now = ui.input(|i| i.time); + ui.ctx().data_mut(|d| d.insert_temp(expand_key, now)); } } if let Some(minimized) = minimized && response.double_clicked() { - self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); + let (now, window) = (ui.input(|i| i.time), ui.ctx().options(|o| o.input_options.max_double_click_delay)); + let expanded_by_first_click = ui.ctx().data_mut(|d| d.remove_temp::(expand_key)).is_some_and(|t| now - t <= window); + if !expanded_by_first_click { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); + } } if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 6d982c88..b770016c 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,10 +1,11 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Vec2, vec2}; +use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use super::{Renderer, clamp_float, dock::group_action}; use crate::docking::{ Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, + surface::TAB_PADDING, }; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. @@ -16,7 +17,8 @@ fn float_only(ctx: &Context) -> bool { const DOCKED_FLOAT_HEIGHT: f32 = 320.0; impl Renderer<'_, P> { - pub(in crate::docking) fn finish_group_drag(&mut self, ctx: &Context) { + pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { + let ctx = ui.ctx(); let key = self.id.with("group-drag"); let Some(drag) = ctx.data(|d| d.get_temp::>(key)) else { return }; if !drag.members.iter().all(|p| self.layout.contains(p) && self.permissions(p).movable) { @@ -26,7 +28,7 @@ impl Renderer<'_, P> { let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); let pointer = pointer.filter(|p| p.is_finite()); - let ghost = pointer.map(|pointer| clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect())); + let ghost = pointer.map(|pointer| self.group_ghost(ctx, &drag, pointer)); if !escape && focused && (down || released) @@ -63,7 +65,7 @@ impl Renderer<'_, P> { } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); - self.paint_group_drag(ctx, &drag, pointer, float_only); + self.paint_group_drag(ui, &drag, pointer, float_only); } } @@ -121,20 +123,35 @@ impl Renderer<'_, P> { } // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); - let rect = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); + let rect = self.group_ghost(ctx, drag, pointer); let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); } } - /// Ghost outline at the pointer (a window moving live is its own ghost) and one - /// destination preview. - fn paint_group_drag(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + /// The ghost at the pointer (a window moving live is its own ghost) under one destination + /// preview. The ghost has a translucent fill, a 2 pt outline and the members' labels. + fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let ctx = ui.ctx(); let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { - let ghost = clamp_float(drag.source.translate(pointer - drag.origin), ctx.content_rect()); - painter.rect_stroke(ghost, 0.0, Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)), StrokeKind::Inside); + let ghost = self.group_ghost(ctx, drag, pointer); + painter.rect(ghost, 0.0, self.style.accent.gamma_multiply(0.15), Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + let strip = Rect::from_min_size(ghost.min, vec2(ghost.width(), self.style.tab_height.min(ghost.height()))); + let labels = painter.with_clip_rect(strip.shrink(2.0)); + let mut x = strip.left() + TAB_PADDING * 0.5; + for panel in &drag.members { + let width = strip.right() - TAB_PADDING * 0.5 - x; + if width <= 0.0 { + break; + } + let label = self.label_of(panel); + let galley = self.tab_label(ui, panel, &label, self.style.text, width); + let advance = galley.size().x + TAB_PADDING; + labels.galley(pos2(x, strip.center().y - galley.size().y * 0.5), galley, self.style.text); + x += advance; + } } if float_only { return; @@ -278,6 +295,28 @@ impl Renderer<'_, P> { size.max(limits.min + header).min(limits.max + header) } + /// Size of a group or column floated by a drag or Float Tab Group: the source width, and the + /// tallest member's floating size, else the source height capped like a panel leaving a + /// dock column. A group that already floats keeps its size. + pub(super) fn group_float_size(&self, members: &[P], source: Vec2) -> Vec2 { + let place = members.first().and_then(|panel| self.layout.place(panel)); + if matches!(place, Some(Place::Floating { .. }) | None) { + return source; + } + let valid = |s: &Vec2| s.is_finite() && s.x > 0.0 && s.y > 0.0; + let host = members.iter().filter_map(|panel| self.content.floating_size(panel).filter(valid)).map(|s| s.y).reduce(f32::max); + let docked = matches!(place, Some(Place::Dock { .. })); + vec2(source.x, host.unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + } + + /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab + /// offset, inside the window (Z2). + fn group_ghost(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Rect { + let size = if drag.gesture == GroupGesture::Window { drag.source.size() } else { self.group_float_size(&drag.members, drag.source.size()) }; + let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size); + clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + } + fn tab_float_rect(&self, ctx: &Context, drag: &Drag

, pointer: Pos2) -> Rect { let size = self.float_size(&drag.panel, drag.source.size()); let grab = drag.grab.min(vec2(size.x, self.style.tab_height)); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 141f6f5d..2b149150 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -626,7 +626,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { ui.allocate_rect(rect, Sense::hover()); } renderer.floating(ui.ctx(), &view.floating); - renderer.finish_group_drag(ui.ctx()); + renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs new file mode 100644 index 00000000..7bea2a42 --- /dev/null +++ b/crates/ui/src/docking/trial_tests.rs @@ -0,0 +1,201 @@ +//! Fixes from the DesignCraft trial: group float size, tab label elision, the drag ghost and +//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups. + +use super::*; + +/// Every shape, with nested `Shape::Vec`s flattened. +fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec<(Rect, egui::Shape)> { + fn walk(clip: Rect, shape: &egui::Shape, out: &mut Vec<(Rect, egui::Shape)>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(clip, s, out)), + shape => out.push((clip, shape.clone())), + } + } + let mut out = Vec::new(); + for clipped in shapes { + walk(clipped.clip_rect, &clipped.shape, &mut out); + } + out +} + +fn texts(d: &Driver) -> Vec<(Rect, egui::epaint::TextShape)> { + flat(&d.shapes) + .into_iter() + .filter_map(|(clip, shape)| match shape { + egui::Shape::Text(text) => Some((clip, text)), + _ => None, + }) + .collect() +} + +fn rects(d: &Driver) -> Vec { + flat(&d.shapes) + .into_iter() + .filter_map(|(_, shape)| match shape { + egui::Shape::Rect(rect) => Some(rect), + _ => None, + }) + .collect() +} + +fn last_char(galley: &egui::Galley) -> Option { + galley.rows.last().and_then(|row| row.glyphs.last()).map(|glyph| glyph.chr) +} + +#[test] +fn a_full_height_docked_group_floats_at_the_host_or_capped_height_inside_the_window() { + for size in [None, Some(vec2(200.0, 150.0))] { + let mut layout = docked(); + let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; + **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; + let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(420.0, 300.0)); + let Some(Target::Float { rect: Some([x, y, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + match size { + Some(size) => assert_eq!(*height, size.y), + None => assert!(*height <= 320.0, "{height}"), + } + assert!(*width > 100.0 && *width <= 240.0, "{width}"); + let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + assert!(window.contains_rect(Rect::from_min_size(pos2(*x, *y), vec2(*width, *height))), "{x} {y} {width} {height}"); + } +} + +/// Labels as a host's pre-laid-out galleys, which egui does not wrap again. +struct GalleyLabels; + +impl DockContent for GalleyLabels { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + fn logical_label(&self, panel: &u32) -> String { + format!("Character Styles {panel}") + } + fn label(&mut self, ui: &egui::Ui, _: &u32, logical: &str, font: &egui::FontId, color: Color32) -> egui::WidgetText { + egui::WidgetText::Galley(ui.painter().layout_no_wrap(logical.to_owned(), font.clone(), color)) + } +} + +#[test] +fn shrunk_tabs_end_their_labels_with_an_ellipsis_inside_the_tab_active_or_not() { + let panels = [1, 2, 3, 4, 5, 6]; + let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() }; + let ctx = Context::default(); + let mut content = GalleyLabels; + let mut shapes = Vec::new(); + for _ in 0..2 { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + let style = DockStyle::from_ui(ui); + ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 400.0))), |ui| { + DockArea::new(Id::new(AREA)).show_content(ui, &layout, &style, &mut content); + }); + }); + full.textures_delta.clear(); + shapes = full.shapes; + } + let texts: Vec<_> = flat(&shapes) + .into_iter() + .filter_map(|(clip, shape)| match shape { + egui::Shape::Text(text) => Some((clip, text)), + _ => None, + }) + .collect(); + for panel in panels { + let tab = ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect; + let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { + panic!("no label in tab {panel}") + }; + assert_eq!(last_char(&text.galley), Some('…'), "tab {panel}"); + let drawn = Rect::from_min_size(text.pos, text.galley.size()); + assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); + assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); + } +} + +#[test] +fn drag_bar_shows_a_grip_at_rest_and_a_group_drag_paints_a_filled_labelled_ghost() { + let mut d = Driver::new(frames()); + let visuals = d.ctx.global_style().visuals.clone(); + let bar = d.rect("drag-bar", 2); + let grip = flat(&d.shapes).into_iter().any(|(_, shape)| match shape { + egui::Shape::LineSegment { points, stroke } => { + bar.contains(points[0]) && bar.contains(points[1]) && stroke.color == visuals.weak_text_color() + } + _ => false, + }); + assert!(grip, "no grip mark in the drag bar at rest"); + + d.hold(bar.center(), pos2(150.0, 300.0)); + let fill = visuals.selection.stroke.color.gamma_multiply(0.15); + let Some(ghost) = rects(&d).into_iter().find(|r| r.fill == fill && r.stroke.width == 2.0 && r.rect.contains(pos2(150.0, 300.0))) else { + panic!("no filled ghost under the pointer") + }; + for label in ["Panel 2", "Panel 3"] { + assert!(texts(&d).iter().any(|(_, t)| t.galley.text() == label && ghost.rect.contains(t.pos)), "{label} missing from the ghost"); + } + d.step(vec![key(egui::Key::Escape)]); +} + +#[test] +fn double_click_on_a_tab_of_a_minimized_group_ends_expanded() { + let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; + let mut d = Driver::new(layout); + let minimized = |d: &Driver| d.layout.floating[0].groups[0].minimized; + let active = d.tab(6).center(); + d.click(active); + d.click(active); + assert!(minimized(&d)); + d.wait(); + + let other = d.tab(5).center(); + d.click(other); + d.click(other); + assert!(!minimized(&d)); + assert_eq!(d.layout.floating[0].groups[0].active_panel(), Some(&5)); +} + +#[test] +fn a_clicked_tab_shows_no_focus_outline_but_tab_key_focus_does() { + let mut d = Driver::new(frames()); + let accent = d.ctx.global_style().visuals.selection.stroke.color; + let id = Id::new(AREA).with(("tab", &3u32)); + let outline = d.tab(3).shrink(2.0); + let outlined = |d: &Driver| rects(d).iter().any(|r| r.rect == outline && r.stroke.width > 0.0 && r.stroke.color == accent); + + d.click(d.tab(3).center()); + // A platform may focus the clicked widget, as an AccessKit focus request does. + d.ctx.memory_mut(|m| m.request_focus(id)); + d.step(vec![]); + assert_eq!(d.ctx.memory(|m| m.focused()), Some(id)); + assert!(!outlined(&d)); + + d.ctx.memory_mut(|m| m.surrender_focus(id)); + for _ in 0..20 { + if d.ctx.memory(|m| m.focused()) == Some(id) { + break; + } + d.step(vec![key(egui::Key::Tab)]); + } + assert_eq!(d.ctx.memory(|m| m.focused()), Some(id)); + d.step(vec![]); + assert!(outlined(&d)); +} + +#[test] +fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { + let mut debug = String::new(); + let mut full = Context::default().run_ui(RawInput::default(), |ui| { + debug = format!("{:?}", DockStyle::from_ui(ui).localized(|key| Some(key.to_owned()))); + }); + full.textures_delta.clear(); + let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); + assert_eq!(values.len(), 24, "{debug}"); + assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); + let unique: std::collections::HashSet<_> = values.iter().collect(); + assert_eq!(unique.len(), values.len()); + + let window = Floating::single(vec!["swatches".to_owned()], 0, [0.0, 0.0, 200.0, 200.0]); + assert!(window.contains("swatches")); + assert!(!window.contains("layers")); +} From 0c140eb65b4f9fdb8ba18ef87764f421daf36b21 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:40:26 +0000 Subject: [PATCH 026/160] docking: iconic columns with flyouts, hit rects and a test driver An iconic column draws one icon per panel, a grip above each group and a separator between groups (M3). Widened past `DockStyle::iconic_label_width` it shows panel names (M4). Clicking an icon opens its group as a flyout on the canvas side; another icon switches, the same icon closes, and with Auto-Collapse Iconic Panels (default on) Escape or a press outside closes it (M5). Dragging a flyout's strip floats its group (M6), an icon drags its panel (S7) and the grip its group (S8). A drop into an iconic column makes a new icon group, previewed by the column outline and an insertion line (P9). `raise_panel` on an iconic panel opens its flyout (U3), and the column menu has the Auto-Collapse toggle as `DockEvent::ToggleAutoCollapse`. Iconic widths move to `DockStyle` (`icon_size`, `iconic_width`, `iconic_max_width`). `DockArea::hit_rects` names every gesture source the area drew last frame, and `docking::testing::DockDriver` (feature `testing`) drives an area with real egui input for application tests. Behaviour follows vectorcraft crates/ui-egui/src/dock.rs (icon column, flyout placement, names when widened) and photocraft dock.rs (auto-collapse). --- crates/ui/Cargo.toml | 2 + crates/ui/src/docking/hits.rs | 124 +++++++++ crates/ui/src/docking/iconic_tests.rs | 324 +++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 7 + crates/ui/src/docking/render/dock.rs | 35 ++- crates/ui/src/docking/render/iconic.rs | 328 ++++++++++++++++++++++++ crates/ui/src/docking/render/mod.rs | 9 +- crates/ui/src/docking/render/preview.rs | 2 +- crates/ui/src/docking/surface.rs | 36 ++- crates/ui/src/docking/testing.rs | 222 ++++++++++++++++ 10 files changed, 1073 insertions(+), 16 deletions(-) create mode 100644 crates/ui/src/docking/hits.rs create mode 100644 crates/ui/src/docking/iconic_tests.rs create mode 100644 crates/ui/src/docking/render/iconic.rs create mode 100644 crates/ui/src/docking/testing.rs diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index dd85edd4..b5fc3bbd 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -18,6 +18,8 @@ serde = { version = "1", features = ["derive"], optional = true } serde = ["dep:serde"] # Floating windows may move to their own OS windows (desktop egui backends with viewports). native-windows = [] +# `docking::testing`: a headless driver for application docking tests. +testing = [] [dev-dependencies] egui = { workspace = true, features = ["default_fonts"] } diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs new file mode 100644 index 00000000..26c25d91 --- /dev/null +++ b/crates/ui/src/docking/hits.rs @@ -0,0 +1,124 @@ +//! Where each docking gesture starts, for control protocols and tests. + +use std::{collections::HashMap, fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Rect}; + +use super::{DockArea, Layout, Node, render::RESIZE_HANDLES}; + +/// A rendered docking control that starts a gesture. Groups, columns and windows are named by +/// their first panel. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum Hit

{ + /// A tab: click activates, drag moves the panel (S1). + Tab(P), + /// A group's drag bar: click opens the group menu, drag moves the group (S2). + DragBar(P), + /// The top border of a group's strip: drag moves the group (S3). + Border(P), + /// The "…" button of a group whose tabs overflow; named by the active panel. + Overflow(P), + /// [D] A column bar: drag moves the column (S6). + ColumnBar(P), + /// [D] The »/« button of a column bar (M2). + Collapse(P), + /// An accordion entry's −/+ button. + StackToggle(P), + /// A floating window's title bar (S5). + Title(P), + /// A floating window's × button. + Close(P), + /// [D] A panel's icon in an iconic column (S7, M5). + Icon(P), + /// [D] The grip above an icon group (S8). + IconGrip(P), + /// A floating window's resize handle. + Resize(P, ResizeEdge), + Divider(Divider

), +} + +/// The edge or corner a floating window's resize handle moves. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ResizeEdge { + Left, + Right, + Bottom, + BottomLeft, + BottomRight, +} + +/// A divider that resizes what is on either side of it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum Divider

{ + /// A split, by its path from the root (see [`super::Action::ResizeSplit`]). + Split(Vec), + /// Below the accordion entry of this panel. + Stack(P), + /// [D] The inner edge of the column holding this panel first. + Column(P), + /// Below the dock or floating group holding this panel first. + Group(P), +} + +fn key(area: Id) -> Id { + area.with("hit-rects") +} + +impl DockArea<'_, P> { + /// Every gesture source the area drew in its last frame, with its rect in points. Hosts + /// return these from a `ui.docking` control method beside the layout and `focused`; tests + /// drag from their centres. + pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit

, Rect)> { + ctx.data(|d| d.get_temp::, Rect)>>(key(id))).unwrap_or_default() + } +} + +/// Name the controls the renderer registered this frame and store their rects. +pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { + let mut names: HashMap> = HashMap::new(); + for panel in layout.panels() { + let p = || panel.clone(); + let some = Some(panel); + names.insert(area.with(("tab", panel)), Hit::Tab(p())); + names.insert(area.with(("icon", panel)), Hit::Icon(p())); + names.insert(area.with(("overflow", panel)), Hit::Overflow(p())); + names.insert(area.with(("collapse", panel)), Hit::StackToggle(p())); + names.insert(area.with(("stack-resize", panel)), Hit::Divider(Divider::Stack(p()))); + names.insert(area.with(("drag-bar", some)), Hit::DragBar(p())); + names.insert(area.with(("drag-border", some)), Hit::Border(p())); + names.insert(area.with(("icon-grip", some)), Hit::IconGrip(p())); + names.insert(area.with(("column-bar", some)), Hit::ColumnBar(p())); + names.insert(area.with(("column-collapse", some)), Hit::Collapse(p())); + names.insert(area.with(("column-resize", some)), Hit::Divider(Divider::Column(p()))); + names.insert(area.with(("group-resize", some)), Hit::Divider(Divider::Group(p()))); + names.insert(area.with(("floating-divider", some)), Hit::Divider(Divider::Group(p()))); + let title = area.with(("move-group", some)); + names.insert(title, Hit::Title(p())); + names.insert(title.with("close"), Hit::Close(p())); + let window = area.with(("floating", panel)); + let edges = [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft]; + for (name, edge) in RESIZE_HANDLES.iter().zip(edges) { + names.insert(window.with(*name), Hit::Resize(p(), edge)); + } + } + let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); + while let Some((node, path)) = nodes.pop() { + if let Node::Split { first, second, .. } = node { + names.insert(area.with(("split", &path)), Hit::Divider(Divider::Split(path.clone()))); + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + nodes.push((second, second_path)); + nodes.push((first, first_path)); + } + } + let hits: Vec<(Hit

, Rect)> = + owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect(); + ctx.data_mut(|d| d.insert_temp(key(area), hits)); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs new file mode 100644 index 00000000..67e722d2 --- /dev/null +++ b/crates/ui/src/docking/iconic_tests.rs @@ -0,0 +1,324 @@ +//! [D] Iconic columns, flyouts and hit rects, driven through `testing::DockDriver`. + +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Key, Pos2, Rect, pos2, vec2}; + +struct Content; + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + } +} + +type Driver = DockDriver; + +/// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0. +fn docked(groups: &[&[u32]], iconic: bool) -> Layout { + let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0); + column.iconic = iconic; + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }), + floating: Vec::new(), + } +} + +fn first_column(layout: &Layout) -> Column { + match &layout.root { + Some(Node::Split { first, .. }) => match first.as_ref() { + Node::Dock { columns, .. } => columns.first().cloned().unwrap(), + other => panic!("no dock: {other:?}"), + }, + other => panic!("no split: {other:?}"), + } +} + +fn groups(layout: &Layout) -> Vec> { + first_column(layout).groups.iter().map(|g| g.panels.clone()).collect() +} + +fn hit(d: &Driver, hit: Hit) -> Rect { + d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())) +} + +fn open(d: &Driver) -> Option { + flyout::(&d.ctx, d.id) +} + +fn texts(d: &Driver) -> Vec { + d.shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => Some(text.galley.text().to_owned()), + _ => None, + }) + .collect() +} + +const CANVAS: Pos2 = pos2(600.0, 400.0); + +#[test] +fn collapsed_column_shows_one_icon_per_panel_with_group_separators() { + let d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3))); + let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3))); + assert_eq!(one.width(), 36.0, "the column takes the style's iconic width"); + assert_eq!((grip1.bottom(), one.bottom()), (one.top(), two.top()), "a group's icons follow its grip and each other"); + assert_eq!(grip3.top() - two.bottom(), 7.0, "groups are apart by the separator"); + assert_eq!(three.top(), grip3.bottom()); + assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs"); +} + +#[test] +fn widening_an_iconic_column_shows_labels() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + assert!(!texts(&d).iter().any(|t| t == "Panel 1")); + let edge = hit(&d, Hit::Divider(Divider::Column(1))); + let out = d.drag(edge.center(), pos2(150.0, edge.center().y)); + assert!(out.error.is_none()); + let width = first_column(&d.layout).iconic_width.unwrap(); + assert!((100.0..=160.0).contains(&width), "{width}"); + d.settle(); + let labels = texts(&d); + assert!(["Panel 1", "Panel 2", "Panel 3"].iter().all(|l| labels.iter().any(|t| t == l)), "{labels:?}"); + let edge = hit(&d, Hit::Divider(Divider::Column(1))); + d.drag(edge.center(), pos2(20.0, edge.center().y)); + d.settle(); + assert_eq!(first_column(&d.layout).iconic_width, Some(36.0)); + assert!(!texts(&d).iter().any(|t| t == "Panel 1")); +} + +#[test] +fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let icon = hit(&d, Hit::Icon(2)).center(); + let out = d.click(icon); + assert!(out.actions.contains(&Action::Activate { panel: 2 })); + d.settle(); + assert_eq!(open(&d), Some(2)); + let column = hit(&d, Hit::Icon(1)); + let tab = hit(&d, Hit::Tab(2)); + assert!(tab.left() >= column.right(), "the flyout opens on the canvas side: {tab:?}"); + assert!(tab.top() <= hit(&d, Hit::IconGrip(1)).bottom(), "beside its group"); + assert_eq!(first_column(&d.layout).groups.first().map(|g| g.active), Some(1)); + + // Another icon switches flyouts, the same icon closes. + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + assert_eq!(open(&d), Some(3)); + assert!(d.hit(&Hit::Tab(2)).is_none()); + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + assert_eq!(open(&d), None); + + // Escape and a press outside close it. + d.click(icon); + d.settle(); + d.key(Key::Escape); + d.settle(); + assert_eq!(open(&d), None); + d.click(icon); + d.settle(); + let inside = hit(&d, Hit::Tab(2)).center(); + d.click(inside); + d.settle(); + assert_eq!(open(&d), Some(2), "a press inside keeps it"); + d.click(CANVAS); + d.settle(); + assert_eq!(open(&d), None); + + // Auto-Collapse off: it stays until an icon closes it. + d.auto_collapse = false; + d.click(icon); + d.settle(); + d.key(Key::Escape); + d.click(CANVAS); + d.settle(); + assert_eq!(open(&d), Some(2)); + d.click(icon); + d.settle(); + assert_eq!(open(&d), None); + + // U3: raising an iconic panel, as the Window menu does, opens its flyout. + d.raise = Some(3); + d.settle(); + assert_eq!(open(&d), Some(3)); +} + +#[test] +fn auto_collapse_toggle_is_a_column_menu_event() { + use egui_kittest::{Harness, kittest::Queryable}; + let layout = docked(&[&[1, 2]], true); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, events: &mut Vec>| { + let out = DockArea::new(egui::Id::new("menu")).show_content(ui, &layout, &DockStyle::from_ui(ui), &mut Content); + events.extend(out.events); + }, + Vec::new(), + ); + h.run_steps(2); + let bar = h.ctx.read_response(egui::Id::new("menu").with(("column-bar", Some(&1u32)))).unwrap().rect.center(); + h.input_mut().events.extend([ + egui::Event::PointerMoved(bar), + egui::Event::PointerButton { pos: bar, button: egui::PointerButton::Secondary, pressed: true, modifiers: egui::Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerButton { + pos: bar, + button: egui::PointerButton::Secondary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(2); + h.get_by_label("Auto-Collapse Iconic Panels").click(); + h.run_steps(2); + assert_eq!(h.state(), &vec![DockEvent::ToggleAutoCollapse]); +} + +#[test] +fn dragging_a_flyout_strip_floats_its_group() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + d.click(hit(&d, Hit::Icon(1)).center()); + d.settle(); + let bar = hit(&d, Hit::DragBar(1)); + let out = d.drag(bar.center(), CANVAS); + assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions); + assert_eq!(groups(&d.layout), vec![vec![3]]); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::>()), Some(vec![1, 2])); + d.settle(); + assert_eq!(open(&d), None, "the flyout closes with its group gone"); + + // A tab dragged out of a flyout floats that panel alone. + d.click(hit(&d, Hit::Icon(3)).center()); + d.settle(); + let tab = hit(&d, Hit::Tab(3)); + let out = d.drag(tab.center(), pos2(450.0, 200.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 3, .. }]), "{:?}", out.actions); +} + +#[test] +fn icon_and_icon_group_grip_drags_move_a_panel_and_a_group() { + let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); + let before = d.layout.clone(); + // Escape, focus loss and cancel_drag end an icon drag with no change. + let icon = hit(&d, Hit::Icon(2)).center(); + d.hold(icon, CANVAS); + d.key(Key::Escape); + d.release(CANVAS); + d.hold(icon, CANVAS); + d.focus(false); + d.focus(true); + d.release(CANVAS); + d.hold(icon, CANVAS); + cancel_drag::(&d.ctx, d.id); + d.release(CANVAS); + assert_eq!(d.layout, before); + + let out = d.drag(icon, CANVAS); + let Some(Action::Float { panel: 2, rect }) = out.actions.first() else { panic!("{:?}", out.actions) }; + assert_eq!((rect[2], rect[3]), (240.0, 320.0), "a panel floated from its icon takes the column's expanded width"); + assert_eq!(groups(&d.layout), vec![vec![1], vec![3]]); + + let grip = hit(&d, Hit::IconGrip(3)).center(); + let out = d.drag(grip, pos2(450.0, 250.0)); + assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 3, to: Target::Float { .. } }]), "{:?}", out.actions); + assert_eq!(groups(&d.layout), vec![vec![1]]); +} + +#[test] +fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() { + let mut layout = docked(&[&[1], &[2]], true); + layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0])); + let mut d = DockDriver::new(layout, Content); + let bar = hit(&d, Hit::DragBar(5)).center(); + let one = hit(&d, Hit::Icon(1)); + let target = pos2(one.center().x, one.bottom() - 2.0); + d.hold(bar, target); + let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0)); + assert!(d.outlined(column.shrink(1.0)), "the column is outlined"); + let line = Rect::from_x_y_ranges(column.x_range(), (one.bottom() - 2.0)..=(one.bottom() + 2.0)); + assert!(d.filled(line), "with the insertion line under group 1"); + let out = d.release(target); + assert!( + out.actions.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true } } }), + "{:?}", + out.actions + ); + assert_eq!(groups(&d.layout), vec![vec![1], vec![5, 6], vec![2]]); + assert!(first_column(&d.layout).iconic); +} + +/// Every kind of gesture source has a rect, and dragging from it does something. +#[test] +fn hit_rects_cover_every_gesture_source() { + let mut expanded = Column::new(vec![Group::new(vec![1, 2, 3, 4, 5, 6, 7], 0), Group::new(vec![8], 0)], 150.0); + expanded.width = 150.0; + let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0); + icons.iconic = true; + let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] }; + let stack = Node::Stack { + entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }], + }; + let frames = Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.5), + first: Box::new(Node::Tabs { panels: vec![0], active: 0 }), + second: Box::new(stack), + }; + let mut window = Floating::single(vec![12], 0, [500.0, 60.0, 220.0, 300.0]); + window.groups.push(Group::new(vec![13], 0)); + let layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }), + floating: vec![window], + }; + let d = DockDriver::new(layout.clone(), Content); + let hits = d.hits(); + let kinds = |f: &dyn Fn(&Hit) -> bool| hits.iter().filter(|(h, _)| f(h)).count(); + let expected: [(&str, &dyn Fn(&Hit) -> bool); 15] = [ + ("tab", &|h| matches!(h, Hit::Tab(_))), + ("drag bar", &|h| matches!(h, Hit::DragBar(_))), + ("border", &|h| matches!(h, Hit::Border(_))), + ("overflow", &|h| matches!(h, Hit::Overflow(_))), + ("column bar", &|h| matches!(h, Hit::ColumnBar(_))), + ("collapse", &|h| matches!(h, Hit::Collapse(_))), + ("stack toggle", &|h| matches!(h, Hit::StackToggle(_))), + ("title", &|h| matches!(h, Hit::Title(12))), + ("close", &|h| matches!(h, Hit::Close(12))), + ("icon", &|h| matches!(h, Hit::Icon(9))), + ("icon grip", &|h| matches!(h, Hit::IconGrip(9))), + ("resize", &|h| matches!(h, Hit::Resize(12, _))), + ("split", &|h| matches!(h, Hit::Divider(Divider::Split(_)))), + ("column divider", &|h| matches!(h, Hit::Divider(Divider::Column(_)))), + ("group divider", &|h| matches!(h, Hit::Divider(Divider::Group(_)))), + ]; + for (name, kind) in expected { + assert!(kinds(kind) > 0, "{name}: {hits:?}"); + } + assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10))))); + assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 5); + for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) { + let mut d = DockDriver::new(layout.clone(), Content); + let from = rect.center(); + let moved = d.hold(from, from + vec2(-30.0, 40.0)); + let dragging = is_dragging::(&d.ctx, d.id); + let released = d.release(from + vec2(-30.0, 40.0)); + assert!(dragging || !moved.actions.is_empty() || !released.actions.is_empty(), "{hit:?} at {rect:?} does nothing"); + } + for (hit, rect) in hits.iter().filter(|(h, _)| matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) { + let mut d = DockDriver::new(layout.clone(), Content); + let out = d.click(rect.center()); + let opened = egui::Popup::is_any_open(&d.ctx); + assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing"); + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 6ddeebf6..85c76545 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -7,13 +7,18 @@ pub mod chrome; mod dock_model; +mod hits; mod model; mod render; mod resize; mod surface; mod targets; +#[cfg(any(test, feature = "testing"))] +pub mod testing; mod workspace; +pub use hits::{Divider, Hit, ResizeEdge}; +pub use render::{close_flyout, flyout, open_flyout}; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use surface::{ @@ -32,6 +37,8 @@ mod floating_tests; #[cfg(test)] mod group_tests; #[cfg(test)] +mod iconic_tests; +#[cfg(test)] mod model_tests; #[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 3d0f7e1a..fe5d4728 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,15 +10,11 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer}; use crate::docking::{ - Action, Column, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target, + Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target, targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, }; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; -/// Width of an iconic column without a saved width. -const ICONIC_WIDTH: f32 = 36.0; -/// Widest an iconic column gets by dragging its inner edge. -const ICONIC_MAX: f32 = 240.0; /// Width of the »/« button at the outer end of a column bar. const COLLAPSE_WIDTH: f32 = 18.0; @@ -44,8 +40,12 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side { } } -pub(in crate::docking) fn column_width

(column: &Column

) -> f32 { - if column.iconic { column.iconic_width.unwrap_or(ICONIC_WIDTH) } else { column.width } +pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) -> f32 { + if column.iconic { + column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) + } else { + column.width + } } /// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved @@ -104,7 +104,7 @@ impl Renderer<'_, P> { }; let gap = self.style.gap; let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width()); - let width = columns.iter().map(column_width).sum::().clamp(0.0, room); + let width = columns.iter().map(|c| column_width(c, self.style)).sum::().clamp(0.0, room); let (dock, rest) = match edge_of(side, self.style.right_to_left) { Edge::Left => ( Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), @@ -141,12 +141,12 @@ impl Renderer<'_, P> { if usable(rect) { ui.painter().rect_filled(rect, 0.0, self.style.background); } - let total: f32 = columns.iter().map(column_width).sum(); + let total: f32 = columns.iter().map(|c| column_width(c, self.style)).sum(); let mut x = outer; let mut drawn = Vec::new(); for (index, column) in columns.iter().enumerate() { let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; - let width = column_width(column).min((rect.width() - (x - outer).abs()).max(0.0)); + let width = column_width(column, self.style).min((rect.width() - (x - outer).abs()).max(0.0)); let inner = x + inward * width; let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); self.column(ui, column, first, column_rect, edge); @@ -161,8 +161,8 @@ impl Renderer<'_, P> { } // Registered after the columns, so the narrow hit areas win at the edges. for (column, first, width, inner) in drawn { - let others = total - column_width(column); - let (lo, hi) = if column.iconic { (ICONIC_WIDTH, ICONIC_MAX) } else { self.column_bounds(column) }; + let others = total - column_width(column, self.style); + let (lo, hi) = if column.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(column) }; let hi = hi.min(room - others).max(lo.min(room - others).max(0.0)); let lo = lo.min(hi); let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range()); @@ -216,6 +216,9 @@ impl Renderer<'_, P> { let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); self.column_bar(ui, column, first, bar, rect, edge); let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); + if column.iconic && usable(body) { + self.iconic_column(ui, column, body, edge); + } if column.iconic || !usable(body) { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); return; @@ -404,6 +407,9 @@ impl Renderer<'_, P> { if self.menu_action(ui, &label) { self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); } + if self.auto_collapse_item(ui) { + self.output.events.push(DockEvent::ToggleAutoCollapse); + } }); } @@ -411,6 +417,7 @@ impl Renderer<'_, P> { /// covers `pointer`. Covered dock targets take nothing. fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool { self.blockers.iter().any(|r| r.contains(pointer)) + || self.iconic.covers(pointer) || self.layout.floating.iter().any(|window| { let Some(first) = window.panels().next() else { return false }; !moving.is_some_and(|p| window.contains(p)) @@ -486,7 +493,9 @@ impl Renderer<'_, P> { /// The insertion line of a dock target. pub(super) fn paint_dock_line(&self, ctx: &Context, line: Rect) { if usable(line) { - self.preview_painter(ctx).rect_filled(line, 1.0, self.style.accent); + let painter = self.preview_painter(ctx); + self.iconic.outline(&painter, line, self.style); + painter.rect_filled(line, 1.0, self.style.accent); } } } diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs new file mode 100644 index 00000000..738f0657 --- /dev/null +++ b/crates/ui/src/docking/render/iconic.rs @@ -0,0 +1,328 @@ +//! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens. +//! +//! Behaviour follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side, +//! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels). + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Id, Key, Order, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; + +use super::{GroupKind, Renderer, dock::Edge}; +use crate::docking::{ + Column, DockStyle, Group, GroupDrag, GroupGesture, begin_drag, + chrome::{ChromeStyle, FloatingFrame}, + is_dragging, + targets::{DockDrop, DockTarget, horizontal_line, usable}, +}; + +/// Height of the grip above each icon group. +const GRIP: f32 = 6.0; +/// Room between two icon groups, with the separator line in its middle. +const SEPARATOR: f32 = 7.0; +/// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it. +const FLYOUT_HEIGHT: f32 = 320.0; + +/// The panel whose icon opened the flyout of an area. +#[derive(Clone)] +struct Open

(P); + +fn key(area: Id) -> Id { + area.with("flyout") +} + +/// [D] Open the flyout of the group holding `panel`, when that group is in an iconic column. +/// The flyout shows from the next frame on. [`crate::docking::DockArea::raise_panel`] does the +/// same for a panel the Window menu opens. +pub fn open_flyout(ctx: &Context, area_id: Id, panel: P) { + ctx.data_mut(|d| d.insert_temp(key(area_id), Open(panel))); +} + +/// [D] Close the area's flyout. +pub fn close_flyout(ctx: &Context, area_id: Id) { + ctx.data_mut(|d| d.remove::>(key(area_id))); +} + +/// [D] The panel whose icon opened the area's flyout, while one is open. +pub fn flyout(ctx: &Context, area_id: Id) -> Option

{ + ctx.data(|d| d.get_temp::>(key(area_id))).map(|open| open.0) +} + +/// The flyout an iconic column asks for this frame. +struct Request

{ + group: Group

, + /// The icons of the group, where the flyout's top aligns. + icons: Rect, + /// The iconic column, which a press does not close the flyout in. + column: Rect, + edge: Edge, + /// The column's expanded width. + width: f32, +} + +/// Per-frame iconic state of a renderer. +pub(in crate::docking) struct Iconic

{ + auto_collapse: bool, + /// Iconic column bodies drawn this frame, outlined by a drop into them (P9). + columns: Vec, + /// Expanded column width of each panel drawn as an icon: a panel floated from its icon + /// takes it. + widths: Vec<(P, f32)>, + request: Option>, + /// The flyout drawn this frame; it occludes dock targets behind it. + rect: Option, +} + +impl Iconic

{ + pub(in crate::docking) fn new(auto_collapse: bool) -> Self { + Self { auto_collapse, columns: Vec::new(), widths: Vec::new(), request: None, rect: None } + } + + pub(super) fn covers(&self, pointer: Pos2) -> bool { + self.rect.is_some_and(|rect| rect.contains(pointer)) + } + + /// Outline the iconic column an insertion line lies in. + pub(super) fn outline(&self, painter: &egui::Painter, line: Rect, style: &DockStyle) { + if let Some(column) = self.columns.iter().find(|column| column.shrink(1.0).contains(line.center())) { + painter.rect_stroke(column.shrink(1.0), 0.0, Stroke::new(2.0, style.accent), StrokeKind::Inside); + } + } + + pub(super) fn float_width(&self, panel: &P) -> Option { + self.widths.iter().find(|(p, _)| p == panel).map(|(_, width)| *width) + } +} + +impl Renderer<'_, P> { + fn row_height(&self) -> f32 { + self.style.icon_size + 10.0 + } + + fn icon_group_height(&self, group: &Group

) -> f32 { + GRIP + group.panels.len() as f32 * self.row_height() + } + + /// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit. + pub(super) fn iconic_column(&mut self, ui: &mut Ui, column: &Column

, body: Rect, edge: Edge) { + self.iconic.columns.push(body); + for panel in column.groups.iter().flat_map(|g| g.panels.iter()) { + self.iconic.widths.push((panel.clone(), column.width)); + } + // Opening a panel from the Window menu opens its flyout (U3). + if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) { + open_flyout(ui.ctx(), self.id, panel.clone()); + } + let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::() + + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; + if total > body.height() + 0.5 { + let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return }; + let mut child = + ui.new_child(UiBuilder::new().id_salt(("icon-column", first)).max_rect(body).layout(egui::Layout::top_down(egui::Align::Min))); + child.set_clip_rect(body.intersect(ui.clip_rect())); + egui::ScrollArea::vertical() + .id_salt(self.id.with(("icon-scroll", first))) + .auto_shrink([false, false]) + .scroll_source(egui::scroll_area::ScrollSource::SCROLL_BAR | egui::scroll_area::ScrollSource::MOUSE_WHEEL) + .show(&mut child, |ui| { + let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total), Sense::hover()); + self.icon_groups(ui, column, area, body, edge); + }); + } else { + self.icon_groups(ui, column, body, body, edge); + } + } + + /// Icon groups in `area` (the scrolled content when the column scrolls), their drop bands + /// clipped to the visible `view`. + fn icon_groups(&mut self, ui: &mut Ui, column: &Column

, area: Rect, view: Rect, edge: Edge) { + let open = flyout::

(ui.ctx(), self.id); + let labels = view.width() >= self.style.iconic_label_width; + let xs = view.x_range(); + let mut top = area.top(); + let mut bands = Vec::new(); + for (index, group) in column.groups.iter().enumerate() { + let Some(first) = group.panels.first() else { continue }; + if index > 0 { + let y = top + SEPARATOR * 0.5; + ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border); + top += SEPARATOR; + } + let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group))); + let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP)); + self.icon_grip(ui, group, grip, column.width); + let showing = open.as_ref().filter(|p| group.panels.contains(p)); + let mut y = grip.bottom(); + for panel in &group.panels { + let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height())); + self.icon(ui, panel, cell, labels, showing); + y = cell.bottom(); + } + // The state again: a click above may have opened or closed this group's flyout. + if flyout::

(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) { + self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width }); + } + bands.push((first.clone(), rect)); + top = rect.bottom(); + } + // P9: the upper half of an icon group inserts above it, the lower half and the room + // under the last group below it. The line sits on the group's edge. + let count = bands.len(); + for (index, (anchor, rect)) in bands.into_iter().enumerate() { + let middle = rect.center().y; + let upper_top = if index == 0 { view.top() } else { rect.top() - SEPARATOR * 0.5 }; + let lower_bottom = if index + 1 == count { view.bottom() } else { rect.bottom() + SEPARATOR * 0.5 }; + for (below, ys, line) in [(false, upper_top..=middle, rect.top()), (true, middle..=lower_bottom, rect.bottom())] { + let band = Rect::from_x_y_ranges(xs, ys).intersect(view); + if usable(band) { + let line = horizontal_line(line.clamp(view.top() + 2.0, view.bottom() - 2.0), xs); + self.dock_targets.push(DockDrop { target: DockTarget::GroupBand { anchor: anchor.clone(), below }, band, line }); + } + } + } + } + + /// The grip above an icon group drags the whole group (S8). + fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, width: f32) { + let Some(first) = group.panels.first() else { return }; + let movable = group.panels.iter().all(|p| self.permissions(p).movable); + let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag()); + let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; + self.register_interaction(ui.ctx(), response.id); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); + let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text }; + let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect())); + painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y - 1.0, Stroke::new(1.0, color)); + painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color)); + if movable && response.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); + let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); + let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); + let drag = GroupDrag { + panel: first.clone(), + original: [source.left(), source.top(), source.width(), source.height()], + gesture: GroupGesture::Group, + members: group.panels.clone(), + origin, + source, + }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("group-drag"), drag)); + close_flyout::

(ui.ctx(), self.id); + } + } + + /// One panel's icon: a click opens, switches or closes the flyout (M5); a drag moves the + /// panel (S7). `showing` is the panel whose icon opened this group's flyout. + fn icon(&mut self, ui: &mut Ui, panel: &P, cell: Rect, labels: bool, showing: Option<&P>) { + let movable = self.permissions(panel).movable; + let label = self.label_of(panel); + let response = ui.interact(cell, self.id.with(("icon", panel)), if movable { Sense::click_and_drag() } else { Sense::click() }); + self.register_interaction(ui.ctx(), response.id); + let current = showing == Some(panel); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), current, &label)); + let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect())); + if current || response.hovered() { + painter.rect_filled(cell.shrink(2.0), 3.0, self.style.active_background); + } + if response.has_focus() { + painter.rect_stroke(cell.shrink(1.0), 3.0, Stroke::new(1.0, self.style.accent), StrokeKind::Inside); + } + let size = self.style.icon_size; + let inset = ((self.style.iconic_width - size) * 0.5).max(2.0); + let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size)); + let color = if current { self.style.text } else { self.style.inactive_text }; + if let Some(source) = self.content.icon(panel) { + egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell)); + } else { + let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect(); + painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside); + painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color); + } + if labels { + let text = self.content.label(ui, panel, &label, &self.style.font, color); + let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone()); + painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color); + } else if response.hovered() && !response.dragged() { + response.clone().on_hover_text(&label); + } + if response.clicked() { + if current { + close_flyout::

(ui.ctx(), self.id); + } else { + if showing.is_none() { + ui.ctx().move_to_top(FloatingFrame::layer(self.id.with("flyout"))); + } + open_flyout(ui.ctx(), self.id, panel.clone()); + self.output.actions.push(crate::docking::Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + if movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), cell) { + close_flyout::

(ui.ctx(), self.id); + } + } + + /// The Auto-Collapse Iconic Panels item of a column menu (U1): true when chosen. + pub(super) fn auto_collapse_item(&mut self, ui: &mut Ui) -> bool { + let logical = self.style.auto_collapse_label.clone(); + let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); + let mut on = self.iconic.auto_collapse; + let response = ui.add(egui::Checkbox::new(&mut on, text)); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), self.iconic.auto_collapse, &logical)); + if response.clicked() { + ui.close(); + } + response.clicked() + } + + /// Draw the flyout an iconic column asked for, beside the column on the canvas side, or + /// forget a flyout whose group is no longer iconic. With Auto-Collapse, Escape or a press + /// outside the flyout and its column closes it. + pub(in crate::docking) fn flyout(&mut self, ctx: &Context) { + let Some(request) = self.iconic.request.take() else { + close_flyout::

(ctx, self.id); + return; + }; + let Some(active) = request.group.active_panel().cloned() else { return }; + let id = self.id.with("flyout"); + if self.iconic.auto_collapse && self.flyout_dismissed(ctx, id, request.column) { + close_flyout::

(ctx, self.id); + return; + } + let screen = ctx.content_rect(); + let bounds = self.workspace.intersect(screen); + let width = request.width.max(self.style.min_pane).min(bounds.width()); + let height = self + .content + .floating_size(&active) + .map(|size| size.y) + .or(request.group.height) + .filter(|h| h.is_finite() && *h > 0.0) + .unwrap_or(FLYOUT_HEIGHT) + .max(self.style.tab_height + self.style.min_pane) + .min(bounds.height()); + let left = match request.edge { + Edge::Left => request.column.right(), + Edge::Right => request.column.left() - width, + }; + let top = request.icons.top().min(bounds.bottom() - height).max(bounds.top()); + let rect = Rect::from_min_size(pos2(left, top), vec2(width, height)); + let style = ChromeStyle::from_dock(self.style); + let group = request.group; + let response = FloatingFrame::new(id, rect).bounds(bounds).resizable(false).show(ctx, &style, |ui, rect| { + self.group(ui, &group.panels, group.active, false, rect, GroupKind::Dock); + }); + self.iconic.rect = Some(response.rect); + } + + /// Escape (outside a drag) or a primary press outside the flyout, its column and any menu. + fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool { + let escape = !is_dragging::

(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + let press = ctx.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten()); + let outside = press.is_some_and(|pos| { + let inside = ctx.memory(|m| m.area_rect(id)).is_some_and(|r| r.contains(pos)) || column.contains(pos); + let menu = ctx.layer_id_at(pos).is_some_and(|layer| matches!(layer.order, Order::Foreground | Order::Tooltip)); + !inside && !menu + }); + escape || outside + } +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b8179c37..613bf689 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -9,6 +9,7 @@ use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node mod dock; mod floating; mod frame; +mod iconic; #[cfg(feature = "native-windows")] mod native; @@ -16,6 +17,9 @@ pub(in crate::docking) use frame::{FrameState, view}; pub(in crate::docking) use group::GroupKind; +pub(in crate::docking) use iconic::Iconic; +pub use iconic::{close_flyout, flyout, open_flyout}; + pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -35,6 +39,8 @@ pub(super) struct Renderer<'a, P> { pub(super) owned_interactions: Vec, pub(super) output: DockOutput

, pub(super) frame: FrameState

, + /// [D] Flyout and iconic column state for this frame. + pub(super) iconic: Iconic

, } impl Renderer<'_, P> { @@ -123,6 +129,7 @@ impl Renderer<'_, P> { self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); + self.register_interaction(ui.ctx(), response.id); response.widget_info(|| { egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), entry.open, self.label_of(&entry.panel)) }); @@ -222,7 +229,7 @@ pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

(node: &Node

, style: &DockStyle, content: &dyn DockContent

) -> PanelLimits { match node { Node::Dock { columns, .. } => { - let width: f32 = columns.iter().map(dock::column_width).sum(); + let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum(); PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } } Node::Tabs { panels, .. } => { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 6d982c88..734be291 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -269,7 +269,7 @@ impl Renderer<'_, P> { if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { return size; } - let mut size = source; + let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { size.y = size.y.min(DOCKED_FLOAT_HEIGHT); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 141f6f5d..8b6a4a77 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -59,6 +59,16 @@ pub struct DockStyle { pub seam_band: f32, /// [D] Width of the band along the docked area's left and right edges that makes a new dock. pub new_dock_band: f32, + /// [D] Side of a panel icon in an iconic column; each icon row is 10 pt taller. + pub icon_size: f32, + /// [D] Width of an iconic column without a saved width, and the narrowest it gets. + pub iconic_width: f32, + /// [D] Widest an iconic column gets by dragging its inner edge. + pub iconic_max_width: f32, + /// [D] An iconic column at least this wide shows panel names beside the icons. + pub iconic_label_width: f32, + /// [D] Column menu item that toggles Auto-Collapse Iconic Panels. + pub auto_collapse_label: String, /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide /// layout direction, so the host sets this from its locale. Frame splits keep their sides. pub right_to_left: bool, @@ -123,6 +133,11 @@ impl DockStyle { column_bar_height: 14.0, seam_band: 8.0, new_dock_band: 12.0, + icon_size: 20.0, + iconic_width: 36.0, + iconic_max_width: 240.0, + iconic_label_width: 96.0, + auto_collapse_label: "Auto-Collapse Iconic Panels".into(), right_to_left: false, move_column_label: "Move panel column".into(), collapse_to_icons_label: "Collapse to Icons".into(), @@ -153,9 +168,14 @@ impl DockStyle { self.column_bar_height, self.seam_band, self.new_dock_band, + self.icon_size, + self.iconic_width, + self.iconic_max_width, + self.iconic_label_width, ] .iter() .all(|v| v.is_finite() && (0.0..=4096.0).contains(v)) + && self.iconic_width <= self.iconic_max_width && self.tab_height > 0.0 && self.font.size > 0.0 } @@ -187,6 +207,9 @@ pub enum DockEvent

{ /// Maximize Frame: maximize the frame holding `panel`, or restore it when it is the one /// passed to [`DockArea::maximized`]. ToggleMaximize { panel: P }, + /// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its + /// preference and passes it to [`DockArea::auto_collapse_iconic`]. + ToggleAutoCollapse, } /// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. @@ -448,11 +471,19 @@ pub struct DockArea<'a, P = ()> { blockers: Vec, maximized: Option

, hide: HidePanels<'a, P>, + auto_collapse: bool, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true } + } + + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press + /// outside it. Off, it stays open until its icon or another icon is clicked. + pub fn auto_collapse_iconic(mut self, on: bool) -> Self { + self.auto_collapse = on; + self } /// Maximize Frame: the docked frame holding `panel` fills the area; other frames and @@ -620,14 +651,17 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { owned_interactions: Vec::new(), output: DockOutput::default(), frame, + iconic: super::render::Iconic::new(self.auto_collapse), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); ui.allocate_rect(rect, Sense::hover()); } + renderer.flyout(ui.ctx()); renderer.floating(ui.ctx(), &view.floating); renderer.finish_group_drag(ui.ctx()); renderer.finish_drag(ui.ctx()); + super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs new file mode 100644 index 00000000..64702470 --- /dev/null +++ b/crates/ui/src/docking/testing.rs @@ -0,0 +1,222 @@ +//! Drive a [`DockArea`] with real egui input in application tests (feature `testing`). +//! +//! ```ignore +//! let mut driver = DockDriver::new(app_layout(), Panels::default()); +//! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); +//! driver.drag(bar.center(), egui::pos2(4.0, 300.0)); +//! assert!(driver.layout.dock_path(Side::Start).is_some()); +//! ``` + +use std::{fmt::Debug, hash::Hash}; + +use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2}; + +use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout}; + +/// Pointer travel that starts a drag before moving to its destination. +const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0); + +type StyleFn = Box; + +/// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances +/// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits, +/// the way an application's command path would. +pub struct DockDriver { + pub ctx: Context, + pub layout: Layout

, + pub content: C, + /// The area's id, for [`DockArea::hit_rects`] and the free docking functions. + pub id: Id, + /// The window, 800x600 by default. + pub screen: Rect, + /// Where the area draws inside the window; the whole window by default. + pub area: Rect, + /// Whether the window has focus. Losing it cancels gestures (C2). + pub focused: bool, + /// Modifiers held for the following events. + pub modifiers: Modifiers, + /// Passed to [`DockArea::auto_collapse_iconic`]. + pub auto_collapse: bool, + /// Passed to [`DockArea::maximized`]. + pub maximized: Option

, + /// Passed to [`DockArea::raise_panel`] on the next frame only. + pub raise: Option

, + style: Option, + shapes: Vec, + time: f64, +} + +impl> DockDriver { + /// A driver whose first frames have settled, so every control has its rect. + pub fn new(layout: Layout

, content: C) -> Self { + let screen = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut driver = Self { + ctx: Context::default(), + layout, + content, + id: Id::new("dock-driver"), + screen, + area: screen, + focused: true, + modifiers: Modifiers::NONE, + auto_collapse: true, + maximized: None, + raise: None, + style: None, + shapes: Vec::new(), + time: 0.0, + }; + driver.settle(); + driver + } + + /// Adjust the style each frame, after [`DockStyle::from_ui`]. Settles again. + pub fn with_style(mut self, style: impl Fn(&mut DockStyle) + 'static) -> Self { + self.style = Some(Box::new(style)); + self.settle(); + self + } + + /// Draw in `area` of the window. Settles again. + pub fn with_area(mut self, area: Rect) -> Self { + self.area = area; + self.settle(); + self + } + + /// Render one frame with `events` and return its output. Nothing is applied. + pub fn frame(&mut self, events: Vec) -> DockOutput

{ + let mut output = DockOutput::default(); + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(self.screen), + events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(), + focused: self.focused, + time: Some(self.time), + ..Default::default() + }; + let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); + let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse); + let mut full = self.ctx.run_ui(input, |ui| { + let mut style = DockStyle::from_ui(ui); + if let Some(adjust) = style_fn { + adjust(&mut style); + } + ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { + let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto); + if let Some(panel) = raise.clone() { + dock = dock.raise_panel(panel); + } + output = dock.show_content(ui, layout, &style, content); + }); + }); + full.textures_delta.clear(); + self.shapes = full.shapes; + output + } + + /// Render one frame and apply its actions, all or none, with the content's permissions. A + /// rejected batch is reported in the output's `error`. + pub fn step(&mut self, events: Vec) -> DockOutput

{ + let mut output = self.frame(events); + if output.error.is_none() + && let Err(error) = self.layout.apply_all(output.actions.clone(), |p| self.content.permissions(p)) + { + output.error = Some(error); + } + output + } + + /// Empty frames until moved widgets report their rects; egui reports them a frame late. + pub fn settle(&mut self) { + for _ in 0..3 { + self.step(Vec::new()); + } + } + + pub fn move_to(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos)]) + } + + pub fn press(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, true)]) + } + + pub fn release(&mut self, pos: Pos2) -> DockOutput

{ + self.step(vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// Press and release at `pos`; the output is the release frame's. + pub fn click(&mut self, pos: Pos2) -> DockOutput

{ + self.press(pos); + self.release(pos) + } + + /// Press at `from`, move past the drag threshold and on to `to`, without releasing. + pub fn hold(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.press(from); + self.move_to(from + NUDGE); + self.move_to(to) + } + + /// A whole drag; the output is the release frame's. + pub fn drag(&mut self, from: Pos2, to: Pos2) -> DockOutput

{ + self.hold(from, to); + self.release(to) + } + + /// Press and release `key` in one frame. + pub fn key(&mut self, key: Key) -> DockOutput

{ + let modifiers = self.modifiers; + self.step(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]) + } + + /// Give or take the window's focus for this and the following frames. + pub fn focus(&mut self, focused: bool) -> DockOutput

{ + self.focused = focused; + self.step(vec![Event::WindowFocused(focused)]) + } + + /// The pointer leaves the window. + pub fn leave(&mut self) -> DockOutput

{ + self.step(vec![Event::PointerGone]) + } + + /// Every gesture source of the last frame. + pub fn hits(&self) -> Vec<(Hit

, Rect)> { + DockArea::

::hit_rects(&self.ctx, self.id) + } + + /// The rect of one gesture source in the last frame. + pub fn hit(&self, hit: &Hit

) -> Option { + self.hits().into_iter().find(|(h, _)| h == hit).map(|(_, rect)| rect) + } + + /// The shapes of the last frame. + pub fn shapes(&self) -> &[ClippedShape] { + &self.shapes + } + + /// Whether the last frame filled `rect` (an insertion line or a split preview). + pub fn filled(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill != egui::Color32::TRANSPARENT, + _ => false, + }) + } + + /// Whether the last frame outlined `rect` without filling it. + pub fn outlined(&self, rect: Rect) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect == rect && shape.fill == egui::Color32::TRANSPARENT && shape.stroke.width > 0.0, + _ => false, + }) + } + + fn button(&self, pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers } + } +} From 0d350d8e11797cb8c46e8acf7c92af890e5952a0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 11:59:33 +0000 Subject: [PATCH 027/160] docking: DockCommand for the shared command ids and the ui.dock operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DockCommand

deserializes a control command's params, with its id under "command", and maps it to layout actions applied all or none. It covers the window.panel.* and window.workspace.* ids, the raw {action} form, and the ui.dock {operation, …} shape of LightCraft, PDFCraft, CADCraft and SoundCraft (open, close, activate, float, moveFloating, move, setStackOpen, resizeStack, resizeSplit), so their parsers can go without a protocol change. Named workspaces and Reset stay with the application. tests/docking_workflows.rs loads every v1 fixture, docks groups by their bars into a new dock, a bottom band and a seam, collapses and expands the column, floats it by command, drags it back, and checks the layout reads back as written. --- crates/ui/src/docking/command.rs | 476 +++++++++++++++++++++++++ crates/ui/src/docking/command_tests.rs | 267 ++++++++++++++ crates/ui/src/docking/mod.rs | 6 + crates/ui/tests/docking_workflows.rs | 183 ++++++++++ 4 files changed, 932 insertions(+) create mode 100644 crates/ui/src/docking/command.rs create mode 100644 crates/ui/src/docking/command_tests.rs diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs new file mode 100644 index 00000000..7b7b7d20 --- /dev/null +++ b/crates/ui/src/docking/command.rs @@ -0,0 +1,476 @@ +//! The shared docking command vocabulary (feature `serde`). +//! +//! Applications deserialize a control command's JSON params, with its id under `"command"`, +//! into [`DockCommand`] and apply it. craft-ui does not depend on `serde_json`. +//! +//! ```ignore +//! let mut params = params.clone(); +//! params["command"] = json!(id); +//! let command: DockCommand = serde_json::from_value(params)?; +//! command.apply(&mut app.workspace, |p| app.permissions(p))?; +//! ``` + +use std::{collections::HashSet, hash::Hash}; + +use serde::{Deserialize, Deserializer, Serialize}; + +use super::{Action, DockError, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place}; +use crate::layout::SplitSize; + +/// Where a panel floats when a command gives no position or size. +const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; + +/// A docking command, tagged by its control id under `"command"`. +/// +/// Ids and params (`?` marks optional ones): +/// +/// | Id | Params | +/// |---|---| +/// | `window.panel.layout` | `{action}`: a serialized [`Action`] | +/// | `window.panel.move` | `{panel, anchor, zone?, before?, group?, placement?}` | +/// | `window.panel.float` | `{panel, x?, y?, width?, height?, group?, column?}` | +/// | `window.panel.dock` | `{panel, anchor?, zone?, before?, group?, column?, side?}` | +/// | `window.panel.activate`, `.close`, `.closeGroup`, `.closeOthers` | `{panel}` | +/// | `window.panel.minimize` | `{panel, minimized?}`; without `minimized` it toggles | +/// | `window.panel.iconic` | `{panel, iconic?}`; without `iconic` it toggles | +/// | `window.panel.window` | `{panel, native?}` (desktop) | +/// | `window.workspace`, `.new`, `.delete` | `{name}` | +/// | `window.workspace.reset`, `ui.dock.reset` | `{}` | +/// | `window.workspace.lock` | `{locked?}`; without `locked` it toggles | +/// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] | +/// +/// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`, +/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). `before` +/// names the tab to insert before; `null` appends. `group` moves the panel's whole group, +/// `column` its dock column. `side` is `Start` or `End`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "command", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))] +#[non_exhaustive] +pub enum DockCommand

{ + #[serde(rename = "window.panel.layout")] + Layout { action: Action

}, + #[serde(rename = "window.panel.move")] + Move { + panel: P, + anchor: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + zone: Option, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + #[serde(default)] + group: bool, + /// A serialized [`Placement`]; it takes precedence over `zone` and `before`. + #[serde(default, skip_serializing_if = "Option::is_none")] + placement: Option>, + }, + #[serde(rename = "window.panel.float")] + Float { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + x: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + y: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + height: Option, + #[serde(default)] + group: bool, + #[serde(default)] + column: bool, + }, + /// To `anchor`; else to a new column of the dock on `side` (a new dock when it has none); + /// else a floating panel joins the first dock group (or frame) as a tab, and a docked one + /// stays. A hidden panel without `anchor` and `side` is shown at its last location. + #[serde(rename = "window.panel.dock")] + Dock { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + anchor: Option

, + #[serde(default, skip_serializing_if = "Option::is_none")] + zone: Option, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + #[serde(default)] + group: bool, + #[serde(default)] + column: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + side: Option, + }, + /// Select a panel; a hidden one is shown at its last location first. + #[serde(rename = "window.panel.activate")] + Activate { panel: P }, + #[serde(rename = "window.panel.close")] + Close { panel: P }, + #[serde(rename = "window.panel.closeGroup")] + CloseGroup { panel: P }, + #[serde(rename = "window.panel.closeOthers")] + CloseOthers { panel: P }, + #[serde(rename = "window.panel.minimize")] + Minimize { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + minimized: Option, + }, + #[serde(rename = "window.panel.iconic")] + Iconic { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + iconic: Option, + }, + /// Move the panel's floating window to its own OS window (`native`, the default) or back. + /// A docked panel's group floats first. + #[serde(rename = "window.panel.window")] + Window { + panel: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + native: Option, + }, + /// Switch to a named workspace. The application owns named workspaces. + #[serde(rename = "window.workspace")] + Workspace { name: String }, + #[serde(rename = "window.workspace.new")] + NewWorkspace { name: String }, + #[serde(rename = "window.workspace.delete")] + DeleteWorkspace { name: String }, + /// Reset the current workspace to its saved or built-in arrangement (application-owned). + #[serde(rename = "window.workspace.reset", alias = "ui.dock.reset")] + ResetWorkspace {}, + #[serde(rename = "window.workspace.lock")] + Lock { + #[serde(default, skip_serializing_if = "Option::is_none")] + locked: Option, + }, + /// The `ui.dock` shape of LightCraft, PDFCraft, CADCraft and SoundCraft. + #[serde(rename = "ui.dock")] + UiDock(UiDock

), +} + +/// `ui.dock` params: a raw action, or an operation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged, bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))] +pub enum UiDock

{ + Action { action: Action

}, + Operation(DockOperation

), +} + +/// A `ui.dock` operation, tagged by `operation`. `move` takes `target` (the anchor), `zone` +/// (default `center`) and `before`, which only applies to a centre move. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "camelCase", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))] +#[non_exhaustive] +pub enum DockOperation

{ + /// Show a panel at its last location and select it. + Open { panel: P }, + Close { panel: P }, + Activate { panel: P }, + Float { panel: P, rect: [f32; 4] }, + MoveFloating { panel: P, rect: [f32; 4] }, + Move { + panel: P, + target: P, + #[serde(default, skip_serializing_if = "Option::is_none")] + zone: Option, + #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] + before: Option>, + }, + SetStackOpen { panel: P, open: bool }, + /// `height` is required; `null` clears it. + ResizeStack { + panel: P, + #[serde(deserialize_with = "required")] + height: Option, + }, + ResizeSplit { path: Vec, size: SplitSize }, +} + +/// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ZoneName { + Tab, + #[serde(alias = "Center")] + Center, + #[serde(alias = "Left")] + Left, + #[serde(alias = "Right")] + Right, + #[serde(alias = "Top")] + Top, + #[serde(alias = "Bottom")] + Bottom, + Above, + Below, +} + +/// A present field, `null` included: `Some(None)` for `null`, `None` when absent. +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result, D::Error> { + T::deserialize(deserializer).map(Some) +} + +/// A required field that may be `null`. +fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> Result { + T::deserialize(deserializer) +} + +/// `zone` and `before` as a placement. `default` is the zone when none is given. +fn placement(zone: Option, before: &Option>, default: ZoneName) -> Result, DockError> { + let before = before.clone(); + Ok(match (zone.unwrap_or(default), before) { + (ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before }, + (ZoneName::Tab, None) => Placement::Tab { before: None }, + (ZoneName::Center, None) => Placement::Split(Zone::Center), + (_, Some(_)) => return Err(DockError::InvalidPlacement), + (ZoneName::Left, None) => Placement::Split(Zone::Left), + (ZoneName::Right, None) => Placement::Split(Zone::Right), + (ZoneName::Top, None) => Placement::Split(Zone::Top), + (ZoneName::Bottom, None) => Placement::Split(Zone::Bottom), + (ZoneName::Above, None) => Placement::Group { below: false }, + (ZoneName::Below, None) => Placement::Group { below: true }, + }) +} + +/// How much of the layout a command moves. +#[derive(Clone, Copy)] +enum Unit { + Panel, + Group, + Column, +} + +impl Unit { + fn of(group: bool, column: bool) -> Self { + if column { + Self::Column + } else if group { + Self::Group + } else { + Self::Panel + } + } + + /// The action that moves this unit of `panel` to `to`. A floating panel's column is its + /// whole window. + fn to(self, layout: &Layout

, panel: &P, to: Target

) -> Result, DockError> { + let floating = matches!(layout.place(panel), Some(Place::Floating { .. })); + let panel = panel.clone(); + Ok(match (self, to) { + (Self::Panel, Target::At { anchor, placement }) => Action::Move { panel, anchor, placement }, + (Self::Panel, _) => return Err(DockError::InvalidPlacement), + (Self::Group, to) => Action::MoveGroup { panel, to }, + (Self::Column, to) if floating => Action::MoveWindow { panel, to }, + (Self::Column, to) => Action::MoveColumn { panel, to }, + }) + } +} + +impl

DockCommand

{ + /// Every command id, for an application's command registry. + pub const IDS: &'static [&'static str] = &[ + "window.panel.layout", + "window.panel.move", + "window.panel.float", + "window.panel.dock", + "window.panel.activate", + "window.panel.close", + "window.panel.closeGroup", + "window.panel.closeOthers", + "window.panel.minimize", + "window.panel.iconic", + "window.panel.window", + "window.workspace", + "window.workspace.new", + "window.workspace.delete", + "window.workspace.reset", + "window.workspace.lock", + "ui.dock", + "ui.dock.reset", + ]; + + /// Named workspaces and Reset belong to the application, which handles these commands + /// before [`Self::apply`]. + pub fn is_app_owned(&self) -> bool { + matches!(self, Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {}) + } +} + +impl DockCommand

{ + /// The layout actions this command makes on `workspace`, to apply all or none. + /// Showing a hidden panel and Lock change more than the layout, and named workspaces + /// belong to the application: those return [`DockError::NotAllowed`]; use [`Self::apply`]. + /// A panel or anchor that is not shown returns [`DockError::MissingPanel`] or + /// [`DockError::MissingTarget`]. + pub fn to_actions(&self, workspace: &Workspace

) -> Result>, DockError> { + let layout = &workspace.layout; + let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) }; + let one = |action: Action

| Ok(vec![action]); + match self { + Self::Layout { action } | Self::UiDock(UiDock::Action { action }) => one(action.clone()), + Self::Move { panel, anchor, zone, before, group, placement: given } => { + shown(panel)?; + let placement = match given { + Some(placement) => placement.clone(), + None => placement(*zone, before, ZoneName::Tab)?, + }; + one(Unit::of(*group, false).to(layout, panel, Target::At { anchor: anchor.clone(), placement })?) + } + Self::Float { panel, x, y, width, height, group, column } => { + shown(panel)?; + let given = [*x, *y, *width, *height]; + let window = layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect); + let rect = |base: [f32; 4]| { + let mut rect = base; + for (value, given) in rect.iter_mut().zip(given) { + *value = given.unwrap_or(*value); + } + rect + }; + let any = given.iter().any(Option::is_some); + match (Unit::of(*group, *column), window) { + (Unit::Panel, Some(base)) if layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1) => { + one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }) + } + (Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(DEFAULT_FLOAT) }), + (Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }), + (unit, _) => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?), + } + } + Self::Dock { panel, anchor, zone, before, group, column, side } => { + shown(panel)?; + let unit = Unit::of(*group, *column); + if let Some(anchor) = anchor { + let placement = placement(*zone, before, ZoneName::Tab)?; + return one(unit.to(layout, panel, Target::At { anchor: anchor.clone(), placement })?); + } + if let Some(side) = side { + let to = match last_column(layout, *side) { + Some(anchor) => Target::At { anchor, placement: Placement::Column { after: true } }, + None => Target::NewDock { side: *side }, + }; + let unit = match (unit, &to) { + (Unit::Panel, Target::NewDock { .. }) if group_len(layout, panel) == Some(1) => Unit::Group, + (unit, _) => unit, + }; + return one(unit.to(layout, panel, to)?); + } + if !matches!(layout.place(panel), Some(Place::Floating { .. })) { + return Ok(Vec::new()); + } + let members = members(layout, panel, unit); + let panels = layout.panels(); + let free = || panels.iter().copied().filter(|p| !members.contains(p)); + let docked = free().find(|p| matches!(layout.place(p), Some(Place::Dock { .. }))); + let framed = || free().find(|p| matches!(layout.place(p), Some(Place::Node(_)))); + let anchor = docked.or_else(framed).cloned().ok_or(DockError::MissingTarget)?; + one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Tab { before: None } })?) + } + Self::Activate { panel } | Self::UiDock(UiDock::Operation(DockOperation::Activate { panel } | DockOperation::Open { panel })) => { + shown(panel)?; + one(Action::Activate { panel: panel.clone() }) + } + Self::Close { panel } | Self::UiDock(UiDock::Operation(DockOperation::Close { panel })) => one(Action::Close { panel: panel.clone() }), + Self::CloseGroup { panel } => one(Action::CloseGroup { panel: panel.clone() }), + Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }), + Self::Minimize { panel, minimized } => { + let place = layout.place(panel).ok_or(DockError::MissingPanel)?; + let current = layout.group(&place).is_some_and(|g| g.minimized); + one(Action::SetMinimized { panel: panel.clone(), minimized: minimized.unwrap_or(!current) }) + } + Self::Iconic { panel, iconic } => { + let location = layout.location(panel)?; + let current = location.group.and_then(|g| g.column).is_some_and(|c| c.iconic); + one(Action::SetIconic { panel: panel.clone(), iconic: iconic.unwrap_or(!current) }) + } + Self::Window { panel, native } => { + shown(panel)?; + if !native.unwrap_or(true) { + return one(Action::SetNative { panel: panel.clone(), rect: None }); + } + let mut actions = Vec::new(); + let mut scratch = layout.clone(); + if !scratch.floating.iter().any(|w| w.contains(panel)) { + let float = Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: None } }; + scratch.apply(float.clone())?; + actions.push(float); + } + let rect = scratch.floating.iter().find(|w| w.contains(panel)).map(|w| w.rect).ok_or(DockError::MissingPanel)?; + actions.push(Action::SetNative { panel: panel.clone(), rect: Some(rect) }); + Ok(actions) + } + Self::UiDock(UiDock::Operation(operation)) => match operation { + DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }), + DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }), + DockOperation::Move { panel, target, zone, before } => { + let placement = placement(*zone, before, ZoneName::Center)?; + one(Action::Move { panel: panel.clone(), anchor: target.clone(), placement }) + } + DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }), + DockOperation::ResizeStack { panel, height } => one(Action::ResizeStack { panel: panel.clone(), height: *height }), + DockOperation::ResizeSplit { path, size } => one(Action::ResizeSplit { path: path.clone(), size: *size }), + DockOperation::Open { .. } | DockOperation::Close { .. } | DockOperation::Activate { .. } => unreachable!("matched above"), + }, + Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {} | Self::Lock { .. } => { + Err(DockError::NotAllowed) + } + } + } + + /// Apply the command to `workspace`, all or nothing, and return the panels it closed so + /// the application can release their state. Showing a hidden panel restores its last + /// location; Lock sets [`Workspace::locked`]. Application-owned commands + /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. + pub fn apply(&self, workspace: &mut Workspace

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + let show = match self { + Self::Activate { panel } + | Self::Dock { panel, anchor: None, side: None, .. } + | Self::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel), + _ => None, + }; + if let Some(panel) = show.filter(|p| !workspace.layout.contains(p)) { + workspace.set_visible(panel.clone(), true, None)?; + return Ok(Vec::new()); + } + if let Self::Lock { locked } = self { + workspace.locked = locked.unwrap_or(!workspace.locked); + return Ok(Vec::new()); + } + let actions = self.to_actions(workspace)?; + let before: Vec

= workspace.layout.panels().into_iter().cloned().collect(); + workspace.apply_all(actions, permissions)?; + let after: HashSet<&P> = workspace.layout.panels().into_iter().collect(); + Ok(before.into_iter().filter(|p| !after.contains(p)).collect()) + } +} + +/// The first panel of the last column of the dock on `side`. +fn last_column(layout: &Layout

, side: Side) -> Option

{ + let path = layout.dock_path(side)?; + let node = layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path))?; + match node { + super::Node::Dock { columns, .. } => columns.last()?.groups.first()?.panels.first().cloned(), + _ => None, + } +} + +fn group_len(layout: &Layout

, panel: &P) -> Option { + layout.place(panel).and_then(|place| layout.group(&place)).map(|g| g.panels.len()) +} + +/// The panels that move with `panel`. +fn members(layout: &Layout

, panel: &P, unit: Unit) -> Vec

{ + match unit { + Unit::Panel => vec![panel.clone()], + Unit::Group => layout.place(panel).and_then(|place| layout.group(&place)).map(|g| g.panels.clone()).unwrap_or_default(), + Unit::Column => match layout.place(panel) { + Some(Place::Dock { path, column, .. }) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path)) { + Some(super::Node::Dock { columns, .. }) => { + columns.get(column).map(|c| c.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()).unwrap_or_default() + } + _ => Vec::new(), + }, + Some(Place::Floating { .. }) => layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default(), + _ => vec![panel.clone()], + }, + } +} diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs new file mode 100644 index 00000000..60ad359d --- /dev/null +++ b/crates/ui/src/docking/command_tests.rs @@ -0,0 +1,267 @@ +//! `DockCommand`: every documented shape parses and maps to atomic layout actions. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; + +use super::*; +use crate::layout::{SplitAxis, SplitSize}; + +type P = String; + +fn s(panel: &str) -> P { + panel.to_owned() +} + +fn permissions(panel: &P) -> Permissions { + if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() } +} + +/// A start dock with one column of `[layers, channels]` and `[history]` beside the canvas, a +/// floating `color`, and `swatches` hidden from history's group. +fn workspace() -> Workspace

{ + let groups = vec![Group::new(vec![s("layers"), s("channels")], 0), Group::new(vec![s("history"), s("swatches")], 0)]; + let dock = Node::Dock { side: Side::Start, columns: vec![Column::new(groups, 240.0)] }; + let canvas = Node::Tabs { panels: vec![s("canvas")], active: 0 }; + let layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }), + floating: vec![Floating::single(vec![s("color")], 0, [300.0, 200.0, 200.0, 150.0])], + }; + let mut workspace = Workspace::new(layout); + workspace.apply(Action::Close { panel: s("swatches") }, permissions).unwrap(); + workspace +} + +fn parse(params: Value) -> Result, serde_json::Error> { + serde_json::from_value(params) +} + +/// The actions `params` makes on a fresh workspace. +fn actions(params: Value) -> Vec> { + let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); + command.to_actions(&workspace()).unwrap_or_else(|e| panic!("{params}: {e}")) +} + +/// Apply `params` to a fresh workspace; return it and the panels closed. +fn applied(params: Value) -> (Workspace

, Vec

) { + let mut workspace = workspace(); + let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); + let closed = command.apply(&mut workspace, permissions).unwrap_or_else(|e| panic!("{params}: {e}")); + (workspace, closed) +} + +/// `params` parses but applying it fails with `error` and changes nothing. +fn rejected(params: Value, error: DockError) { + let mut workspace = workspace(); + let before = workspace.clone(); + let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); + assert_eq!(command.apply(&mut workspace, permissions), Err(error), "{params}"); + assert_eq!(workspace, before, "{params}"); +} + +fn tab(before: Option<&str>) -> Placement

{ + Placement::Tab { before: before.map(s) } +} + +#[test] +fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { + let mut seen = BTreeSet::new(); + let mut id = |params: Value| { + seen.insert(params["command"].as_str().unwrap().to_owned()); + params + }; + + // The raw action form, in both protocols. + let raw = json!({"MoveFloating": {"panel": "color", "rect": [44, 66, 360, 450]}}); + let moved = vec![Action::MoveFloating { panel: s("color"), rect: [44.0, 66.0, 360.0, 450.0] }]; + assert_eq!(actions(id(json!({"command": "window.panel.layout", "action": raw}))), moved); + assert_eq!(actions(id(json!({"command": "ui.dock", "action": raw}))), moved); + + // move: tab by default, before a tab or appended, zones, a serialized placement, a group. + let mv = |placement| vec![Action::Move { panel: s("history"), anchor: s("layers"), placement }]; + let base = json!({"command": "window.panel.move", "panel": "history", "anchor": "layers"}); + let with = |extra: Value| { + let mut params = base.clone(); + params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + params + }; + assert_eq!(actions(id(base.clone())), mv(tab(None))); + assert_eq!(actions(with(json!({"before": "channels"}))), mv(tab(Some("channels")))); + assert_eq!(actions(with(json!({"zone": "center", "before": null}))), mv(tab(None))); + assert_eq!(actions(with(json!({"zone": "center"}))), mv(Placement::Split(Zone::Center))); + assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Split(Zone::Left))); + assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true })); + assert_eq!(actions(with(json!({"placement": {"Column": {"after": true}}}))), mv(Placement::Column { after: true })); + assert_eq!( + actions(with(json!({"group": true, "zone": "above"}))), + vec![Action::MoveGroup { panel: s("history"), to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false } } }] + ); + + // float: a docked panel, a lone floating panel, a group and a column. + assert_eq!( + actions(id(json!({"command": "window.panel.float", "panel": "layers", "x": 10, "y": 20}))), + vec![Action::Float { panel: s("layers"), rect: [10.0, 20.0, 260.0, 320.0] }] + ); + assert_eq!( + actions(json!({"command": "window.panel.float", "panel": "color", "width": 320})), + vec![Action::MoveFloating { panel: s("color"), rect: [300.0, 200.0, 320.0, 150.0] }] + ); + assert_eq!( + actions(json!({"command": "window.panel.float", "panel": "layers", "group": true})), + vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: None } }] + ); + assert_eq!( + actions(json!({"command": "window.panel.float", "panel": "history", "column": true})), + vec![Action::MoveColumn { panel: s("history"), to: Target::Float { rect: None } }] + ); + + // dock: to an anchor, to a side, back into the dock, and a hidden panel to its last place. + assert_eq!( + actions(id(json!({"command": "window.panel.dock", "panel": "color", "anchor": "history", "zone": "tab"}))), + vec![Action::Move { panel: s("color"), anchor: s("history"), placement: tab(None) }] + ); + assert_eq!( + actions(json!({"command": "window.panel.dock", "panel": "color", "side": "End"})), + vec![Action::MoveGroup { panel: s("color"), to: Target::NewDock { side: Side::End } }] + ); + let new_column = Target::At { anchor: s("layers"), placement: Placement::Column { after: true } }; + assert_eq!( + actions(json!({"command": "window.panel.dock", "panel": "color", "column": true, "side": "Start"})), + vec![Action::MoveWindow { panel: s("color"), to: new_column.clone() }] + ); + assert_eq!( + actions(json!({"command": "window.panel.dock", "panel": "history", "group": true, "side": "Start"})), + vec![Action::MoveGroup { panel: s("history"), to: new_column }] + ); + assert_eq!( + actions(json!({"command": "window.panel.dock", "panel": "color"})), + vec![Action::Move { panel: s("color"), anchor: s("layers"), placement: tab(None) }] + ); + assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers"})), vec![]); + let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches"})); + assert!(shown.layout.contains(&s("swatches")) && shown.hidden.is_empty()); + + // activate shows a hidden panel; close actions report what they closed. + let (shown, _) = applied(id(json!({"command": "window.panel.activate", "panel": "swatches"}))); + assert!(shown.layout.contains(&s("swatches"))); + assert_eq!(actions(json!({"command": "window.panel.activate", "panel": "channels"})), vec![Action::Activate { panel: s("channels") }]); + assert_eq!(applied(id(json!({"command": "window.panel.close", "panel": "channels"}))).1, vec![s("channels")]); + assert_eq!(applied(id(json!({"command": "window.panel.closeGroup", "panel": "channels"}))).1, vec![s("layers"), s("channels")]); + assert_eq!(applied(id(json!({"command": "window.panel.closeOthers", "panel": "channels"}))).1, vec![s("layers")]); + + // minimize and iconic toggle without a value. + assert_eq!( + actions(id(json!({"command": "window.panel.minimize", "panel": "layers"}))), + vec![Action::SetMinimized { panel: s("layers"), minimized: true }] + ); + assert_eq!( + actions(json!({"command": "window.panel.minimize", "panel": "layers", "minimized": false})), + vec![Action::SetMinimized { panel: s("layers"), minimized: false }] + ); + assert_eq!(actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))), vec![Action::SetIconic { panel: s("history"), iconic: true }]); + + // window: a floating window goes native at its rect; a docked group floats first. + assert_eq!( + actions(id(json!({"command": "window.panel.window", "panel": "color"}))), + vec![Action::SetNative { panel: s("color"), rect: Some([300.0, 200.0, 200.0, 150.0]) }] + ); + let docked = actions(json!({"command": "window.panel.window", "panel": "layers"})); + assert!( + matches!(docked.as_slice(), [Action::MoveGroup { to: Target::Float { rect: None }, .. }, Action::SetNative { rect: Some(_), .. }]), + "{docked:?}" + ); + assert_eq!(actions(json!({"command": "window.panel.window", "panel": "color", "native": false})), vec![Action::SetNative { panel: s("color"), rect: None }]); + + // Named workspaces and Reset belong to the application. + for params in [ + json!({"command": "window.workspace", "name": "Painting"}), + json!({"command": "window.workspace.new", "name": "Mine"}), + json!({"command": "window.workspace.delete", "name": "Mine"}), + json!({"command": "window.workspace.reset"}), + json!({"command": "ui.dock.reset"}), + ] { + let command = parse(id(params.clone())).unwrap(); + assert!(command.is_app_owned(), "{params}"); + rejected(params, DockError::NotAllowed); + } + assert_eq!(parse(json!({"command": "ui.dock.reset"})).unwrap(), DockCommand::ResetWorkspace {}); + + // Lock toggles or sets. + assert!(applied(id(json!({"command": "window.workspace.lock"}))).0.locked); + assert!(!applied(json!({"command": "window.workspace.lock", "locked": false})).0.locked); + + // The small apps' ui.dock operations. + let op = |params: Value| { + let mut params = params; + params["command"] = json!("ui.dock"); + params + }; + let (shown, _) = applied(op(json!({"operation": "open", "panel": "swatches"}))); + assert!(shown.layout.contains(&s("swatches"))); + assert_eq!(actions(op(json!({"operation": "close", "panel": "layers"}))), vec![Action::Close { panel: s("layers") }]); + assert_eq!(actions(op(json!({"operation": "activate", "panel": "channels"}))), vec![Action::Activate { panel: s("channels") }]); + assert_eq!( + actions(op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 300, 200]}))), + vec![Action::Float { panel: s("layers"), rect: [1.0, 2.0, 300.0, 200.0] }] + ); + assert_eq!( + actions(op(json!({"operation": "moveFloating", "panel": "color", "rect": [1, 2, 300, 200]}))), + vec![Action::MoveFloating { panel: s("color"), rect: [1.0, 2.0, 300.0, 200.0] }] + ); + let to_layers = |placement| vec![Action::Move { panel: s("color"), anchor: s("layers"), placement }]; + assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center))); + assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))), to_layers(Placement::Split(Zone::Left))); + assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None))); + assert_eq!( + actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": "channels"}))), + to_layers(tab(Some("channels"))) + ); + assert_eq!( + actions(op(json!({"operation": "setStackOpen", "panel": "layers", "open": false}))), + vec![Action::SetStackOpen { panel: s("layers"), open: false }] + ); + assert_eq!( + actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": null}))), + vec![Action::ResizeStack { panel: s("layers"), height: None }] + ); + assert_eq!( + actions(op(json!({"operation": "resizeStack", "panel": "layers", "height": 120}))), + vec![Action::ResizeStack { panel: s("layers"), height: Some(120.0) }] + ); + assert_eq!( + actions(op(json!({"operation": "resizeSplit", "path": [], "size": {"Ratio": 0.35}}))), + vec![Action::ResizeSplit { path: vec![], size: SplitSize::Ratio(0.35) }] + ); + + let ids: BTreeSet = DockCommand::

::IDS.iter().map(|id| (*id).to_owned()).collect(); + assert_eq!(seen, ids, "one case per id"); + + // Bad params: parse errors, or a DockError with nothing changed. + for params in [ + json!({"command": "window.panel.unknown", "panel": "layers"}), + json!({"command": "window.panel.move", "panel": "layers"}), + json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "middle"}), + json!({"command": "window.panel.minimize", "panel": "layers", "minimized": "yes"}), + op(json!({"operation": "spin", "panel": "layers"})), + op(json!({"operation": "float", "panel": "layers", "rect": [1, 2, 3]})), + op(json!({"operation": "resizeStack", "panel": "layers"})), + op(json!({"operation": "setStackOpen", "panel": "layers"})), + op(json!({"operation": "move", "panel": "layers"})), + ] { + assert!(parse(params.clone()).is_err(), "{params}"); + } + rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "nowhere"}), DockError::MissingTarget); + rejected(json!({"command": "window.panel.move", "panel": "nowhere", "anchor": "layers"}), DockError::MissingPanel); + rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}), DockError::InvalidPlacement); + rejected(json!({"command": "window.panel.float", "panel": "layers", "width": 1e30}), DockError::InvalidGeometry); + rejected(json!({"command": "window.panel.move", "panel": "canvas", "anchor": "layers"}), DockError::NotAllowed); + rejected(op(json!({"operation": "moveFloating", "panel": "color", "rect": [0, 0, -5, 10]})), DockError::InvalidGeometry); + rejected(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})), DockError::InvalidPlacement); + rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget); + // A docked group cannot float into a native window when locked. + let mut locked = workspace(); + locked.locked = true; + let before = locked.clone(); + assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, permissions).is_err()); + assert_eq!(locked, before); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 85c76545..b4c65efe 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -6,6 +6,8 @@ //! input and commits only a valid result; a rejected move cannot lose the source panel. pub mod chrome; +#[cfg(feature = "serde")] +mod command; mod dock_model; mod hits; mod model; @@ -17,6 +19,8 @@ mod targets; pub mod testing; mod workspace; +#[cfg(feature = "serde")] +pub use command::{DockCommand, DockOperation, UiDock, ZoneName}; pub use hits::{Divider, Hit, ResizeEdge}; pub use render::{close_flyout, flyout, open_flyout}; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; @@ -30,6 +34,8 @@ pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; +#[cfg(all(test, feature = "serde"))] +mod command_tests; #[cfg(test)] mod dock_tests; #[cfg(test)] diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 29a6c9d9..1e8ae94c 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -250,3 +250,186 @@ fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() { } } } + +/// Each app's saved v1 layout goes through a docking session driven by real pointer input and a +/// command, then writes and reads back unchanged. +#[cfg(all(feature = "serde", feature = "testing"))] +mod v1_fixtures { + use craft_ui::docking::{DockCommand, DockContent, Hit, Layout, Node, Permissions, ResizeEdge, Side, testing::DockDriver}; + use egui::{Pos2, Rect, pos2}; + use serde_json::{Value, json}; + + struct Content; + + impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, panel: &String) { + ui.label(panel.as_str()); + } + fn logical_label(&self, panel: &String) -> String { + panel.clone() + } + fn permissions(&self, panel: &String) -> Permissions { + if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() } + } + } + + type Driver = DockDriver; + type Columns = Vec>>; + + /// App, file contents and the JSON pointer to a saved layout. + const FIXTURES: [(&str, &str, &str); 10] = [ + ("designcraft", include_str!("fixtures/v1/designcraft.json"), "/docking"), + ("designcraft", include_str!("fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"), + ("vectorcraft", include_str!("fixtures/v1/vectorcraft.json"), "/docking"), + ("lightcraft", include_str!("fixtures/v1/lightcraft.json"), "/docking/layout"), + ("pdfcraft", include_str!("fixtures/v1/pdfcraft.json"), "/docking/layout"), + ("cadcraft", include_str!("fixtures/v1/cadcraft.json"), "/workspace/docking/layout"), + ("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/docking/layout"), + ("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/mix_docking/layout"), + ("photocraft", include_str!("fixtures/v1/photocraft.json"), "/docking"), + ("photocraft", include_str!("fixtures/v1/photocraft.json"), "/panel_layout/docking"), + ]; + + /// The start dock's columns, each a list of groups. + fn start_columns(layout: &Layout) -> Columns { + fn find(node: &Node) -> Option { + match node { + Node::Dock { side: Side::Start, columns } => { + Some(columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect()) + } + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, + } + } + layout.root.as_ref().and_then(find).unwrap_or_default() + } + + fn iconic(layout: &Layout) -> bool { + fn find(node: &Node) -> Option { + match node { + Node::Dock { side: Side::Start, columns } => columns.first().map(|column| column.iconic), + Node::Split { first, second, .. } => find(first).or_else(|| find(second)), + _ => None, + } + } + layout.root.as_ref().and_then(find).unwrap_or(false) + } + + fn hit(d: &Driver, hit: Hit) -> Rect { + d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())) + } + + /// A point of `from` that no floating window above it covers, as drawn last frame. + fn grab(d: &Driver, from: &Hit, panel: &String) -> Option { + let rect = d.hit(from)?; + let own = d.layout.floating.iter().position(|w| w.contains(panel)); + let windows: Vec = d + .layout + .floating + .iter() + .filter_map(|w| { + let first = w.panels().next()?.clone(); + Some(Rect::from_min_max(d.hit(&Hit::Title(first.clone()))?.min, d.hit(&Hit::Resize(first, ResizeEdge::BottomRight))?.max)) + }) + .collect(); + let covered = |p: Pos2| windows.iter().enumerate().any(|(i, w)| own.is_none_or(|own| i > own) && w.contains(p)); + let mut x = rect.left() + 4.0; + while x < rect.right() { + let p = pos2(x, rect.center().y); + if !covered(p) { + return Some(p); + } + x += 4.0; + } + None + } + + /// Drag from an uncovered point of `from` to `to`; something must change. + fn drag(d: &mut Driver, from: Hit, to: Pos2, what: &str) { + let panel = match &from { + Hit::DragBar(p) | Hit::Title(p) => p.clone(), + other => panic!("{other:?}"), + }; + let at = grab(d, &from, &panel).unwrap_or_else(|| panic!("{what}: {from:?} is covered")); + let out = d.drag(at, to); + assert!(out.error.is_none() && !out.actions.is_empty(), "{what}: {out:?}\n{:?}\n{:?}", d.layout, d.hits()); + d.settle(); + } + + fn sorted(layout: &Layout) -> Vec { + let mut panels: Vec = layout.panels().into_iter().cloned().collect(); + panels.sort(); + panels + } + + #[test] + fn v1_layouts_dock_collapse_float_redock_and_reload() { + let mut seams = Vec::new(); + for (app, text, pointer) in FIXTURES { + let at = format!("{app}{pointer}"); + let file: Value = serde_json::from_str(text).unwrap(); + let layout: Layout = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap_or_else(|e| panic!("{at}: {e}")); + let panels = sorted(&layout); + let mut d = Driver::new(layout, Content); + let mut bars: Vec = Vec::new(); + for (h, _) in d.hits() { + if let Hit::DragBar(panel) = h + && !bars.contains(&panel) + { + bars.push(panel); + } + } + assert!(bars.len() >= 2, "{at}: two movable groups in {bars:?}"); + + // The first group's bar dropped on the window's start edge makes a dock. + drag(&mut d, Hit::DragBar(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: new dock")); + let columns = start_columns(&d.layout); + assert!(columns.len() == 1 && columns[0].len() == 1 && columns[0][0][0] == bars[0], "{at}: {columns:?}"); + + // The second group stacks below it, and a third goes into the seam between them. + let first = hit(&d, Hit::Tab(bars[0].clone())); + let bottom = d.area.bottom(); + drag(&mut d, Hit::DragBar(bars[1].clone()), pos2(first.center().x, bottom - 10.0), &format!("{at}: stack below")); + let third = bars[2..].iter().find(|p| grab(&d, &Hit::DragBar((*p).clone()), p).is_some()).cloned(); + if let Some(third) = &third { + seams.push(at.clone()); + let second = hit(&d, Hit::Tab(bars[1].clone())); + drag(&mut d, Hit::DragBar(third.clone()), pos2(second.center().x, second.top() + 2.0), &format!("{at}: seam")); + } + let columns = start_columns(&d.layout); + let heads: Vec<&String> = columns[0].iter().map(|group| &group[0]).collect(); + let expected: Vec<&String> = match &third { + Some(third) => vec![&bars[0], third, &bars[1]], + None => vec![&bars[0], &bars[1]], + }; + assert_eq!((columns.len(), heads), (1, expected), "{at}"); + + // Collapse the column to icons and expand it again. + d.click(hit(&d, Hit::Collapse(bars[0].clone())).center()); + d.settle(); + assert!(iconic(&d.layout), "{at}: collapsed"); + assert!(d.hit(&Hit::Icon(bars[0].clone())).is_some(), "{at}: icons"); + d.click(hit(&d, Hit::Collapse(bars[0].clone())).center()); + d.settle(); + assert!(!iconic(&d.layout), "{at}: expanded"); + + // Float the column by command, then drag its window back to the start edge. + let float: DockCommand = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); + let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone())).unwrap(); + d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); + d.settle(); + assert!(start_columns(&d.layout).is_empty(), "{at}: floated"); + drag(&mut d, Hit::Title(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: dock back")); + assert_eq!(start_columns(&d.layout), columns, "{at}: docked back"); + + // Nothing was lost, and the layout reads back as written. + assert_eq!(sorted(&d.layout), panels, "{at}"); + let written = serde_json::to_string(&d.layout).unwrap(); + let read: Layout = serde_json::from_str(&written).unwrap(); + read.validate().unwrap_or_else(|e| panic!("{at}: {e}")); + assert_eq!(read, d.layout, "{at}"); + } + // The fixtures with a third movable group, uncovered, try the seam. + assert_eq!(seams.len(), 6, "{seams:?}"); + } +} From b98ef89921eb3ed388be38447d364e5f1efd5107 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:07:14 +0000 Subject: [PATCH 028/160] docking: rewrite the docking guide for the current API docs/ui-docking.md covers the Dock and Frame profiles, the model and actions, Workspace, the renderer and DockContent, gestures, DockCommand, DockStyle::localized, hit rects and the DockDriver test driver, and links the task-pane guide. Also: ui.dock open, close and activate map in one place, the command tests build as a cfg(test) module, the hit-kind table gets a type alias, the testing example uses public API, and rustfmt. --- crates/ui/src/docking/command.rs | 46 +++- crates/ui/src/docking/command_tests.rs | 26 ++- crates/ui/src/docking/iconic_tests.rs | 9 +- crates/ui/src/docking/mod.rs | 6 +- crates/ui/src/docking/render/dock.rs | 6 +- crates/ui/src/docking/render/iconic.rs | 7 +- crates/ui/src/docking/testing.rs | 5 +- crates/ui/tests/docking_workflows.rs | 3 +- docs/ui-docking.md | 303 ++++++++++++++++--------- 9 files changed, 266 insertions(+), 145 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 7b7b7d20..ffd066ee 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -162,11 +162,23 @@ pub enum UiDock

{ #[non_exhaustive] pub enum DockOperation

{ /// Show a panel at its last location and select it. - Open { panel: P }, - Close { panel: P }, - Activate { panel: P }, - Float { panel: P, rect: [f32; 4] }, - MoveFloating { panel: P, rect: [f32; 4] }, + Open { + panel: P, + }, + Close { + panel: P, + }, + Activate { + panel: P, + }, + Float { + panel: P, + rect: [f32; 4], + }, + MoveFloating { + panel: P, + rect: [f32; 4], + }, Move { panel: P, target: P, @@ -175,14 +187,20 @@ pub enum DockOperation

{ #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")] before: Option>, }, - SetStackOpen { panel: P, open: bool }, + SetStackOpen { + panel: P, + open: bool, + }, /// `height` is required; `null` clears it. ResizeStack { panel: P, #[serde(deserialize_with = "required")] height: Option, }, - ResizeSplit { path: Vec, size: SplitSize }, + ResizeSplit { + path: Vec, + size: SplitSize, + }, } /// A drop zone by name, as control protocols write it. PascalCase [`Zone`] names also parse. @@ -365,11 +383,11 @@ impl DockCommand

{ let anchor = docked.or_else(framed).cloned().ok_or(DockError::MissingTarget)?; one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Tab { before: None } })?) } - Self::Activate { panel } | Self::UiDock(UiDock::Operation(DockOperation::Activate { panel } | DockOperation::Open { panel })) => { + Self::Activate { panel } => { shown(panel)?; one(Action::Activate { panel: panel.clone() }) } - Self::Close { panel } | Self::UiDock(UiDock::Operation(DockOperation::Close { panel })) => one(Action::Close { panel: panel.clone() }), + Self::Close { panel } => one(Action::Close { panel: panel.clone() }), Self::CloseGroup { panel } => one(Action::CloseGroup { panel: panel.clone() }), Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }), Self::Minimize { panel, minimized } => { @@ -399,6 +417,11 @@ impl DockCommand

{ Ok(actions) } Self::UiDock(UiDock::Operation(operation)) => match operation { + DockOperation::Open { panel } | DockOperation::Activate { panel } => { + shown(panel)?; + one(Action::Activate { panel: panel.clone() }) + } + DockOperation::Close { panel } => one(Action::Close { panel: panel.clone() }), DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }), DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }), DockOperation::Move { panel, target, zone, before } => { @@ -408,7 +431,6 @@ impl DockCommand

{ DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }), DockOperation::ResizeStack { panel, height } => one(Action::ResizeStack { panel: panel.clone(), height: *height }), DockOperation::ResizeSplit { path, size } => one(Action::ResizeSplit { path: path.clone(), size: *size }), - DockOperation::Open { .. } | DockOperation::Close { .. } | DockOperation::Activate { .. } => unreachable!("matched above"), }, Self::Workspace { .. } | Self::NewWorkspace { .. } | Self::DeleteWorkspace { .. } | Self::ResetWorkspace {} | Self::Lock { .. } => { Err(DockError::NotAllowed) @@ -469,7 +491,9 @@ fn members(layout: &Layout

, panel: &P, unit: Unit) -> V } _ => Vec::new(), }, - Some(Place::Floating { .. }) => layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default(), + Some(Place::Floating { .. }) => { + layout.floating.iter().find(|w| w.contains(panel)).map(|w| w.panels().cloned().collect()).unwrap_or_default() + } _ => vec![panel.clone()], }, } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 60ad359d..b106a39d 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -1,4 +1,5 @@ //! `DockCommand`: every documented shape parses and maps to atomic layout actions. +#![cfg(feature = "serde")] use std::collections::BTreeSet; @@ -158,7 +159,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { actions(json!({"command": "window.panel.minimize", "panel": "layers", "minimized": false})), vec![Action::SetMinimized { panel: s("layers"), minimized: false }] ); - assert_eq!(actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))), vec![Action::SetIconic { panel: s("history"), iconic: true }]); + assert_eq!( + actions(id(json!({"command": "window.panel.iconic", "panel": "history"}))), + vec![Action::SetIconic { panel: s("history"), iconic: true }] + ); // window: a floating window goes native at its rect; a docked group floats first. assert_eq!( @@ -170,7 +174,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { matches!(docked.as_slice(), [Action::MoveGroup { to: Target::Float { rect: None }, .. }, Action::SetNative { rect: Some(_), .. }]), "{docked:?}" ); - assert_eq!(actions(json!({"command": "window.panel.window", "panel": "color", "native": false})), vec![Action::SetNative { panel: s("color"), rect: None }]); + assert_eq!( + actions(json!({"command": "window.panel.window", "panel": "color", "native": false})), + vec![Action::SetNative { panel: s("color"), rect: None }] + ); // Named workspaces and Reset belong to the application. for params in [ @@ -210,7 +217,10 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { ); let to_layers = |placement| vec![Action::Move { panel: s("color"), anchor: s("layers"), placement }]; assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center))); - assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))), to_layers(Placement::Split(Zone::Left))); + assert_eq!( + actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))), + to_layers(Placement::Split(Zone::Left)) + ); assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None))); assert_eq!( actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": "channels"}))), @@ -252,11 +262,17 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { } rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "nowhere"}), DockError::MissingTarget); rejected(json!({"command": "window.panel.move", "panel": "nowhere", "anchor": "layers"}), DockError::MissingPanel); - rejected(json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}), DockError::InvalidPlacement); + rejected( + json!({"command": "window.panel.move", "panel": "layers", "anchor": "history", "zone": "left", "before": null}), + DockError::InvalidPlacement, + ); rejected(json!({"command": "window.panel.float", "panel": "layers", "width": 1e30}), DockError::InvalidGeometry); rejected(json!({"command": "window.panel.move", "panel": "canvas", "anchor": "layers"}), DockError::NotAllowed); rejected(op(json!({"operation": "moveFloating", "panel": "color", "rect": [0, 0, -5, 10]})), DockError::InvalidGeometry); - rejected(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})), DockError::InvalidPlacement); + rejected( + op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "top", "before": "channels"})), + DockError::InvalidPlacement, + ); rejected(op(json!({"operation": "resizeSplit", "path": [true, true, true], "size": {"Ratio": 0.5}})), DockError::MissingTarget); // A docked group cannot float into a native window when locked. let mut locked = workspace(); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 67e722d2..cbcef4ef 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -21,6 +21,9 @@ impl DockContent for Content { type Driver = DockDriver; +/// A name and a test for one kind of [`Hit`]. +type Check<'a> = (&'a str, &'a dyn Fn(&Hit) -> bool); + /// A left dock with one column of `groups`, iconic when `iconic`, beside canvas 0. fn docked(groups: &[&[u32]], iconic: bool) -> Layout { let mut column = Column::new(groups.iter().map(|g| Group::new(g.to_vec(), 0)).collect(), 240.0); @@ -267,9 +270,7 @@ fn hit_rects_cover_every_gesture_source() { let mut icons = Column::new(vec![Group::new(vec![9], 0)], 240.0); icons.iconic = true; let dock = Node::Dock { side: Side::Start, columns: vec![icons, expanded] }; - let stack = Node::Stack { - entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }], - }; + let stack = Node::Stack { entries: vec![StackEntry { panel: 10, open: true, height: None }, StackEntry { panel: 11, open: true, height: None }] }; let frames = Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(0.5), @@ -285,7 +286,7 @@ fn hit_rects_cover_every_gesture_source() { let d = DockDriver::new(layout.clone(), Content); let hits = d.hits(); let kinds = |f: &dyn Fn(&Hit) -> bool| hits.iter().filter(|(h, _)| f(h)).count(); - let expected: [(&str, &dyn Fn(&Hit) -> bool); 15] = [ + let expected: [Check<'_>; 15] = [ ("tab", &|h| matches!(h, Hit::Tab(_))), ("drag bar", &|h| matches!(h, Hit::DragBar(_))), ("border", &|h| matches!(h, Hit::Border(_))), diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b4c65efe..4c7582bf 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -21,10 +21,10 @@ mod workspace; #[cfg(feature = "serde")] pub use command::{DockCommand, DockOperation, UiDock, ZoneName}; -pub use hits::{Divider, Hit, ResizeEdge}; -pub use render::{close_flyout, flyout, open_flyout}; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; +pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; +pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag, is_dragging, @@ -34,7 +34,7 @@ pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; -#[cfg(all(test, feature = "serde"))] +#[cfg(test)] mod command_tests; #[cfg(test)] mod dock_tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index fe5d4728..ae6646ee 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -41,11 +41,7 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side { } pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) -> f32 { - if column.iconic { - column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) - } else { - column.width - } + if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width } } /// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 738f0657..79a37f27 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -112,8 +112,8 @@ impl Renderer<'_, P> { if let Some(panel) = self.activation.as_ref().filter(|p| column.groups.iter().any(|g| g.panels.contains(p))) { open_flyout(ui.ctx(), self.id, panel.clone()); } - let total: f32 = column.groups.iter().map(|g| self.icon_group_height(g)).sum::() - + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; + let total: f32 = + column.groups.iter().map(|g| self.icon_group_height(g)).sum::() + column.groups.len().saturating_sub(1) as f32 * SEPARATOR; if total > body.height() + 0.5 { let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { return }; let mut child = @@ -239,7 +239,8 @@ impl Renderer<'_, P> { } if labels { let text = self.content.label(ui, panel, &label, &self.style.font, color); - let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone()); + let galley = + text.into_galley(ui, Some(egui::TextWrapMode::Truncate), (cell.right() - icon.right() - 12.0).max(0.0), self.style.font.clone()); painter.galley(pos2(icon.right() + 6.0, cell.center().y - galley.size().y * 0.5), galley, color); } else if response.hovered() && !response.dragged() { response.clone().on_hover_text(&label); diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 64702470..e34cad2f 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -3,8 +3,9 @@ //! ```ignore //! let mut driver = DockDriver::new(app_layout(), Panels::default()); //! let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); -//! driver.drag(bar.center(), egui::pos2(4.0, 300.0)); -//! assert!(driver.layout.dock_path(Side::Start).is_some()); +//! driver.drag(bar.center(), egui::pos2(2.0, 300.0)); +//! driver.settle(); +//! assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some()); //! ``` use std::{fmt::Debug, hash::Hash}; diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index 1e8ae94c..ddf2ecee 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -414,7 +414,8 @@ mod v1_fixtures { assert!(!iconic(&d.layout), "{at}: expanded"); // Float the column by command, then drag its window back to the start edge. - let float: DockCommand = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); + let float: DockCommand = + serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone())).unwrap(); d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); d.settle(); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 82324509..0e8b3ba6 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -1,151 +1,232 @@ # Panel docking -`craft_ui::docking` lets an application move the same panel between docked tabs, side splits and -floating groups. The shared layer owns placement and interaction; the application owns each -panel's contents, commands, document state and persistence policy. +`craft_ui::docking` lets the user arrange an app's panels: tab groups, docks with columns, +splits, floating windows and iconic columns. craft-ui owns the layout model, the renderer and the +gestures. The app owns panel contents, commands, document state and where layouts are saved. -## Model and transactions +Office apps that draw their own document with a few task panes beside it use +[`craft_ui::panes`](ui-panes.md) instead. -`Layout

` contains an optional root `Node

` and floating tab groups. A node can be: +## Profiles -- `Tabs`: ordered panel identities with one active index. -- `Split`: horizontal or vertical children, with a ratio or fixed first/second size. -- `Stack`: accordion entries with individual open state and optional content height. +`DockStyle::profile` picks one of two arrangements. Both use the same model and actions. -Choose a stable, nonlocalized panel ID type implementing `Clone + Eq + Hash`. Rendering also -requires `Debug + Send + Sync + 'static`. A panel identity occurs at most once in the layout. -Keep transient document/sequence tabs separate when they represent documents inside one panel. +| Profile | Arrangement | Apps | +|---|---|---| +| `Profile::Dock` (default) | Docks on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | Creative apps with a canvas and panel docks | +| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + tab drag undocks. Drops on all four outer edges split at the root. | Apps whose whole window is frames | + +Sides are logical: `Side::Start` is the left edge, or the right edge when +`DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its +locale. + +## Model + +`Layout

` holds an optional root `Node

` and a list of `Floating` windows. + +| Node | Holds | +|---|---| +| `Tabs` | A tab group: panels and the active index. | +| `Split` | Two children, horizontal or vertical, with a ratio or a fixed first or second size. | +| `Stack` | Accordion entries, each open or closed, with an optional height. | +| `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | + +A `Floating` window has one or more stacked groups, a rect, and an optional native-window rect. +`Floating::single` makes a window with one group; `panels()` and `contains()` read it. + +`P` is the app's panel id: stable, not localized, `Clone + Eq + Hash`. The renderer also needs +`Debug + Send + Sync + 'static`. A panel occurs at most once in a layout. + +Change a layout only through `Action`s: ```rust use craft_ui::docking::{Action, Floating, Layout, Node, Placement}; let mut layout = Layout { root: Some(Node::Tabs { panels: vec!["layers", "properties"], active: 0 }), - floating: vec![Floating { - panels: vec!["brushes"], active: 0, rect: [40.0, 80.0, 240.0, 300.0], - }], + floating: vec![Floating::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0])], }; layout.apply(Action::Move { - panel: "brushes", anchor: "layers", + panel: "brushes", + anchor: "layers", placement: Placement::Tab { before: Some("properties") }, })?; # Ok::<(), craft_ui::docking::DockError>(()) ``` -Every transaction validates the original layout before cloning or recursive mutation. It checks -the resulting layout before replacing the original. Missing or self targets, duplicate IDs, -invalid active indices, excessive depth/count and invalid geometry return `DockError`; they do -not remove the source panel. Tab removals retain the selected panel by identity when possible. -Empty source groups collapse. Moving within an accordion preserves its entry state and height; -unaffected split sizing and accordion entries remain unchanged. +- `Move`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a + group, a dock column or a floating window, named by any panel in it. +- A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. +- A `Placement` is a tab position, a split zone, a new group above or below, a new column, or a + new frame on an outer edge. +- Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move + a floating window to its own OS window (`SetNative`, feature `native-windows`). -`OpenAt` adds a currently absent registered panel at an explicit tab or side destination. `Open` -is the simpler group-reveal operation. Registry checks remain app-owned: the shared model cannot -know whether an ID names an actual panel or whether its content is supported by the application. +Every action checks the whole result before it commits. A bad target, a duplicate id, a stale +split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source +panel is never lost. -Use `apply_with_permissions` to prevent moving/closing a document canvas or merging a panel into -it. `Permissions::PROTECTED` disallows these operations while allowing side splits around it. -An app that requires a permanently docked panel also checks that policy before accepting a -transaction; an empty shared root is valid when all panels float. +`Permissions` limit what may happen to a panel. `Permissions::PROTECTED` keeps a canvas docked +and open and takes no tabs, but allows splits beside it. `apply_with_permissions` applies them. -## Rendering and interaction +## Workspace -Existing renderers can convert their layout into the shared model for transactions and retain -their own panel chrome. `DropTarget` describes visible body/tab rectangles and whether a group -accepts tabs or splits. Supply targets back-to-front, including floating-window layer order. -`resolve_drop` returns a tab insertion or side/center placement and its preview rectangle. -The frontmost containing group blocks targets underneath even when it rejects a drop. +`Workspace

` is what an app saves: the layout, the hidden panels with where each one goes back, +an optional saved arrangement for Reset, and `locked`. -The optional `DockArea` renderer handles tab fitting and overflow, drag/drop previews, -Escape/focus-loss cancellation, split/accordion dividers and floating-group resize grips. It renders a -snapshot and returns `DockOutput.actions`; apply them through the application's command path. -With `serde` enabled, `Action` also serializes so pointer, keyboard and control-channel requests -can share that command path, including splitter changes, tab reorder and floating bounds. -Panel body callbacks receive stable global egui IDs, so moving a panel does not reset its widget -memory. The application should still keep durable panel/document data outside transient egui -widget memory. +| Method | Does | +|---|---| +| `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused. | +| `set_visible(panel, visible, fallback)` | Hide a panel, or show it at its recorded place, else beside `fallback`. | +| `reconcile(registered, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. | +| `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | +| `validate` | Check a loaded workspace before use. | -`DockStyle` supplies colors, font, spacing and localized action labels. `show_with_limits` also -accepts per-panel minimum/maximum sizes. Visible geometry clamps to the available area; a narrow -viewport relaxes minima proportionally. Persisted preferred sizes are unchanged by this drawing -clamp. A divider gesture produces an explicit resize action. +With the `serde` feature, `Workspace`, `Layout` and `Action` serialize. Files without a version +are v1 and load unchanged. craft-ui does not choose where files go; the app does. -Use `begin_drag` with the same area ID to carry an existing tab/header gesture into `DockArea` -when an app switches from its default arrangement to a customizable workspace. +## Rendering -Floating groups are egui areas in one viewport. Native child windows, cross-viewport drag -transport, OS window ownership and multi-monitor coordinate conversion remain the host's -responsibility. The model does not instantiate or reparent native windows. +`DockArea::new(id).show_content(ui, &layout, &style, &mut content)` draws a layout snapshot and +returns a `DockOutput`. It does not change the layout. The app applies `output.actions` through +its own command path, so pointer, keyboard and control commands share one path. -## Persistence and visibility +`DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, +permissions, size limits, preferred floating size, panel menu and group header. `bare` hides the +tab strip of a lone panel. Panel bodies keep a stable egui id when they move, so widget memory +survives. -Enable `craft-ui`'s optional `serde` feature to serialize the shared model, or retain an existing -schema through an adapter. The serialization feature does not choose a filesystem location or -write preferences. Applications validate panel IDs, payload size and schema version before -committing loaded state. Bound recursive deserialization/conversion in the app as well as using -`Layout::validate`; the validator cannot retroactively protect an unbounded parser. +`DockOutput` also has: -To hide a panel without forgetting its placement, capture `Layout::location`, then close it. -Keep the resulting `Location` with the app's visibility state. `restore` uses stable neighbor -IDs, restores captured fixed sizing/floating bounds and recreates a previously closed accordion -group. If a neighbor was removed, it returns an unchanged layout and `MissingTarget`; choose an -explicit fallback such as a default side or remembered floating rectangle. Restoring multiple -panels closed from the same group in reverse close order preserves neighbor relationships. +- `focused`: the focused panel. +- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and + `DockEvent::ToggleAutoCollapse` (Dock). +- `error`: a rejected gesture. -## Acceptance +Builder options on `DockArea`: -The shared tests cover lossless moves, tab reorder, stale targets, atomic errors, protected -canvases, accordion state, fixed sizing, hidden-panel restoration, bounded malformed layouts, -and a deterministic sequence of 800 panel moves. Pointer tests exercise dragging a floating -panel into the main tab strip and canceling a drag. +| Option | Use | +|---|---| +| `maximized(panel)` | Maximize Frame: that frame fills the area. | +| `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. | +| `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | +| `raise_panel(panel)` | Raise and focus a panel's floating window this frame. | +| `blocked_rects(rects)` | App overlays that docking gestures must ignore. | -`tests/docking_workflows.rs` renders a document and populated Layers/Properties/Brushes panels, -then uses actual pointer events to merge, detach and redock Brushes. It checks retained values -and widget identity at two widths, two display scales and both egui themes. To retain captures: +`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a +panel body. `begin_drag` hands a drag that started in the app's own chrome to the area. +`docking::chrome` has the floating frame and title bar for apps that draw their own windows. + +## Gestures + +- A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags + the whole group. A column bar drags a column. A floating window's title bar drags the window. +- Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, + a filled half for a split. Escape or losing window focus cancels a drag. +- Double-click a tab or drag bar to minimize a group to its strip. +- A column's »/« button collapses it to icons, one icon per panel with a separator between groups. + Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the + canvas side. Drag an icon to move its panel, or the grip above an icon group to move the group. + +## Commands + +With the `serde` feature, `DockCommand

` is the shared control vocabulary. The app puts the +command id under `"command"` in the params and deserializes them. craft-ui has no `serde_json` +dependency. + +```rust,ignore +let mut params = params.clone(); +params["command"] = json!(id); +let command: DockCommand = serde_json::from_value(params)?; +if command.is_app_owned() { + return app.workspace_command(command); +} +let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?; +``` + +| Id | Params (`?` is optional) | +|---|---| +| `window.panel.layout` | `{action}`: a serialized `Action` | +| `window.panel.move` | `{panel, anchor, zone?, before?, group?, placement?}` | +| `window.panel.float` | `{panel, x?, y?, width?, height?, group?, column?}` | +| `window.panel.dock` | `{panel, anchor?, zone?, before?, group?, column?, side?}` | +| `window.panel.activate`, `.close`, `.closeGroup`, `.closeOthers` | `{panel}` | +| `window.panel.minimize` | `{panel, minimized?}` | +| `window.panel.iconic` | `{panel, iconic?}` | +| `window.panel.window` | `{panel, native?}` | +| `window.workspace`, `.new`, `.delete` | `{name}` | +| `window.workspace.reset`, `ui.dock.reset` | `{}` | +| `window.workspace.lock` | `{locked?}` | +| `ui.dock` | `{action}`, or `{operation, …}` | + +- `zone` is `tab`, `center`, `left`, `right`, `top`, `bottom`, `above` or `below`. `before` names + the tab to insert before; `null` appends. +- `group` moves the panel's whole group; `column` moves its dock column, or its whole window when + it floats. `side` is `Start` or `End`. +- Without a value, `minimized`, `iconic` and `locked` toggle. +- `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`, + `close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`, + `setStackOpen {open}`, `resizeStack {height}` and `resizeSplit {path, size}`. `rect` is + `[x, y, width, height]`. + +`to_actions` returns the actions a command makes without applying them. `apply` applies them all +or none and returns the panels it closed, so the app can release their state. Showing a hidden +panel and Lock change the workspace directly. Named workspaces and Reset belong to the app: +`is_app_owned` is true for them and `apply` refuses them. `DockCommand::IDS` lists every id for +the app's command registry. App-specific aliases stay in the app. + +## Labels + +Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English +default. `DockStyle::localized` sets them all from one keyed lookup: + +```rust,ignore +let style = DockStyle::from_ui(ui).localized(|key| app.strings.get(key).cloned()); +``` + +Keys are `dock.close`, `dock.float`, `dock.collapseToIcons` and so on; the rustdoc of `localized` +lists them all. `DockContent::label` and `action_label` can shape panel and action text further; +the logical label stays the accessibility name. + +## Testing + +`DockArea::hit_rects(ctx, id)` lists every gesture source drawn last frame as a `Hit` with its +rect: tabs, drag bars, borders, column bars, »/« buttons, title bars, close buttons, icons, icon +grips, resize edges and dividers. It is always compiled, so an app can return it from a +`ui.docking` control method next to the layout and the focused panel. + +The `testing` feature adds `docking::testing::DockDriver`. It runs a `DockArea` in a headless +egui context with real pointer and key events, and applies the actions each frame emits. + +```rust,ignore +let mut driver = DockDriver::new(app_layout(), Panels::default()); +let bar = driver.hit(&Hit::DragBar(PanelId::Layers)).unwrap(); +driver.drag(bar.center(), egui::pos2(2.0, 300.0)); +driver.settle(); +assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some()); +``` + +It also has `press`, `release`, `click`, `hold`, `key`, `focus`, `hits`, and checks for painted +previews (`filled`, `outlined`). + +craft-ui's own tests in `crates/ui/tests/docking_workflows.rs`: + +- Load every app's v1 layout fixture, dock groups into a new dock, a bottom band and a seam, + collapse and expand the column, float it by command and drag it back, then check the layout + reads back as written. +- Render Layers, Properties and Brushes panels with real content, and merge, detach and redock + Brushes by pointer at two widths, two scales and both themes. Set `CRAFT_UI_DOCK_VISUAL_DIR` + to keep the captures: ```sh CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test docking_workflows --all-features ``` -These are offscreen egui checks. An adopting application still needs command, content, workspace -reload, theme and input tests using its own renderer and persistence adapter. +An adopting app still tests its own commands, contents, saved workspaces and themes. -## Application chrome and activation +## What stays in the app -`DockArea::show_customized` accepts one mutable `DockContent

` adapter, with body, -panel-menu, group-header and label methods. Apps queue commands in these callbacks and -apply them after rendering the readonly snapshot. `group_header_width` reserves a bounded -trailing portion of the existing tab strip; its child UI is clipped to that region. -Collapsed accordion entries still have panel menus; collapsing tab groups is host policy. - -`label` returns `WidgetText`, including an application-shaped galley, used for tab width, -painting and menu labels. The separate logical label callback supplies tab accessibility -text. `action_label` independently shapes the localized stock Float/Move/Close menu -text without receiving a panel identity. Its default returns unchanged logical `WidgetText`; -custom visual text never replaces the action's logical accessibility label. Panel targets -and overflow entries continue to use `label`. Original `show` and `show_with_limits` call -sites remain supported. - -Apply `Action::Activate` in the host, then use `DockArea::raise_panel(panel)` on the next -frame to raise the owning in-viewport floating layer and focus the panel tab. This request -does not mutate the serialized layout or persist z-order. Floating area identity still -derives from the first panel; native-window identity remains host-owned. - -Floating groups have a distinct 12-point grip in the existing tab strip. Moving it emits -`MoveFloating` for the whole group; dragging an individual tab detaches just that panel. -`DockOutput::group_drag` and `group_drop` expose the group identity and release position -for app-owned atomic group docking policies. Escape/lost focus restores the group's -starting rectangle. No generic group docking or app command registry is inferred. - -Hosts supply overlay rectangles via `blocked_rects` before showing the surface. These -block drop previews, release actions and float fallback at the pointer, including overlays -appearing mid-drag. Hosts must provide current overlay geometry or cancel docking when -modal state prevents interaction; later-drawn overlays cannot be discovered retroactively. - -`DockArea::chrome_has_focus(ctx, layout)` classifies focus on rendered dock tabs, -collapse buttons, overflow, split/stack dividers, floating resize corners and group -grips. It excludes panel content and custom header/menu widgets and rejects invalid -layouts. Hosts can permit global commands from chrome while reserving Enter, Space, -Tab, Escape and arrow keys for activation, traversal, cancellation and resizing. -Content widgets retain their usual keyboard ownership. The renderer owns the IDs; -hosts do not need to reconstruct them. +- The panel registry, panel contents and document state. +- Named workspaces, Reset, and when and where layouts are saved. +- Menus and key bindings outside the docking chrome. +- Native window ownership on platforms without egui viewports. From 8a0df8c12ff3a3e076fae3bd65dfdcb8b50cf8e0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:24:38 +0000 Subject: [PATCH 029/160] docking: fixes from DesignCraft trial step 2 - Permissions::accepts_splits; PROTECTED takes no side splits, so a group dropped on a protected canvas floats. - A press of any pointer button in a panel body, stripless panels included, reports the panel in DockOutput::focused. - Right to left, tab strips, their header and drag bar, insertion slots (DropTarget::right_to_left), accordion headers and chrome::TitleBar (ChromeStyle::right_to_left) mirror. TitleBar truncates galley titles like tab labels. - DockStyle::close_menu_items and close_button hide the built-in close controls without changing Permissions::closable. - Menu labels say what moves: Move Panel to Group (move_panel_to_label) and Move Group to (move_group_to_label). - The column bar's button has its own accessibility labels (collapse_column_label, expand_column_label). - Placement::for_zone maps zones onto what the anchor's container takes; DockCommand uses it and takes right_to_left. - Layout::apply, apply_with_permissions and apply_all return the closed panels. - Placement::Group takes an optional preferred height. --- crates/ui/src/docking/chrome.rs | 42 ++++- crates/ui/src/docking/command.rs | 60 ++++--- crates/ui/src/docking/command_tests.rs | 46 +++++- crates/ui/src/docking/dock_model.rs | 44 ++++- crates/ui/src/docking/dock_tests.rs | 4 +- crates/ui/src/docking/floating_tests.rs | 15 +- crates/ui/src/docking/group_tests.rs | 25 +-- crates/ui/src/docking/iconic_tests.rs | 3 +- crates/ui/src/docking/labels.rs | 14 +- crates/ui/src/docking/model.rs | 81 +++++++--- crates/ui/src/docking/model_tests.rs | 8 +- crates/ui/src/docking/render/dock.rs | 3 +- crates/ui/src/docking/render/floating.rs | 20 ++- crates/ui/src/docking/render/frame.rs | 2 +- crates/ui/src/docking/render/group.rs | 62 +++---- crates/ui/src/docking/render/mod.rs | 12 +- crates/ui/src/docking/surface.rs | 26 ++- crates/ui/src/docking/targets.rs | 21 ++- crates/ui/src/docking/tests.rs | 18 ++- crates/ui/src/docking/trial_tests.rs | 195 ++++++++++++++++++++++- crates/ui/tests/docking_workflows.rs | 2 +- docs/ui-docking.md | 36 +++-- 22 files changed, 587 insertions(+), 152 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 4c8985a9..f5b6bb6b 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -3,7 +3,11 @@ //! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes //! a layout: the caller turns the responses into its own commands. -use egui::{Color32, Context, FontId, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2}; +use std::sync::Arc; + +use egui::{ + Color32, Context, FontId, Galley, Id, Key, LayerId, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetText, pos2, vec2, +}; use super::{DockStyle, PanelLimits, resize::AnchoredResize}; @@ -24,6 +28,8 @@ pub struct ChromeStyle { pub weak_text: Color32, pub accent: Color32, pub font: FontId, + /// Mirror the title bar: buttons at the left, the title at the right. + pub right_to_left: bool, } impl ChromeStyle { @@ -40,6 +46,7 @@ impl ChromeStyle { weak_text: style.inactive_text, accent: style.accent, font: style.font.clone(), + right_to_left: style.right_to_left, } } } @@ -248,8 +255,21 @@ pub fn clamp(rect: Rect, bounds: Rect) -> Rect { Rect::from_min_size(min, size) } +/// `text` on one line no wider than `width`, cut at the end with '…'. egui does not wrap a +/// host's pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job. +pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> Arc { + let galley = text.into_galley(ui, Some(egui::TextWrapMode::Truncate), width, font); + if galley.size().x <= width + 0.5 { + return galley; + } + let mut job = (*galley.job).clone(); + job.wrap = egui::text::TextWrapping::truncate_at_width(width); + ui.painter().layout_job(job) +} + /// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt -/// in from the right edge. The bar senses clicks and drags; double-clicks are the caller's. +/// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses +/// clicks and drags; double-clicks are the caller's. pub struct TitleBar<'a> { id: Id, move_label: &'a str, @@ -274,7 +294,7 @@ impl<'a> TitleBar<'a> { Self { id, move_label, title: None, menu: None, close: None, movable: true } } - /// Text at the left instead of the grip lines. + /// Text at the leading edge instead of the grip lines, cut with '…' to fit. pub fn title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self @@ -302,17 +322,27 @@ impl<'a> TitleBar<'a> { let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); painter.rect_filled(rect, 0.0, style.title_background); let side = rect.height(); + // Laid out left to right, then mirrored in a right-to-left layout. + let mirror = |r: Rect| { + if style.right_to_left { + Rect::from_x_y_ranges((rect.left() + rect.right() - r.right())..=(rect.left() + rect.right() - r.left()), r.y_range()) + } else { + r + } + }; let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side))); let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0); let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom()))); let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left()); - let bar_rect = Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom())); + let bar_rect = mirror(Rect::from_min_max(rect.min, pos2(buttons.max(rect.left()), rect.bottom()))); + let (close_rect, menu_rect) = (close_rect.map(mirror), menu_rect.map(mirror)); let bar = ui.interact(bar_rect, self.id, Sense::click_and_drag()); let bar = if self.movable { bar.on_hover_cursor(egui::CursorIcon::Grab) } else { bar }; bar.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.move_label)); if let Some(title) = self.title { - let galley = title.into_galley(ui, Some(egui::TextWrapMode::Truncate), (bar_rect.width() - 12.0).max(0.0), style.font.clone()); - painter.galley(pos2(rect.left() + 6.0, rect.center().y - galley.size().y * 0.5), galley, style.text); + let galley = truncated(ui, title, (bar_rect.width() - 12.0).max(0.0), style.font.clone()); + let x = if style.right_to_left { bar_rect.right() - 6.0 - galley.size().x } else { bar_rect.left() + 6.0 }; + painter.galley(pos2(x, rect.center().y - galley.size().y * 0.5), galley, style.text); } else { let grip = Stroke::new(0.6, style.weak_text); for k in 0..2 { diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index da75068b..e0b26d60 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -7,7 +7,7 @@ //! let mut params = params.clone(); //! params["command"] = json!(id); //! let command: DockCommand = serde_json::from_value(params)?; -//! command.apply(&mut app.workspace, |p| app.permissions(p))?; +//! command.apply(&mut app.workspace, style.right_to_left, |p| app.permissions(p))?; //! ``` use std::hash::Hash; @@ -40,7 +40,8 @@ const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; /// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] | /// /// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`, -/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). `before` +/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). The side +/// zones map onto the anchor's container with [`Placement::for_zone`]. `before` /// names the tab to insert before; `null` appends. `group` moves the panel's whole group, /// `column` its dock column. `side` is `Start` or `End`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -232,21 +233,29 @@ fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(deserializer: D) -> T::deserialize(deserializer) } -/// `zone` and `before` as a placement. `default` is the zone when none is given. -fn placement(zone: Option, before: &Option>, default: ZoneName) -> Result, DockError> { - let before = before.clone(); - Ok(match (zone.unwrap_or(default), before) { - (ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before }, - (ZoneName::Tab, None) => Placement::Tab { before: None }, - (ZoneName::Center, None) => Placement::Split(Zone::Center), - (_, Some(_)) => return Err(DockError::InvalidPlacement), - (ZoneName::Left, None) => Placement::Split(Zone::Left), - (ZoneName::Right, None) => Placement::Split(Zone::Right), - (ZoneName::Top, None) => Placement::Split(Zone::Top), - (ZoneName::Bottom, None) => Placement::Split(Zone::Bottom), - (ZoneName::Above, None) => Placement::Group { below: false }, - (ZoneName::Below, None) => Placement::Group { below: true }, - }) +/// Where a command puts what it moves relative to `anchor`: `zone` and `before`, with side +/// zones mapped by [`Placement::for_zone`]. `default` is the zone when none is given. +struct Zoned<'a, P> { + layout: &'a Layout

, + right_to_left: bool, +} + +impl Zoned<'_, P> { + fn placement(&self, anchor: &P, zone: Option, before: &Option>, default: ZoneName) -> Result, DockError> { + let side = |zone| Placement::for_zone(self.layout, anchor, zone, self.right_to_left); + Ok(match (zone.unwrap_or(default), before.clone()) { + (ZoneName::Tab | ZoneName::Center, Some(before)) => Placement::Tab { before }, + (ZoneName::Tab, None) => Placement::Tab { before: None }, + (ZoneName::Center, None) => Placement::Split(Zone::Center), + (_, Some(_)) => return Err(DockError::InvalidPlacement), + (ZoneName::Left, None) => side(Zone::Left)?, + (ZoneName::Right, None) => side(Zone::Right)?, + (ZoneName::Top, None) => side(Zone::Top)?, + (ZoneName::Bottom, None) => side(Zone::Bottom)?, + (ZoneName::Above, None) => Placement::Group { below: false, height: None }, + (ZoneName::Below, None) => Placement::Group { below: true, height: None }, + }) + } } /// How much of the layout a command moves. @@ -319,8 +328,13 @@ impl DockCommand

{ /// belong to the application: those return [`DockError::NotAllowed`]; use [`Self::apply`]. /// A panel or anchor that is not shown returns [`DockError::MissingPanel`] or /// [`DockError::MissingTarget`]. - pub fn to_actions(&self, workspace: &Workspace

) -> Result>, DockError> { + /// + /// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked + /// anchor they make a new column or group, and `right_to_left` is the + /// [`super::DockStyle::right_to_left`] the layout is drawn with. + pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool) -> Result>, DockError> { let layout = &workspace.layout; + let zoned = Zoned { layout, right_to_left }; let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) }; let one = |action: Action

| Ok(vec![action]); match self { @@ -329,7 +343,7 @@ impl DockCommand

{ shown(panel)?; let placement = match given { Some(placement) => placement.clone(), - None => placement(*zone, before, ZoneName::Tab)?, + None => zoned.placement(anchor, *zone, before, ZoneName::Tab)?, }; one(Unit::of(*group, false).to(layout, panel, Target::At { anchor: anchor.clone(), placement })?) } @@ -358,7 +372,7 @@ impl DockCommand

{ shown(panel)?; let unit = Unit::of(*group, *column); if let Some(anchor) = anchor { - let placement = placement(*zone, before, ZoneName::Tab)?; + let placement = zoned.placement(anchor, *zone, before, ZoneName::Tab)?; return one(unit.to(layout, panel, Target::At { anchor: anchor.clone(), placement })?); } if let Some(side) = side { @@ -425,7 +439,7 @@ impl DockCommand

{ DockOperation::Float { panel, rect } => one(Action::Float { panel: panel.clone(), rect: *rect }), DockOperation::MoveFloating { panel, rect } => one(Action::MoveFloating { panel: panel.clone(), rect: *rect }), DockOperation::Move { panel, target, zone, before } => { - let placement = placement(*zone, before, ZoneName::Center)?; + let placement = zoned.placement(target, *zone, before, ZoneName::Center)?; one(Action::Move { panel: panel.clone(), anchor: target.clone(), placement }) } DockOperation::SetStackOpen { panel, open } => one(Action::SetStackOpen { panel: panel.clone(), open: *open }), @@ -442,7 +456,7 @@ impl DockCommand

{ /// the application can release their state. Showing a hidden panel restores its last /// location; Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. - pub fn apply(&self, workspace: &mut Workspace

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + pub fn apply(&self, workspace: &mut Workspace

, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { let show = match self { Self::Activate { panel } | Self::Dock { panel, anchor: None, side: None, .. } @@ -457,7 +471,7 @@ impl DockCommand

{ workspace.locked = locked.unwrap_or(!workspace.locked); return Ok(Vec::new()); } - let actions = self.to_actions(workspace)?; + let actions = self.to_actions(workspace, right_to_left)?; workspace.apply_all(actions, permissions) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index b106a39d..c84051bf 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -40,14 +40,14 @@ fn parse(params: Value) -> Result, serde_json::Error> { /// The actions `params` makes on a fresh workspace. fn actions(params: Value) -> Vec> { let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - command.to_actions(&workspace()).unwrap_or_else(|e| panic!("{params}: {e}")) + command.to_actions(&workspace(), false).unwrap_or_else(|e| panic!("{params}: {e}")) } /// Apply `params` to a fresh workspace; return it and the panels closed. fn applied(params: Value) -> (Workspace

, Vec

) { let mut workspace = workspace(); let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - let closed = command.apply(&mut workspace, permissions).unwrap_or_else(|e| panic!("{params}: {e}")); + let closed = command.apply(&mut workspace, false, permissions).unwrap_or_else(|e| panic!("{params}: {e}")); (workspace, closed) } @@ -56,7 +56,7 @@ fn rejected(params: Value, error: DockError) { let mut workspace = workspace(); let before = workspace.clone(); let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - assert_eq!(command.apply(&mut workspace, permissions), Err(error), "{params}"); + assert_eq!(command.apply(&mut workspace, false, permissions), Err(error), "{params}"); assert_eq!(workspace, before, "{params}"); } @@ -90,12 +90,15 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { assert_eq!(actions(with(json!({"before": "channels"}))), mv(tab(Some("channels")))); assert_eq!(actions(with(json!({"zone": "center", "before": null}))), mv(tab(None))); assert_eq!(actions(with(json!({"zone": "center"}))), mv(Placement::Split(Zone::Center))); - assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Split(Zone::Left))); - assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true })); + assert_eq!(actions(with(json!({"zone": "Left"}))), mv(Placement::Column { after: false })); + assert_eq!(actions(with(json!({"zone": "below"}))), mv(Placement::Group { below: true, height: None })); assert_eq!(actions(with(json!({"placement": {"Column": {"after": true}}}))), mv(Placement::Column { after: true })); assert_eq!( actions(with(json!({"group": true, "zone": "above"}))), - vec![Action::MoveGroup { panel: s("history"), to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false } } }] + vec![Action::MoveGroup { + panel: s("history"), + to: Target::At { anchor: s("layers"), placement: Placement::Group { below: false, height: None } } + }] ); // float: a docked panel, a lone floating panel, a group and a column. @@ -219,7 +222,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers"}))), to_layers(Placement::Split(Zone::Center))); assert_eq!( actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "zone": "left"}))), - to_layers(Placement::Split(Zone::Left)) + to_layers(Placement::Column { after: false }) ); assert_eq!(actions(op(json!({"operation": "move", "panel": "color", "target": "layers", "before": null}))), to_layers(tab(None))); assert_eq!( @@ -278,6 +281,33 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { let mut locked = workspace(); locked.locked = true; let before = locked.clone(); - assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, permissions).is_err()); + assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, permissions).is_err()); assert_eq!(locked, before); } + +#[test] +fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() { + let command = + |zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap(); + let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl) { + Ok(actions) => match actions.as_slice() { + [Action::Move { placement, .. }] => Ok(placement.clone()), + other => panic!("{other:?}"), + }, + Err(error) => Err(error), + }; + // A start dock is on the left edge left to right, on the right edge right to left. + assert_eq!(placed("right", "layers", false), Ok(Placement::Column { after: true })); + assert_eq!(placed("right", "layers", true), Ok(Placement::Column { after: false })); + assert_eq!(placed("left", "layers", true), Ok(Placement::Column { after: true })); + assert_eq!(placed("top", "layers", false), Ok(Placement::Group { below: false, height: None })); + assert_eq!(placed("bottom", "color", false), Ok(Placement::Group { below: true, height: None })); + assert_eq!(placed("left", "color", false), Err(DockError::InvalidPlacement)); + assert_eq!(placed("left", "canvas", true), Ok(Placement::Split(Zone::Left))); + + let (workspace, _) = applied(json!({"command": "window.panel.move", "panel": "history", "anchor": "layers", "zone": "right"})); + let Some(Node::Split { first, .. }) = &workspace.layout.root else { panic!() }; + let Node::Dock { columns, .. } = &**first else { panic!() }; + assert_eq!(columns.len(), 2); + assert_eq!(columns[1].groups[0].panels, vec![s("history")]); +} diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 7049543f..ea88d380 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -6,7 +6,7 @@ use std::hash::Hash; -use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_rect, valid_width}; +use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_height, valid_rect, valid_width}; use crate::layout::{SplitAxis, SplitSize}; /// Width of a new column when nothing better is known. @@ -84,6 +84,39 @@ pub enum Target

{ Float { rect: Option<[f32; 4]> }, } +impl Placement

{ + /// The placement for a drop zone on `anchor`'s group, in the vocabulary its container + /// accepts. `zone` is physical: `Left` is the left half on screen in either direction. + /// + /// | Anchor in | `Center` | `Top`, `Bottom` | `Left`, `Right` | + /// |---|---|---|---| + /// | the split tree | [`Placement::Split`] | `Split` | `Split` | + /// | a dock column | `Split(Center)` (join as tabs) | [`Placement::Group`] above or below | [`Placement::Column`] on that side | + /// | a floating window | `Split(Center)` | `Group` above or below | [`DockError::InvalidPlacement`] | + /// + /// A dock's `Column { after }` counts from its window edge, which depends on the dock's + /// side and `right_to_left` (see [`Side`]); this picks `after` so the new column lands on + /// the zone's side of the anchor's column. + pub fn for_zone(layout: &Layout

, anchor: &P, zone: Zone, right_to_left: bool) -> Result { + let place = layout.place(anchor).ok_or(DockError::MissingTarget)?; + Ok(match (place, zone) { + (Place::Node(_), zone) | (_, zone @ Zone::Center) => Placement::Split(zone), + (Place::Dock { .. } | Place::Floating { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None }, + (Place::Floating { .. }, _) => return Err(DockError::InvalidPlacement), + (Place::Dock { path, .. }, _) => { + let side = match layout.root.as_ref().and_then(|root| node_at(root, &path)) { + Some(Node::Dock { side, .. }) => *side, + _ => return Err(DockError::MissingTarget), + }; + // The physical edge of the dock: Start is left in a left-to-right layout. + let left_edge = (side == Side::Start) != right_to_left; + // `after` is away from the window edge: rightwards for a left dock. + Placement::Column { after: (zone == Zone::Right) == left_edge } + } + }) + } +} + /// What the Window menu shows for a panel. The model does not track the z-order of floating /// windows, so `Front` means "showing": the active tab of an expanded group in an expanded column. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -501,7 +534,14 @@ impl Layout

{ *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; Ok(()) } - Placement::Group { below } => { + Placement::Group { below, height } => { + if !valid_height(height) { + return Err(DockError::InvalidGeometry); + } + let mut moving = moving; + if height.is_some() { + moving.groups.iter_mut().for_each(|group| group.height = height); + } let (groups, index) = match &place { Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group), Place::Floating { window, group } => { diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index b924429f..b49c60a5 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -211,7 +211,7 @@ fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() { d.hold(bar(&d, 5), seam); assert!(d.painted(hline(307.0, 0.0..=240.0)), "a horizontal line across the column at the seam"); let out = d.step(vec![button(seam, false)]); - assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true } }))); + assert_eq!(moved(&out.actions), Some((5, &Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } }))); assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![5], vec![3]]]); assert!(d.layout.floating.is_empty()); } @@ -220,7 +220,7 @@ fn group_dropped_on_a_seam_inserts_between_and_shows_a_horizontal_line() { fn a_tab_dropped_on_a_seam_or_column_edge_moves_only_that_panel() { let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]])); let out = d.drag(d.tab(3).center(), pos2(120.0, 309.0)); - assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true } }]); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 2, placement: Placement::Group { below: true, height: None } }]); assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2], vec![3], vec![4]]]); d.settle(); d.drag(d.tab(4).center(), pos2(246.0, 200.0)); diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 2f7cfb58..008994c8 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -204,7 +204,8 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro // Released over another window's lower band: its groups stack below that window's. let out = d.drag(title, pos2(660.0, 480.0)); assert!( - out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true } } }), + out.actions + .contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true, height: None } } }), "{:?}", out.actions ); @@ -237,7 +238,8 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() { let seam = pos2(120.0, d.tab(4).unwrap().top()); let out = d.drag(title, seam); assert!( - out.actions.contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true } } }), + out.actions + .contains(&Action::MoveWindow { panel: 8, to: Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } } }), "{:?}", out.actions ); @@ -262,7 +264,8 @@ fn floating_stack_seams_and_bands_insert_groups() { let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center(); let out = d.drag(bar, pos2(660.0, 360.0)); assert!( - out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false } } }), + out.actions + .contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } } }), "{:?}", out.actions ); @@ -274,7 +277,11 @@ fn floating_stack_seams_and_bands_insert_groups() { let tab = d.tab(8).unwrap().center(); let seam = pos2(420.0, 237.0); let out = d.drag(tab, seam); - assert!(out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true } }), "{:?}", out.actions); + assert!( + out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true, height: None } }), + "{:?}", + out.actions + ); assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); // The divider between two open groups resizes both. diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index f9801054..0577a628 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -89,6 +89,8 @@ struct Driver { modifiers: Modifiers, focused: bool, transparent: bool, + /// Applied to the style every frame. + tweak: fn(&mut DockStyle), shapes: Vec, time: f64, } @@ -107,6 +109,7 @@ impl Driver { modifiers: Modifiers::NONE, focused: true, transparent: false, + tweak: |_| {}, shapes: Vec::new(), time: 0.0, }; @@ -126,13 +129,14 @@ impl Driver { time: Some(self.time), ..Default::default() }; - let (layout, content, area, transparent) = (&self.layout, &mut self.content, self.area, self.transparent); + let (layout, content, area, transparent, tweak) = (&self.layout, &mut self.content, self.area, self.transparent, self.tweak); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if transparent { style.background = Color32::TRANSPARENT; style.tab_background = Color32::TRANSPARENT; } + tweak(&mut style); ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { output = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); }); @@ -274,21 +278,24 @@ fn top_border_drags_the_group_and_the_menu_button_only_clicks() { } #[test] -fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_centre_floats() { +fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() { let canvas = || TestContent { protected: vec![1], ..Default::default() }; let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0)); - let mut d = Driver::with(frames(), canvas(), area); + let mut d = Driver::with(frames(), TestContent::default(), area); let out = d.drag(d.rect("drag-bar", 2).center(), pos2(20.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::At { anchor: 1, placement: Placement::Split(Zone::Left) })), "{:?}", out.actions); let Some(Node::Split { first, second, .. }) = &d.layout.root else { panic!() }; assert_eq!(**first, tabs(&[2, 3], 1)); assert_eq!(**second, tabs(&[1], 0)); - // The canvas centre takes nothing: no join, no preview; the group floats there. - let mut d = Driver::with(frames(), canvas(), area); - let out = d.drag(d.rect("drag-bar", 2).center(), pos2(150.0, 300.0)); - assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); - assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + // A protected canvas takes nothing, at its centre or its edge: no join, no split, no + // preview; the group floats there. + for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] { + let mut d = Driver::with(frames(), canvas(), area); + let out = d.drag(d.rect("drag-bar", 2).center(), at); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); + assert_eq!(d.layout.root, Some(tabs(&[1], 0))); + } } #[test] @@ -508,7 +515,7 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { h.run_steps(1); h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); - for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Group with"] { + for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group to"] { assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); } h.get_by_label("Close Tab Group").click(); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index cbcef4ef..62a2473b 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -254,7 +254,8 @@ fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() { assert!(d.filled(line), "with the insertion line under group 1"); let out = d.release(target); assert!( - out.actions.contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true } } }), + out.actions + .contains(&Action::MoveGroup { panel: 5, to: Target::At { anchor: 1, placement: Placement::Group { below: true, height: None } } }), "{:?}", out.actions ); diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 623c38e1..7e8a60ee 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -19,7 +19,7 @@ impl DockStyle { /// |---|---|---| /// | `dock.float` | `float_label` | Float panel | /// | `dock.close` | `close_label` | Close panel | - /// | `dock.move` | `move_label` | Move to group | + /// | `dock.movePanelTo` | `move_panel_to_label` | Move Panel to Group | /// | `dock.panels` | `panels_label` | Panels | /// | `dock.resize` | `resize_label` | Resize panels | /// | `dock.resizeWindow` | `resize_window_label` | Resize panel window | @@ -28,10 +28,12 @@ impl DockStyle { /// | `dock.minimize` | `minimize_label` | Minimize | /// | `dock.expand` | `expand_label` | Expand | /// | `dock.floatGroup` | `float_group_label` | Float Tab Group | - /// | `dock.groupWith` | `group_with_label` | Group with | + /// | `dock.moveGroupTo` | `move_group_to_label` | Move Group to | /// | `dock.moveColumn` | `move_column_label` | Move panel column | /// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons | /// | `dock.expandPanels` | `expand_panels_label` | Expand Panels | + /// | `dock.collapseColumn` | `collapse_column_label` | Collapse column to icons | + /// | `dock.expandColumn` | `expand_column_label` | Expand column | /// | `dock.moveWindow` | `move_window_label` | Move panel window | /// | `dock.closeWindow` | `close_window_label` | Close panel window | /// | `dock.undock` | `undock_label` | Undock Panel | @@ -52,11 +54,11 @@ impl DockStyle { self } - fn labels_mut(&mut self) -> [(&'static str, &mut String); 25] { + fn labels_mut(&mut self) -> [(&'static str, &mut String); 27] { [ ("dock.float", &mut self.float_label), ("dock.close", &mut self.close_label), - ("dock.move", &mut self.move_label), + ("dock.movePanelTo", &mut self.move_panel_to_label), ("dock.panels", &mut self.panels_label), ("dock.resize", &mut self.resize_label), ("dock.resizeWindow", &mut self.resize_window_label), @@ -65,10 +67,12 @@ impl DockStyle { ("dock.minimize", &mut self.minimize_label), ("dock.expand", &mut self.expand_label), ("dock.floatGroup", &mut self.float_group_label), - ("dock.groupWith", &mut self.group_with_label), + ("dock.moveGroupTo", &mut self.move_group_to_label), ("dock.moveColumn", &mut self.move_column_label), ("dock.collapseToIcons", &mut self.collapse_to_icons_label), ("dock.expandPanels", &mut self.expand_panels_label), + ("dock.collapseColumn", &mut self.collapse_column_label), + ("dock.expandColumn", &mut self.expand_column_label), ("dock.moveWindow", &mut self.move_window_label), ("dock.closeWindow", &mut self.close_window_label), ("dock.undock", &mut self.undock_label), diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 3d2da9ba..75391f6e 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -158,7 +158,7 @@ impl

Default for Layout

{ } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub enum Placement

{ @@ -168,7 +168,13 @@ pub enum Placement

{ /// `Tabs` and `Stack` nodes only. Split(Zone), /// A new group above or below the anchor's group, in a dock column or a floating stack. - Group { below: bool }, + /// `height` is the inserted group's preferred height ([`super::Group::height`]); `None` + /// keeps a moved group's own height, or shares the free height for a new group. + Group { + below: bool, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + height: Option, + }, /// A new column before (towards the window edge) or after the anchor's column. Column { after: bool }, /// A new frame along one edge of the docked layout, a quarter of its width or height: the @@ -306,29 +312,39 @@ pub enum Action

{ }, } -/// Rendering and command adapters can protect a document canvas while leaving panels movable. +/// What may happen to a panel. [`Layout::apply_with_permissions`] enforces these for every +/// action, whoever issues it. They also decide which gestures and menu items the renderer +/// offers; [`super::DockStyle::close_menu_items`] and [`super::DockStyle::close_button`] hide +/// the built-in close controls of a panel that stays closable by command. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Permissions { pub movable: bool, pub floatable: bool, pub closable: bool, + /// Other panels may join this panel's group as tabs. pub accepts_tabs: bool, + /// Other panels may split this panel's frame (`Placement::Split` with a side zone). + /// Outer-edge frames ([`Placement::Edge`]) are not splits of the anchor. + pub accepts_splits: bool, } impl Default for Permissions { fn default() -> Self { - Self { movable: true, floatable: true, closable: true, accepts_tabs: true } + Self { movable: true, floatable: true, closable: true, accepts_tabs: true, accepts_splits: true } } } impl Permissions { - pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false }; + /// A document canvas: it stays docked and open, and takes neither tabs nor side splits. + /// A group dropped on it floats there. An application that opens panels beside its canvas + /// uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`. + pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false, accepts_splits: false }; - /// Lock Workspace: nothing moves, floats or closes; tab drops keep their policy. + /// Lock Workspace: nothing moves, floats or closes; tab and split drops keep their policy. /// [`super::Workspace::apply`] applies this when the workspace is locked; an application /// with a global lock preference applies it in its own permissions callback. pub fn locked(self) -> Self { - Self { movable: false, floatable: false, closable: false, accepts_tabs: self.accepts_tabs } + Self { movable: false, floatable: false, closable: false, ..self } } } @@ -380,7 +396,7 @@ fn valid_size(size: SplitSize) -> bool { } } -fn valid_height(height: Option) -> bool { +pub(super) fn valid_height(height: Option) -> bool { height.is_none_or(|v| v.is_finite() && (0.0..=MAX_EXTENT).contains(&v)) } @@ -504,7 +520,7 @@ impl Layout

{ } /// The panels `panel` can join as a tab: every other panel whose permissions accept tabs. - /// A "Move to group" menu with an empty answer has nothing to offer and stays hidden. + /// A "Move Panel to Group" menu with an empty answer has nothing to offer and stays hidden. pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> { self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect() } @@ -540,10 +556,10 @@ impl Layout

{ let first = |g: Option<&Group

>| g.and_then(|g| g.panels.first()).cloned(); if let Some(anchor) = first(groups.get(group + 1)) { location.anchor = Some(anchor); - location.placement = Placement::Group { below: false }; + location.placement = Placement::Group { below: false, height: None }; } else if let Some(anchor) = first(group.checked_sub(1).and_then(|i| groups.get(i))) { location.anchor = Some(anchor); - location.placement = Placement::Group { below: true }; + location.placement = Placement::Group { below: true, height: None }; } else if groups.len() == 1 && let Place::Dock { path, column, .. } = &place && let Some(Node::Dock { columns, .. }) = self.root.as_ref().and_then(|root| node_at(root, path)) @@ -686,21 +702,23 @@ impl Layout

{ Ok(()) } - pub fn apply(&mut self, action: Action

) -> Result<(), DockError> { + /// [`Self::apply_with_permissions`] with default permissions. + pub fn apply(&mut self, action: Action

) -> Result, DockError> { self.apply_with_permissions(action, |_| Permissions::default()) } - /// Check application policy before making an atomic change. The callback must be stable - /// throughout one call. Registry validation remains with the application, particularly Open. - pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// Check application policy before making an atomic change, and return the panels the + /// action closed (see [`Self::closed_by`]) so the application can release their state. + /// The callback must be stable throughout one call. Registry validation remains with the + /// application, particularly Open. + pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.validate()?; match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { if !permissions(panel).movable { return Err(DockError::NotAllowed); } - let tab = matches!(placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); - if tab && !permissions(anchor).accepts_tabs { + if !placement_allowed(placement, permissions(anchor)) { return Err(DockError::NotAllowed); } } @@ -715,9 +733,7 @@ impl Layout

{ } match to { Target::Float { .. } if members.iter().any(|p| !permissions(p).floatable) => return Err(DockError::NotAllowed), - Target::At { anchor, placement: Placement::Tab { .. } | Placement::Split(Zone::Center) } - if !members.contains(anchor) && !permissions(anchor).accepts_tabs => - { + Target::At { anchor, placement } if !members.contains(anchor) && !placement_allowed(placement, permissions(anchor)) => { return Err(DockError::NotAllowed); } _ => {} @@ -735,26 +751,29 @@ impl Layout

{ } _ => {} } + let closed = self.closed_by(&action)?; let mut proposed = self.clone(); proposed.apply_inner(action)?; proposed.validate()?; *self = proposed; - Ok(()) + Ok(closed) } /// Apply every action in order, or none: the first rejection leaves the layout unchanged. - pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { + /// Returns every panel the actions closed, in order. + pub fn apply_all(&mut self, actions: impl IntoIterator>, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { let mut proposed = self.clone(); + let mut closed = Vec::new(); for action in actions { - proposed.apply_with_permissions(action, &permissions)?; + closed.extend(proposed.apply_with_permissions(action, &permissions)?); } *self = proposed; - Ok(()) + Ok(closed) } /// The panels a close action (`Close`, `CloseGroup`, `CloseWindow`, `CloseOthers`) would - /// remove, in group order, so the application can release their state once the action is - /// applied. Empty for other actions. + /// remove, in group order. Empty for other actions. The apply methods return the same + /// list; this one answers before applying. pub fn closed_by(&self, action: &Action

) -> Result, DockError> { Ok(match action { Action::Close { panel } => vec![panel.clone()], @@ -990,6 +1009,16 @@ impl Layout

{ } } +/// Whether the anchor's permissions take this placement: tabs need `accepts_tabs`, side +/// splits `accepts_splits`. +fn placement_allowed

(placement: &Placement

, anchor: Permissions) -> bool { + match placement { + Placement::Tab { .. } | Placement::Split(Zone::Center) => anchor.accepts_tabs, + Placement::Split(_) => anchor.accepts_splits, + _ => true, + } +} + fn unit_of

(action: &Action

) -> Unit { match action { Action::MoveColumn { .. } => Unit::Column, diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 1309aa71..5a6843eb 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -62,9 +62,9 @@ fn panels_of(groups: &[Group]) -> Vec> { #[test] fn group_moves_keep_order_active_tab_and_minimized_state() { let targets = [ - Target::At { anchor: 5, placement: Placement::Group { below: true } }, + Target::At { anchor: 5, placement: Placement::Group { below: true, height: None } }, Target::At { anchor: 5, placement: Placement::Column { after: true } }, - Target::At { anchor: 8, placement: Placement::Group { below: false } }, + Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } }, Target::NewDock { side: Side::Start }, Target::Float { rect: Some([20.0, 20.0, 200.0, 300.0]) }, Target::Float { rect: None }, @@ -143,7 +143,7 @@ fn column_moves_and_floats_keep_every_group() { #[test] fn window_docks_as_stacked_groups_or_merges_as_tabs() { let mut layout = docked(); - layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } }).unwrap(); + layout.apply(Action::MoveWindow { panel: 8, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } }).unwrap(); assert_eq!(panels_of(&dock(&layout, Side::End)[0].groups), [vec![2, 3], vec![4], vec![6, 7], vec![8]]); assert!(layout.floating.is_empty()); @@ -287,7 +287,7 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi Action::Move { panel: 2, anchor: 5, placement: Placement::Tab { before: None } }, Action::MoveGroup { panel: 2, to: Target::Float { rect: None } }, Action::MoveColumn { panel: 5, to: Target::NewDock { side: Side::Start } }, - Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true } } }, + Action::MoveWindow { panel: 6, to: Target::At { anchor: 4, placement: Placement::Group { below: true, height: None } } }, Action::Float { panel: 4, rect: [0.0, 0.0, 100.0, 100.0] }, Action::MoveFloating { panel: 6, rect: [0.0, 0.0, 100.0, 100.0] }, Action::Close { panel: 4 }, diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index ae6646ee..32d7240a 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -346,7 +346,8 @@ impl Renderer<'_, P> { if usable(button) { let response = ui.interact(button, self.id.with(("column-collapse", Some(first))), Sense::click()); self.register_interaction(ui.ctx(), response.id); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &label)); + let button_label = if column.iconic { &self.style.expand_column_label } else { &self.style.collapse_column_label }; + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), button_label)); // The arrows point where the column goes: towards the window edge when it collapses. let glyph = if (edge == Edge::Left) != column.iconic { "«" } else { "»" }; let color = if response.hovered() || response.has_focus() { self.style.text } else { self.style.inactive_text }; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index becbd1dc..1ac0b65f 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -109,7 +109,7 @@ impl Renderer<'_, P> { fn title_bar(&mut self, ui: &mut Ui, window: &Floating

, title: Rect, rect: Rect, style: &ChromeStyle) { let Some(first) = window.panels().next() else { return }; let movable = window.panels().all(|p| self.permissions(p).movable); - let closable = window.panels().all(|p| self.permissions(p).closable); + let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); let mut bar = TitleBar::new(self.id.with(("move-group", Some(first))), &move_label).movable(movable); if closable { @@ -256,7 +256,7 @@ impl Renderer<'_, P> { let anchor = members.first()?.clone(); return Some(DropProposal { anchor, - placement: Placement::Group { below: true }, + placement: Placement::Group { below: true, height: None }, preview: Rect::NOTHING, insertion: None, noop: true, @@ -266,7 +266,13 @@ impl Renderer<'_, P> { Some(panel) => (panel, true), None => (anchor(b)?, false), }; - return Some(DropProposal { anchor, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion: Some(line), noop: false }); + return Some(DropProposal { + anchor, + placement: Placement::Group { below, height: None }, + preview: Rect::NOTHING, + insertion: Some(line), + noop: false, + }); } let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?; if strip.contains(pointer) || same(panels) { @@ -281,7 +287,13 @@ impl Renderer<'_, P> { return None; }; let insertion = Some(line(if below { rect.bottom() } else { rect.top() })); - Some(DropProposal { anchor: anchor(panels)?, placement: Placement::Group { below }, preview: Rect::NOTHING, insertion, noop: false }) + Some(DropProposal { + anchor: anchor(panels)?, + placement: Placement::Group { below, height: None }, + preview: Rect::NOTHING, + insertion, + noop: false, + }) } /// What a group drag carries: the whole window rect for the only group of a floating diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index d39235bc..0296daf6 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -213,7 +213,7 @@ impl Renderer<'_, P> { pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { if self.style.profile == Profile::Frame { let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); - if owner.panels.len() > 1 && others && self.menu_action(ui, &self.style.close_others_label.clone()) { + if owner.panels.len() > 1 && others && self.style.close_menu_items && self.menu_action(ui, &self.style.close_others_label.clone()) { self.output.actions.push(Action::CloseOthers { panel: panel.clone() }); } if owner.kind == GroupKind::Frame { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 4ba0c0b8..2d1bc6f9 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -33,7 +33,8 @@ impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; let policy = self.permissions(selected); - let splits = kind == GroupKind::Frame; + let splits = kind == GroupKind::Frame && policy.accepts_splits; + let rtl = self.style.right_to_left; if stripless(panels, &*self.content) { self.contents(ui, selected, rect); self.targets.push(DropTarget { @@ -43,6 +44,7 @@ impl Renderer<'_, P> { tabs: vec![(selected.clone(), Rect::NOTHING)], allow_splits: splits, accepts_tabs: policy.accepts_tabs, + right_to_left: rtl, }); return; } @@ -50,10 +52,19 @@ impl Renderer<'_, P> { let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom()))); ui.painter().rect_filled(strip, 0.0, self.style.tab_background); let floating = matches!(kind, GroupKind::Floating { .. }); + // Laid out left to right, then mirrored in a right-to-left layout: tabs from the right + // edge, the header (the host's ≡) at the left. + let mirror = |r: Rect| { + if rtl { + Rect::from_x_y_ranges((strip.left() + strip.right() - r.right())..=(strip.left() + strip.right() - r.left()), r.y_range()) + } else { + r + } + }; let requested = self.content.group_header_width(panels, selected, floating); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { - let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max); + let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max)); let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header)); child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, panels, selected, floating); @@ -78,13 +89,13 @@ impl Renderer<'_, P> { let mut shown = Vec::new(); for (index, width) in &fit.shown { let Some(panel) = panels.get(*index) else { continue }; - let tab = Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height())); + let tab = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(*width, strip.height()))); self.tab(ui, panel, tab, *index == active, rect, Some(&owner)); shown.push((panel.clone(), tab)); x += width; } if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { - let overflow = Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height())); + let overflow = mirror(Rect::from_min_size(pos2(x, strip.top()), vec2(fit.overflow_width, strip.height()))); let response = ui.interact(overflow, self.id.with(("overflow", selected)), Sense::click()); self.register_interaction(ui.ctx(), response.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.panels_label)); @@ -104,10 +115,11 @@ impl Renderer<'_, P> { }); x += fit.overflow_width; } - let bar = Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom())); + let bar = + mirror(Rect::from_min_max(pos2(x.min(strip.right() - header_width), strip.top()), pos2(strip.right() - header_width, strip.bottom()))); self.group_handle(ui, "drag-bar", bar, &owner, movable); // Registered last, so the band wins over the tabs below it. - let border = Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom()))); + let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())))); self.group_handle(ui, "drag-border", border, &owner, movable); // Logical order, hidden tabs included without a hit area, for no-op detection. let tabs = @@ -119,6 +131,7 @@ impl Renderer<'_, P> { tabs, allow_splits: splits, accepts_tabs: policy.accepts_tabs, + right_to_left: rtl, }); if !minimized { self.contents(ui, selected, Rect::from_min_max(pos2(rect.left(), strip.bottom()), rect.max)); @@ -150,7 +163,11 @@ impl Renderer<'_, P> { let active = response.has_focus() || response.hovered(); if movable { // A grip mark at rest, centred in the bar's reserved width beside the header. - let grip = Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max); + let grip = if self.style.right_to_left { + Rect::from_min_max(hit.min, pos2((hit.left() + self.style.drag_bar_width).min(hit.right()), hit.bottom())) + } else { + Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max) + }; let stroke = Stroke::new(1.0, if active { self.style.text } else { self.style.inactive_text }); let half = (hit.height() * 0.2).min(4.0); for k in -1..=1 { @@ -192,10 +209,10 @@ impl Renderer<'_, P> { } /// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group - /// and Group with. `panel` is the panel whose menu this is. + /// and Move Group to. `panel` is the panel whose menu this is. fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) { let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p))); - let closable = all(&|p| p.closable); + let closable = self.style.close_menu_items && all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); if closable && self.menu_action(ui, &self.close_group_label()) { @@ -220,7 +237,7 @@ impl Renderer<'_, P> { } let targets = if movable { self.group_targets(owner.panels) } else { Vec::new() }; if !targets.is_empty() { - let logical = self.style.group_with_label.clone(); + let logical = self.style.move_group_to_label.clone(); let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.menu_button(text, |ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { @@ -278,21 +295,10 @@ impl Renderer<'_, P> { response } - /// A tab's label, cut at the end with '…' to fit `width`. egui does not wrap a host's - /// pre-laid-out `WidgetText::Galley` again, so that one is laid out again from its job. + /// A tab's label, cut at the end with '…' to fit `width`. pub(super) fn tab_label(&mut self, ui: &Ui, panel: &P, label: &str, color: Color32, width: f32) -> Arc { - let galley = self.content.label(ui, panel, label, &self.style.font, color).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - width, - self.style.font.clone(), - ); - if galley.size().x <= width + 0.5 { - return galley; - } - let mut job = (*galley.job).clone(); - job.wrap = egui::text::TextWrapping::truncate_at_width(width); - ui.painter().layout_job(job) + let text = self.content.label(ui, panel, label, &self.style.font, color); + crate::docking::chrome::truncated(ui, text, width, self.style.font.clone()) } /// True while a tab's focus came from the pointer, which shows no focus outline. Tab or an @@ -369,7 +375,7 @@ impl Renderer<'_, P> { let targets: Vec

= if policy.movable { self.layout.move_targets(panel, |p| self.permissions(p)).into_iter().cloned().collect() } else { Vec::new() }; if !targets.is_empty() { - let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text); + let text = self.content.action_label(ui, &self.style.move_panel_to_label, &self.style.font, self.style.text); let response = ui.menu_button(text, |ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for target in &targets { @@ -384,9 +390,11 @@ impl Renderer<'_, P> { } }); }); - response.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label)); + response + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_panel_to_label)); } - if policy.closable { + if policy.closable && self.style.close_menu_items { let text = self.content.action_label(ui, &self.style.close_label, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.close_label)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 613bf689..37414bc7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -127,7 +127,11 @@ impl Renderer<'_, P> { let panel_rect = Rect::from_min_max(pos2(rect.left(), top), pos2(rect.right(), (top + header + share).min(rect.bottom()))); let title_rect = Rect::from_min_max(panel_rect.min, pos2(panel_rect.right(), (top + header).min(panel_rect.bottom()))); self.tab(ui, &entry.panel, title_rect, entry.open, panel_rect, None); - let collapse = Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max); + let collapse = if self.style.right_to_left { + Rect::from_min_max(title_rect.min, pos2((title_rect.left() + 18.0).min(title_rect.right()), title_rect.bottom())) + } else { + Rect::from_min_max(pos2((title_rect.right() - 18.0).max(title_rect.left()), title_rect.top()), title_rect.max) + }; let response = ui.interact(collapse, self.id.with(("collapse", &entry.panel)), Sense::click()); self.register_interaction(ui.ctx(), response.id); response.widget_info(|| { @@ -152,8 +156,9 @@ impl Renderer<'_, P> { rect: panel_rect, tab_strip: Some(title_rect), tabs: vec![(entry.panel.clone(), title_rect)], - allow_splits: true, + allow_splits: self.permissions(&entry.panel).accepts_splits, accepts_tabs: self.permissions(&entry.panel).accepts_tabs, + right_to_left: self.style.right_to_left, }); top = panel_rect.bottom(); if entry.open && remaining > 0.0 { @@ -211,7 +216,8 @@ impl Renderer<'_, P> { ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); - if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) { + // Any button: a canvas pans with the middle button and opens menus with the secondary. + if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c0b5e004..66c0ad8c 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -28,7 +28,8 @@ pub struct DockStyle { /// Localize these action labels in the application adapter. pub float_label: String, pub close_label: String, - pub move_label: String, + /// Tab menu item that moves one panel into another group (submenu of target panels). + pub move_panel_to_label: String, pub panels_label: String, pub resize_label: String, pub resize_window_label: String, @@ -38,7 +39,8 @@ pub struct DockStyle { pub minimize_label: String, pub expand_label: String, pub float_group_label: String, - pub group_with_label: String, + /// Group menu item that moves the whole group into another group (submenu of targets). + pub move_group_to_label: String, /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, /// before the bar narrows. pub drag_bar_width: f32, @@ -74,8 +76,12 @@ pub struct DockStyle { pub right_to_left: bool, /// [D] Accessibility label of a column bar. pub move_column_label: String, + /// [D] Column menu and group menu items. pub collapse_to_icons_label: String, pub expand_panels_label: String, + /// [D] Accessibility labels of the column bar's »/« button, distinct from the menu items. + pub collapse_column_label: String, + pub expand_column_label: String, pub move_window_label: String, pub close_window_label: String, /// Frame profile menu labels. @@ -87,6 +93,12 @@ pub struct DockStyle { pub restore_frame_label: String, /// Shown where native windows are available (feature `native-windows`). pub new_window_label: String, + /// Show the built-in Close items in tab and group menus (Close panel, Close Tab Group, + /// Close Other Panels in Group) for closable panels. A host that draws its own Close items + /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. + pub close_menu_items: bool, + /// Show × on the title bar of a floating window whose panels are all closable. + pub close_button: bool, } /// Which arrangement a [`DockStyle`] renders and labels. @@ -114,7 +126,7 @@ impl DockStyle { font: FontId::proportional(12.0), float_label: "Float panel".into(), close_label: "Close panel".into(), - move_label: "Move to group".into(), + move_panel_to_label: "Move Panel to Group".into(), panels_label: "Panels".into(), resize_label: "Resize panels".into(), resize_window_label: "Resize panel window".into(), @@ -123,7 +135,7 @@ impl DockStyle { minimize_label: "Minimize".into(), expand_label: "Expand".into(), float_group_label: "Float Tab Group".into(), - group_with_label: "Group with".into(), + move_group_to_label: "Move Group to".into(), drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, @@ -142,6 +154,8 @@ impl DockStyle { move_column_label: "Move panel column".into(), collapse_to_icons_label: "Collapse to Icons".into(), expand_panels_label: "Expand Panels".into(), + collapse_column_label: "Collapse column to icons".into(), + expand_column_label: "Expand column".into(), move_window_label: "Move panel window".into(), close_window_label: "Close panel window".into(), undock_label: "Undock Panel".into(), @@ -151,6 +165,8 @@ impl DockStyle { maximize_label: "Maximize Frame".into(), restore_frame_label: "Restore Frame Size".into(), new_window_label: "Move Panel to New Window".into(), + close_menu_items: true, + close_button: true, } } @@ -185,6 +201,8 @@ impl DockStyle { pub struct DockOutput

{ pub actions: Vec>, pub error: Option, + /// The panel the user focused this frame: a clicked tab or menu entry, a drop, a raised + /// window, or a press of any pointer button in a panel body, stripless panels included. pub focused: Option

, /// Whole-floating-group gesture identity; host may display its own group drop preview. pub group_drag: Option

, diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index cd370e5a..3f946153 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -12,6 +12,9 @@ pub struct DropTarget

{ pub tabs: Vec<(P, Rect)>, pub allow_splits: bool, pub accepts_tabs: bool, + /// The strip runs right to left: the first tab is rightmost, and an insertion slot sits + /// at the right edge of the tab it precedes. + pub right_to_left: bool, } #[derive(Clone, Debug, PartialEq)] @@ -70,11 +73,18 @@ pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) - return None; } let index = before.and_then(|before| target.tabs.iter().position(|(panel, _)| panel == before)); + let rtl = target.right_to_left; + let leading = |rect: &Rect| if rtl { rect.right() } else { rect.left() }; + let visible = || target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect); let x = index .and_then(|index| target.tabs.get(index..)) - .and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.left())) + .and_then(|tabs| tabs.iter().find(|(_, rect)| usable(*rect)).map(|(_, rect)| leading(rect))) .unwrap_or_else(|| { - target.tabs.iter().filter(|(_, rect)| usable(*rect)).map(|(_, rect)| rect.right()).fold(strip.left(), f32::max).min(strip.right()) + if rtl { + visible().map(|rect| rect.left()).fold(strip.right(), f32::min).max(strip.left()) + } else { + visible().map(|rect| rect.right()).fold(strip.left(), f32::max).min(strip.right()) + } }); let width = strip.width().min(2.0); let left = (x - width * 0.5).max(strip.left()).min((strip.right() - width).max(strip.left())); @@ -98,7 +108,8 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg if !target.accepts_tabs { return None; } - let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| pointer.x < r.center().x); + let ahead = |r: &Rect| if target.right_to_left { pointer.x > r.center().x } else { pointer.x < r.center().x }; + let before = target.tabs.iter().filter(|(_, r)| usable(*r)).find(|(_, r)| ahead(r)); let before_panel = before.map(|(p, _)| p.clone()); let insertion = tab_insertion_marker(target, before_panel.as_ref())?; let old_order: Vec<_> = target.tabs.iter().map(|(p, _)| p).collect(); @@ -176,8 +187,8 @@ impl DockTarget

{ pub(super) fn to(&self) -> Target

{ match self { Self::ColumnEdge { anchor, after } => Target::At { anchor: anchor.clone(), placement: Placement::Column { after: *after } }, - Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true } }, - Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below } }, + Self::Seam { anchor } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: true, height: None } }, + Self::GroupBand { anchor, below } => Target::At { anchor: anchor.clone(), placement: Placement::Group { below: *below, height: None } }, Self::NewDock { side } => Target::NewDock { side: *side }, } } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 618ca268..ac070249 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -61,7 +61,7 @@ fn move_targets_skip_the_panel_itself_and_protected_anchors() { let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() }; assert!(!layout.move_targets(&1, guarded).contains(&&3)); - // A lone panel has nowhere to go — its "Move to group" menu hides + // A lone panel has nowhere to go — its "Move Panel to Group" menu hides // (storytold/lightcraft#1768). let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }; assert!(single.move_targets(&1, all).is_empty()); @@ -135,13 +135,15 @@ fn protected_canvas_remains_present_and_cannot_be_grouped_with_panels() { move_to(1, 3, Zone::Center), move_to(3, 1, Zone::Center), Action::OpenAt { panel: 1, anchor: 3, placement: Placement::Tab { before: None } }, + Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, ] { let mut next = layout.clone(); assert_eq!(next.apply_with_permissions(action, permissions), Err(DockError::NotAllowed)); assert_eq!(next, layout); } + let beside = |panel: &u32| if *panel == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }; let mut next = layout; - next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, permissions).unwrap(); + next.apply_with_permissions(Action::OpenAt { panel: 7, anchor: 1, placement: Placement::Split(Zone::Left) }, beside).unwrap(); assert!(next.contains(&1) && next.contains(&7)); } @@ -229,6 +231,7 @@ fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { .collect(), allow_splits: true, accepts_tabs: true, + right_to_left: false, } } @@ -266,7 +269,7 @@ fn long_action_sequence_never_loses_or_duplicates_a_panel() { 2 => Placement::Split(Zone::Top), 3 => Placement::Split(Zone::Bottom), 4 => Placement::Split(Zone::Center), - 5 => Placement::Group { below: seed & 1 == 0 }, + 5 => Placement::Group { below: seed & 1 == 0, height: None }, _ => Placement::Column { after: seed & 2 == 0 }, }; let to = match (seed >> 32) % 4 { @@ -1244,7 +1247,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx)); - for logical in ["Float panel", "Move to group", "Close panel"] { + for logical in ["Float panel", "Move Panel to Group", "Close panel"] { assert!( h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))), "custom action must paint: {logical}" @@ -1381,6 +1384,7 @@ fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() { tabs: panels.iter().map(|id| (*id, Rect::NOTHING)).collect(), allow_splits, accepts_tabs: true, + right_to_left: false, }; let proposal = resolve_drop(&1, rect.center(), &[target]).unwrap(); assert!(proposal.noop); @@ -1441,6 +1445,7 @@ fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() { ], allow_splits: true, accepts_tabs: true, + right_to_left: false, }; for (panel, positions) in [(1, [10.0, 50.0]), (2, [70.0, 110.0]), (3, [130.0, 210.0])] { for x in positions { @@ -1522,6 +1527,7 @@ fn overflow_noop_detection_uses_logical_order_including_hidden_tabs() { ], allow_splits: true, accepts_tabs: true, + right_to_left: false, }; assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); // Before the next visible tab crosses hidden tab 2 and really changes the order. @@ -1542,6 +1548,7 @@ fn insertion_markers_stay_inside_clipped_and_singleton_strips() { tabs: vec![(1, Rect::from_min_size(pos2(tab_left, 20.0), vec2(60.0, 30.0)))], allow_splits: true, accepts_tabs: true, + right_to_left: false, }; for x in [rect.left(), rect.center().x, rect.right()] { let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target)).unwrap(); @@ -1557,7 +1564,8 @@ fn insertion_markers_stay_inside_clipped_and_singleton_strips() { #[test] fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() { let rect = Rect::from_min_max(pos2(-0.001, 0.0), pos2(0.002, 30.0)); - let target = DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true }; + let target = + DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true, right_to_left: false }; let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target]).unwrap(); assert!(proposal.noop); assert!(rect.contains_rect(proposal.insertion.unwrap())); diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 9ce80b53..f7612993 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -1,5 +1,7 @@ //! Fixes from the DesignCraft trial: group float size, tab label elision, the drag ghost and -//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups. +//! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups; then +//! split permissions, body focus, right-to-left strips, close controls, menu and button names, +//! zone mapping, closed panels from `apply` and group heights. use super::*; @@ -190,7 +192,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { }); full.textures_delta.clear(); let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); - assert_eq!(values.len(), 25, "{debug}"); + assert_eq!(values.len(), 27, "{debug}"); assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); let unique: std::collections::HashSet<_> = values.iter().collect(); assert_eq!(unique.len(), values.len()); @@ -199,3 +201,192 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { assert!(window.contains("swatches")); assert!(!window.contains("layers")); } + +const AREA_600: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(600.0, 600.0) }; + +#[test] +fn a_protected_canvas_takes_no_side_split_and_a_group_dropped_on_its_edge_floats() { + let protected = |p: &u32| if *p == 0 { Permissions::PROTECTED } else { Permissions::default() }; + let split = Action::Move { panel: 4, anchor: 0, placement: Placement::Split(Zone::Left) }; + let mut layout = docked(); + assert_eq!(layout.apply_with_permissions(split.clone(), protected), Err(DockError::NotAllowed)); + let opt_in = |p: &u32| if *p == 0 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }; + assert!(layout.apply_with_permissions(split, opt_in).is_ok()); + + let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600); + let bar = d.rect("drag-bar", 2); + let out = d.drag(bar.center(), pos2(300.0, 300.0)); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); +} + +#[test] +fn a_press_in_a_stripless_panel_reports_it_focused() { + let mut d = Driver::with(docked(), TestContent { protected: vec![0], ..Default::default() }, AREA_600); + for pressed in [PointerButton::Primary, PointerButton::Secondary, PointerButton::Middle] { + let at = pos2(420.0, 300.0); + let press = Event::PointerButton { pos: at, button: pressed, pressed: true, modifiers: Modifiers::NONE }; + let out = d.step(vec![Event::PointerMoved(at), press]); + assert_eq!(out.focused, Some(0), "{pressed:?}"); + d.step(vec![Event::PointerButton { pos: at, button: pressed, pressed: false, modifiers: Modifiers::NONE }]); + d.step(vec![key(egui::Key::Escape)]); + } +} + +#[test] +fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() { + let mut d = Driver::with(frames(), TestContent { header: 30.0, ..Default::default() }, AREA_600); + d.tweak = |style| style.right_to_left = true; + d.step(vec![]); + d.step(vec![]); + let (first, second, bar) = (d.tab(2), d.tab(3), d.rect("drag-bar", 2)); + assert!((first.right() - 600.0).abs() < 1.0, "the first tab starts at the right edge: {first:?}"); + assert!(second.right() <= first.left() + 0.5, "{first:?} {second:?}"); + assert!(bar.right() <= second.left() + 0.5 && bar.left() >= 304.0 + 30.0 - 0.5, "the bar sits between the tabs and the header: {bar:?}"); + + // Just right of the first tab's centre is before it in a right-to-left strip. + let out = d.drag(d.tab(1).center(), pos2(first.center().x + 6.0, first.center().y)); + assert!(out.actions.contains(&Action::Move { panel: 1, anchor: 3, placement: Placement::Tab { before: Some(2) } }), "{:?}", out.actions); + + let strip = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let target = DropTarget { + anchor: 1, + rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 80.0)), + tab_strip: Some(strip), + tabs: vec![(1, Rect::from_min_max(pos2(60.0, 0.0), pos2(100.0, 20.0))), (2, Rect::from_min_max(pos2(20.0, 0.0), pos2(60.0, 20.0)))], + allow_splits: true, + accepts_tabs: true, + right_to_left: true, + }; + let drop = |x: f32| resolve_drop(&9, pos2(x, 10.0), std::slice::from_ref(&target)).unwrap(); + assert_eq!((drop(90.0).placement, drop(90.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(1) }, 99.0)); + assert_eq!((drop(70.0).placement, drop(70.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(2) }, 60.0)); + assert_eq!((drop(10.0).placement, drop(10.0).insertion.unwrap().center().x), (Placement::Tab { before: None }, 20.0)); +} + +#[test] +fn a_right_to_left_title_bar_puts_its_buttons_left_and_cuts_a_galley_title() { + let ctx = Context::default(); + let mut shapes = Vec::new(); + let mut rects = (Rect::NOTHING, Rect::NOTHING, Rect::NOTHING); + let bar = Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 18.0)); + for _ in 0..2 { + let mut full = ctx.run_ui(RawInput::default(), |ui| { + let mut style = DockStyle::from_ui(ui); + style.right_to_left = true; + let chrome = chrome::ChromeStyle::from_dock(&style); + let title = egui::WidgetText::Galley(ui.painter().layout_no_wrap("Paragraph Styles and More".into(), style.font.clone(), style.text)); + let shown = chrome::TitleBar::new(Id::new("title"), "Move").title(title).menu("Menu").close("Close").show(ui, bar, &chrome); + rects = (shown.bar.rect, shown.menu.unwrap().rect, shown.close.unwrap().rect); + }); + full.textures_delta.clear(); + shapes = full.shapes; + } + let (grip, menu, close) = rects; + assert!(close.center().x < menu.center().x && menu.right() <= grip.left() + 0.5, "{close:?} {menu:?} {grip:?}"); + assert!((grip.right() - bar.right()).abs() < 0.5); + let title = flat(&shapes) + .into_iter() + .find_map(|(_, shape)| match shape { + egui::Shape::Text(text) if text.galley.text().starts_with("Para") => Some(text), + _ => None, + }) + .unwrap(); + assert_eq!(last_char(&title.galley), Some('…')); + assert!(title.pos.x + title.galley.size().x <= grip.right() + 0.5 && title.pos.x >= grip.left()); +} + +#[test] +fn hidden_close_controls_leave_closing_to_the_host() { + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] }; + let close = Id::new(AREA).with(("move-group", Some(&5u32))).with("close"); + let mut d = Driver::new(layout); + assert!(d.ctx.read_response(close).is_some()); + d.tweak = |style| { + style.close_button = false; + style.close_menu_items = false; + }; + d.step(vec![]); + d.step(vec![]); + assert!(d.ctx.read_response(close).is_none()); + let at = d.tab(1).center(); + let secondary = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }; + d.step(vec![Event::PointerMoved(at), secondary(true)]); + d.step(vec![secondary(false)]); + d.step(vec![]); + let painted = |d: &Driver, label: &str| texts(d).iter().any(|(_, t)| t.galley.text() == label); + assert!(painted(&d, "Float panel"), "the tab menu is open"); + assert!(!painted(&d, "Close panel") && !painted(&d, "Close Tab Group")); + // The panels stay closable: the host's own Close applies and reports what it closed. + assert_eq!(d.layout.apply_with_permissions(Action::CloseWindow { panel: 5 }, |p| d.content.permissions(p)), Ok(vec![5, 6])); +} + +#[test] +fn apply_all_returns_every_panel_its_actions_closed() { + let mut layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] }; + let closed = layout + .apply_all([Action::Activate { panel: 2 }, Action::CloseOthers { panel: 2 }, Action::CloseWindow { panel: 6 }], |_| Permissions::default()); + assert_eq!(closed, Ok(vec![1, 3, 5, 6])); + assert_eq!(layout.apply(Action::Close { panel: 2 }), Ok(vec![2])); +} + +#[test] +fn the_column_button_and_the_column_menu_item_have_distinct_names() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, content: &mut TestContent| { + DockArea::new(Id::new(AREA)).show_content(ui, &docked(), &DockStyle::from_ui(ui), content); + }, + TestContent { protected: vec![0], ..Default::default() }, + ); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1); + assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 0); + let at = h.ctx.read_response(Id::new(AREA).with(("column-bar", Some(&2u32)))).unwrap().rect.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.query_all_by_label("Collapse to Icons").count(), 1); + assert_eq!(h.query_all_by_label("Collapse column to icons").count(), 1); +} + +#[test] +fn for_zone_maps_side_zones_onto_columns_groups_and_splits() { + let layout = Layout { root: docked().root, floating: vec![Floating::single(vec![5], 0, [300.0, 80.0, 200.0, 200.0])] }; + let zone = |anchor, zone, rtl| Placement::for_zone(&layout, &anchor, zone, rtl); + assert_eq!(zone(2, Zone::Right, false), Ok(Placement::Column { after: true })); + assert_eq!(zone(2, Zone::Right, true), Ok(Placement::Column { after: false })); + assert_eq!(zone(4, Zone::Bottom, false), Ok(Placement::Group { below: true, height: None })); + assert_eq!(zone(2, Zone::Center, false), Ok(Placement::Split(Zone::Center))); + assert_eq!(zone(5, Zone::Top, false), Ok(Placement::Group { below: false, height: None })); + assert_eq!(zone(5, Zone::Left, false), Err(DockError::InvalidPlacement)); + assert_eq!(zone(0, Zone::Left, true), Ok(Placement::Split(Zone::Left))); + assert_eq!(zone(9, Zone::Left, false), Err(DockError::MissingTarget)); + + // An end dock mirrors a start dock. + let mut end = docked(); + let Some(Node::Split { first, second, size, .. }) = &mut end.root else { panic!() }; + std::mem::swap(first, second); + *size = SplitSize::FixedSecond(240.0); + let Node::Dock { side, .. } = &mut **second else { panic!() }; + *side = Side::End; + assert_eq!(Placement::for_zone(&end, &2, Zone::Left, false), Ok(Placement::Column { after: true })); + assert_eq!(Placement::for_zone(&end, &2, Zone::Left, true), Ok(Placement::Column { after: false })); +} + +#[test] +fn a_group_placement_gives_the_inserted_group_its_height() { + let mut layout = docked(); + let below = |height| Placement::Group { below: true, height }; + layout.apply(Action::OpenAt { panel: 7, anchor: 4, placement: below(Some(180.0)) }).unwrap(); + layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 7, placement: below(Some(90.0)) } }).unwrap(); + layout.apply(Action::OpenAt { panel: 8, anchor: 4, placement: below(None) }).unwrap(); + let Some(Node::Split { first, .. }) = &layout.root else { panic!() }; + let Node::Dock { columns, .. } = &**first else { panic!() }; + let heights: Vec<_> = columns[0].groups.iter().map(|g| (g.panels.clone(), g.height)).collect(); + assert_eq!(heights, vec![(vec![4], None), (vec![8], None), (vec![7], Some(180.0)), (vec![2, 3], Some(90.0))]); + assert_eq!(layout.apply(Action::OpenAt { panel: 9, anchor: 4, placement: below(Some(f32::NAN)) }), Err(DockError::InvalidGeometry)); +} diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index ddf2ecee..aa7f2e3b 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -416,7 +416,7 @@ mod v1_fixtures { // Float the column by command, then drag its window back to the start edge. let float: DockCommand = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); - let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone())).unwrap(); + let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false).unwrap(); d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); d.settle(); assert!(start_columns(&d.layout).is_empty(), "{at}: floated"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 0e8b3ba6..96ea7ae3 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -18,7 +18,10 @@ Office apps that draw their own document with a few task panes beside it use Sides are logical: `Side::Start` is the left edge, or the right edge when `DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its -locale. +locale. Right to left, tab strips also mirror: the first tab is rightmost, the group header and +drag bar are at the left, and drop insertion slots follow. Accordion headers and floating title +bars (`chrome::TitleBar`, through `ChromeStyle::right_to_left`) mirror too. Frame splits keep +their sides. ## Model @@ -57,17 +60,25 @@ layout.apply(Action::Move { - `Move`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a group, a dock column or a floating window, named by any panel in it. - A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. -- A `Placement` is a tab position, a split zone, a new group above or below, a new column, or a - new frame on an outer edge. +- A `Placement` is a tab position, a split zone, a new group above or below (with an optional + preferred `height`), a new column, or a new frame on an outer edge. +- Containers take different placements: a dock column takes groups and columns, not splits. + `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or + centre zone onto what the anchor's container takes: a split in the frame tree, a new column on + that side or a new group above or below in a dock, a group above or below in a floating window. - Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move a floating window to its own OS window (`SetNative`, feature `native-windows`). Every action checks the whole result before it commits. A bad target, a duplicate id, a stale split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source -panel is never lost. +panel is never lost. `apply`, `apply_with_permissions` and `apply_all` return the panels the +actions closed, so the app can release their state; `closed_by` lists them before applying. -`Permissions` limit what may happen to a panel. `Permissions::PROTECTED` keeps a canvas docked -and open and takes no tabs, but allows splits beside it. `apply_with_permissions` applies them. +`Permissions` limit what may happen to a panel: `movable`, `floatable`, `closable`, +`accepts_tabs` and `accepts_splits`. `apply_with_permissions` enforces them for every action, +whoever issues it. `Permissions::PROTECTED` keeps a canvas docked and open and takes neither tabs +nor side splits; a group dropped on it floats there. An app that opens panels beside its canvas +uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`. ## Workspace @@ -98,7 +109,8 @@ survives. `DockOutput` also has: -- `focused`: the focused panel. +- `focused`: the panel the user focused this frame: a clicked tab, a drop, a raised window, or a + press of any pointer button in a panel body, bare and protected panels included. - `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and `DockEvent::ToggleAutoCollapse` (Dock). - `error`: a rejected gesture. @@ -113,6 +125,10 @@ Builder options on `DockArea`: | `raise_panel(panel)` | Raise and focus a panel's floating window this frame. | | `blocked_rects(rects)` | App overlays that docking gestures must ignore. | +`DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items +and the floating window ×. They hide controls only: a panel that stays `closable` still closes +by the app's own Close items and commands. + `DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a panel body. `begin_drag` hands a drag that started in the app's own chrome to the area. `docking::chrome` has the floating frame and title bar for apps that draw their own windows. @@ -141,7 +157,7 @@ let command: DockCommand = serde_json::from_value(params)?; if command.is_app_owned() { return app.workspace_command(command); } -let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?; +let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app.permissions(panel))?; ``` | Id | Params (`?` is optional) | @@ -160,7 +176,9 @@ let closed = command.apply(&mut app.workspace, |panel| app.permissions(panel))?; | `ui.dock` | `{action}`, or `{operation, …}` | - `zone` is `tab`, `center`, `left`, `right`, `top`, `bottom`, `above` or `below`. `before` names - the tab to insert before; `null` appends. + the tab to insert before; `null` appends. `left`, `right`, `top` and `bottom` follow + `Placement::for_zone`, so beside a docked panel they make a new column or group. `apply` and + `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when it floats. `side` is `Start` or `End`. - Without a value, `minimized`, `iconic` and `locked` toggle. From 6820f9810924ed7b2c21d2f2037014715fa1d46c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:25:49 +0000 Subject: [PATCH 030/160] panes: reserve the exact pane size, clip the body and name each title bar A docked pane draws its body in a child ui clipped to the pane, so a body wider than the pane no longer shifts a right or bottom pane's reserved rect into the document. PaneLabels::move_pane takes {title}, so each title bar's accessible name includes its pane ("Move Styles pane"). Pane title bars mirror with .rtl(true). --- crates/ui/src/panes/mod.rs | 21 +++++++++++++++------ crates/ui/src/panes/tests.rs | 34 ++++++++++++++++++++++++++++++++++ docs/ui-panes.md | 3 ++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index c90fb9f4..961c8053 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -152,7 +152,8 @@ pub struct PaneLabels { pub move_bottom: String, pub float: String, pub close: String, - /// Accessibility label of the title bar. + /// Accessibility label of the title bar. `{title}` is replaced with the pane's title, so + /// each title bar has its own name. pub move_pane: String, /// Accessibility label of the resize edges. pub resize: String, @@ -167,7 +168,7 @@ impl Default for PaneLabels { move_bottom: "Move to Bottom".into(), float: "Float".into(), close: "Close".into(), - move_pane: "Move pane".into(), + move_pane: "Move {title} pane".into(), resize: "Resize pane".into(), } } @@ -325,7 +326,7 @@ impl PaneHost { ) -> PaneOutput

{ let ctx = ui.ctx().clone(); let bounds = ui.available_rect_before_wrap(); - let style = ChromeStyle::from_ui(ui); + let style = ChromeStyle { right_to_left: self.rtl, ..ChromeStyle::from_ui(ui) }; let key = memory_id(self.id); let mut memory = ctx.data(|data| data.get_temp::(key)).unwrap_or_default(); let mut requests = Vec::new(); @@ -494,10 +495,17 @@ impl PaneHost { let title = Rect::from_min_size(top.min, vec2(top.width(), TITLE_BAR_HEIGHT)); ui.allocate_rect(title, Sense::hover()); let bar = self.chrome(ui, title, s, content, style, focus, bounds, requests); - Frame::NONE.inner_margin(self.margin).show(ui, |ui| { + // The pane reserves exactly its size and clips its body. egui cuts a panel wider + // than its exact size at its outer edge, so a wide body would shift a right or + // bottom pane's reserved rect inwards and the document would draw over the pane. + let body = Rect::from_min_max(pos2(top.left(), title.bottom()), top.max); + let mut child = ui.new_child(UiBuilder::new().max_rect(body).layout(*ui.layout())); + child.set_clip_rect(body.intersect(ui.clip_rect())); + Frame::NONE.inner_margin(self.margin).show(&mut child, |ui| { ui.set_min_width(ui.available_width()); content.body(ui, &s.pane); }); + ui.allocate_rect(body, Sense::hover()); bar }); let rect = shown.response.rect; @@ -661,8 +669,9 @@ impl PaneHost { ) -> Response { let movable = content.movable(&s.pane); let closable = content.closable(&s.pane); - let mut bar = - TitleBar::new(s.id.with("title"), &self.labels.move_pane).title(content.title(&s.pane)).menu(&self.labels.menu).movable(movable); + let title = content.title(&s.pane); + let move_label = self.labels.move_pane.replace("{title}", &title); + let mut bar = TitleBar::new(s.id.with("title"), &move_label).title(title).menu(&self.labels.menu).movable(movable); if closable { bar = bar.close(&self.labels.close); } diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index 837760be..16ea6dd0 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -306,3 +306,37 @@ fn pane_state_round_trips_through_json() { let json = serde_json::to_string(&state).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), state); } + +#[test] +fn a_body_wider_than_its_pane_is_clipped_and_the_document_starts_at_the_pane_edge() { + struct Wide; + impl PaneContent for Wide { + fn title(&self, pane: &Pane) -> String { + format!("{pane:?}") + } + fn body(&mut self, ui: &mut Ui, _: &Pane) { + ui.allocate_exact_size(vec2(400.0, 40.0), Sense::hover()); + } + fn default_state(&self, pane: &Pane) -> PaneState { + PaneState::docked(if *pane == Pane::Comments { Edge::End } else { Edge::Start }, 250.0) + } + } + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, document: &mut Rect| { + PaneHost::new("panes").show(ui, &[Pane::Nav, Pane::Comments], |_| None, &mut Wide); + *document = ui.available_rect_before_wrap(); + }, + Rect::NOTHING, + ); + h.run_steps(2); + let document = *h.state(); + assert_eq!(document.left(), L + 250.0, "{document:?}"); + assert_eq!(document.right(), R - 250.0, "{document:?}"); +} + +#[test] +fn each_title_bar_is_named_after_its_pane() { + let h = harness(App::new(&[Pane::Nav, Pane::Comments])); + assert_eq!(h.query_all_by_label("Move Nav pane").count(), 1); + assert_eq!(h.query_all_by_label("Move Comments pane").count(), 1); +} diff --git a/docs/ui-panes.md b/docs/ui-panes.md index b88a7d17..fd3d9a91 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -51,7 +51,8 @@ egui::CentralPanel::default().show(ui, |ui| document(ui)); | `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. | | `SidePolicy::OneSlot` | One closable pane per edge: the last one in `open`. Non-closable context panes (PivotTable Fields) show beside the slot. | | `.max_fraction(f)` | One docked pane takes at most `f` of the host's width (height for bottom panes). | -| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. | +| Chrome | Title, ▾ menu (app items, Move to Start, Move to End, Move to Bottom for bottom panes, Float, Close), ×, a resize edge with pointer and arrow keys. The title bar mirrors with `.rtl(true)`. Its accessible name is `PaneLabels::move_pane` with `{title}` replaced by the pane's title ("Move Styles pane"). | +| Size | A docked pane reserves exactly its size and clips its body, so a body wider than the pane never pushes into the document. | | Drag out | A docked pane's title dragged more than `DOCK_SNAP` (32 pt) out of the pane floats it under the pointer when dropped; dropped within 32 pt of the host's left or right edge it docks there. | | Floating | `FloatingFrame` with its resize handles; the title bar moves it. Escape or window focus loss during a move puts it back. A drop within 32 pt of the left or right edge docks it. Rects are clamped to the host area. | | Focus | `out.focused` is the pane holding keyboard focus or last clicked; a click or focus in the document clears it. F6 and Shift+F6 cycle through the shown panes and then report `out.to_document`: give focus to the document. `.f6(false)` leaves F6 to the app, which calls `panes::focus_next(ctx, id, reverse)` instead. | From f4f9e5b206a721d28ea1214ef8bf85b8e30bb779 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:26:31 +0000 Subject: [PATCH 031/160] docking: one drop preview painter for docking and panes chrome::drop_preview draws the accent outline, filled or not, for every docking destination and drag ghost and for the pane host's drop preview. --- crates/ui/src/docking/chrome.rs | 7 +++++++ crates/ui/src/docking/render/preview.rs | 20 +++++++------------- crates/ui/src/panes/mod.rs | 11 ++--------- docs/ui-docking.md | 3 ++- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index f5b6bb6b..3aeab529 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -245,6 +245,13 @@ impl<'a> FloatingFrame<'a> { } } +/// A drop preview: a 2 pt accent outline inside `rect`, filled with 15% accent when `filled`. +/// Docking and the pane host draw every destination and drag ghost with it. +pub fn drop_preview(painter: &egui::Painter, rect: Rect, accent: Color32, filled: bool) { + let fill = if filled { accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; + painter.rect(rect, 0.0, fill, Stroke::new(2.0, accent), StrokeKind::Inside); +} + /// Keep `rect` inside `bounds`, shrinking it when it is larger. pub fn clamp(rect: Rect, bounds: Rect) -> Rect { if !super::targets::usable(bounds) { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 985b4dd7..2ab0a836 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,8 +1,9 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Color32, Context, Key, Pos2, Rect, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2}; use super::{Renderer, clamp_float, dock::group_action}; +use crate::docking::chrome::drop_preview; use crate::docking::{ Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, surface::TAB_PADDING, @@ -137,7 +138,7 @@ impl Renderer<'_, P> { let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { let ghost = self.group_ghost(ctx, drag, pointer); - painter.rect(ghost, 0.0, self.style.accent.gamma_multiply(0.15), Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + drop_preview(&painter, ghost, self.style.accent, true); let strip = Rect::from_min_size(ghost.min, vec2(ghost.width(), self.style.tab_height.min(ghost.height()))); let labels = painter.with_clip_rect(strip.shrink(2.0)); let mut x = strip.left() + TAB_PADDING * 0.5; @@ -164,9 +165,8 @@ impl Renderer<'_, P> { } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); - let fill = if split { self.style.accent.gamma_multiply(0.15) } else { Color32::TRANSPARENT }; if crate::docking::targets::usable(proposal.preview) { - painter.rect(proposal.preview.shrink(1.0), 0.0, fill, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); } if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); @@ -256,19 +256,13 @@ impl Renderer<'_, P> { let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) { - painter.rect( - proposal.preview.shrink(1.0), - 0.0, - self.style.accent.gamma_multiply(0.15), - Stroke::new(2.0, self.style.accent), - StrokeKind::Inside, - ); + drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, true); } if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } } else if let Some(rect) = floating_rect { - painter.rect_stroke(rect, 0.0, Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + drop_preview(&painter, rect, self.style.accent, false); } } @@ -348,7 +342,7 @@ impl Renderer<'_, P> { if !released { let painter = self.preview_painter(ctx); if let Some(rect) = feedback.preview.filter(|rect| crate::docking::targets::usable(*rect)) { - painter.rect(rect, 0.0, self.style.accent.gamma_multiply(0.15), Stroke::new(2.0, self.style.accent), StrokeKind::Inside); + drop_preview(&painter, rect, self.style.accent, true); } if let Some(rect) = feedback.insertion.filter(|rect| crate::docking::targets::usable(*rect)) { painter.rect_filled(rect, 0.0, self.style.accent); diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 961c8053..9e2aeca4 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -11,10 +11,7 @@ use std::fmt::Debug; use std::hash::Hash; use std::ops::RangeInclusive; -use egui::{ - Color32, Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, - vec2, -}; +use egui::{Context, Frame, Id, Key, LayerId, Margin, Modifiers, Order, Pos2, Rect, Response, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; use crate::docking::PanelLimits; use crate::docking::chrome::{self, ChromeStyle, FloatingFrame, MoveStep, TitleBar}; @@ -599,7 +596,7 @@ impl PaneHost { Some(_) => Rect::from_x_y_ranges(bounds.right() - rect.width()..=bounds.right(), bounds.y_range()), None => float, }; - paint_preview(preview, shape, style.accent); + chrome::drop_preview(preview, shape.shrink(1.0), style.accent, true); } #[allow(clippy::too_many_arguments)] @@ -714,10 +711,6 @@ impl PaneHost { } } -fn paint_preview(painter: &egui::Painter, rect: Rect, accent: Color32) { - painter.rect(rect.shrink(1.0), 0.0, accent.gamma_multiply(0.15), Stroke::new(2.0, accent), StrokeKind::Inside); -} - fn rect_array(rect: Rect) -> [f32; 4] { [rect.min.x, rect.min.y, rect.width(), rect.height()] } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 96ea7ae3..35c8612a 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -131,7 +131,8 @@ by the app's own Close items and commands. `DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a panel body. `begin_drag` hands a drag that started in the app's own chrome to the area. -`docking::chrome` has the floating frame and title bar for apps that draw their own windows. +`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview +docking and `craft_ui::panes` both draw) for apps that draw their own windows. ## Gestures From d91e5f6aea2772899bb314092605cf13beec30ee Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:04:52 +0000 Subject: [PATCH 032/160] docking: paint panel icons in code with DockContent::paint_icon --- crates/ui/src/docking/iconic_tests.rs | 43 ++++++++++++++++++++++++++ crates/ui/src/docking/render/iconic.rs | 14 +++++---- crates/ui/src/docking/surface.rs | 10 ++++++ docs/ui-docking.md | 3 +- 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 62a2473b..b9ad4a88 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -324,3 +324,46 @@ fn hit_rects_cover_every_gesture_source() { assert!(opened || !out.actions.is_empty(), "{hit:?} at {rect:?} does nothing"); } } + +/// Paints panel 1's icon as a dot in code; panel 2 has neither a painted icon nor an image. +struct Painted; + +impl DockContent for Painted { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &u32, color: egui::Color32) -> bool { + if *panel != 1 { + return false; + } + painter.circle_filled(rect.center(), 3.0, color); + true + } +} + +#[test] +fn an_icon_painted_in_code_replaces_the_initial_and_others_fall_back() { + let d = DockDriver::new(docked(&[&[1, 2]], true), Painted); + let (one, two) = (d.hit(&Hit::Icon(1)).unwrap(), d.hit(&Hit::Icon(2)).unwrap()); + let dots: Vec = d + .shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Circle(circle) if circle.radius == 3.0 => Some(circle.center), + _ => None, + }) + .collect(); + assert_eq!(dots.len(), 1, "{dots:?}"); + assert!(one.contains(dots[0]) && !two.contains(dots[0]), "the dot sits in panel 1's cell: {dots:?}"); + let initials: Vec = d + .shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.text() == "P" => Some(text.pos), + _ => None, + }) + .collect(); + assert_eq!(initials.len(), 1, "only panel 2 draws its initial: {initials:?}"); + assert!(two.contains(initials[0])); +} diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 79a37f27..7b908b4a 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -230,12 +230,14 @@ impl Renderer<'_, P> { let inset = ((self.style.iconic_width - size) * 0.5).max(2.0); let icon = Rect::from_min_size(pos2(cell.left() + inset, cell.center().y - size * 0.5), vec2(size, size)); let color = if current { self.style.text } else { self.style.inactive_text }; - if let Some(source) = self.content.icon(panel) { - egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell)); - } else { - let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect(); - painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside); - painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color); + if !self.content.paint_icon(&painter, icon, panel, color) { + if let Some(source) = self.content.icon(panel) { + egui::Image::new(source).tint(color).paint_at(ui, icon.intersect(cell)); + } else { + let initial: String = label.chars().next().into_iter().flat_map(char::to_uppercase).collect(); + painter.rect_stroke(icon, 3.0, Stroke::new(1.0, color), StrokeKind::Inside); + painter.text(icon.center(), egui::Align2::CENTER_CENTER, initial, self.style.font.clone(), color); + } } if labels { let text = self.content.label(ui, panel, &label, &self.style.font, color); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 66c0ad8c..619a87cf 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -371,9 +371,16 @@ pub trait DockContent

{ fn floating_size(&self, _panel: &P) -> Option { None } + /// An image for the panel's icon, tinted with the icon color. [`Self::paint_icon`] goes first. fn icon(&self, _panel: &P) -> Option> { None } + /// Paint the panel's icon in code into `rect` with `color`, and return true. False draws + /// [`Self::icon`], or the label's initial when that is `None`. `painter` is clipped to the + /// icon's cell. + fn paint_icon(&mut self, _painter: &egui::Painter, _rect: Rect, _panel: &P, _color: Color32) -> bool { + false + } /// Whether the panel may move to a native OS window (feature `native-windows`). fn allow_native(&self, _panel: &P) -> bool { false @@ -455,6 +462,9 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn icon(&self, panel: &P) -> Option> { self.inner.icon(panel) } + fn paint_icon(&mut self, painter: &egui::Painter, rect: Rect, panel: &P, color: Color32) -> bool { + self.inner.paint_icon(painter, rect, panel, color) + } fn allow_native(&self, panel: &P) -> bool { self.inner.allow_native(panel) } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 35c8612a..0434d20d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -104,7 +104,8 @@ its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, permissions, size limits, preferred floating size, panel menu and group header. `bare` hides the -tab strip of a lone panel. Panel bodies keep a stable egui id when they move, so widget memory +tab strip of a lone panel. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory survives. `DockOutput` also has: From f46828ea48eadc3aac415768e079a4a780a0696d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:07:03 +0000 Subject: [PATCH 033/160] docking: float commands without a rect at the drag size inside the dock area --- crates/ui/src/docking/command.rs | 71 ++++++++++++++++++++++--- crates/ui/src/docking/command_tests.rs | 45 ++++++++++++++++ crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/render/dock.rs | 1 + crates/ui/src/docking/render/group.rs | 1 + crates/ui/src/docking/render/iconic.rs | 5 +- crates/ui/src/docking/render/mod.rs | 2 + crates/ui/src/docking/render/preview.rs | 17 ++++++ crates/ui/src/docking/surface.rs | 64 ++++++++++++++++++++++ docs/ui-docking.md | 10 +++- 10 files changed, 208 insertions(+), 12 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index e0b26d60..58ef542d 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -7,17 +7,18 @@ //! let mut params = params.clone(); //! params["command"] = json!(id); //! let command: DockCommand = serde_json::from_value(params)?; -//! command.apply(&mut app.workspace, style.right_to_left, |p| app.permissions(p))?; +//! let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); +//! command.apply_in(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?; //! ``` use std::hash::Hash; use serde::{Deserialize, Deserializer, Serialize}; -use super::{Action, DockError, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place}; +use super::{Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place}; use crate::layout::SplitSize; -/// Where a panel floats when a command gives no position or size. +/// Where a panel floats when a command gives no position or size and no [`FloatSpace`]. const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; /// A docking command, tagged by its control id under `"command"`. @@ -332,7 +333,23 @@ impl DockCommand

{ /// `left`, `right`, `top` and `bottom` zones follow [`Placement::for_zone`]: beside a docked /// anchor they make a new column or group, and `right_to_left` is the /// [`super::DockStyle::right_to_left`] the layout is drawn with. + /// + /// A `window.panel.float` without a rect floats at 48, 48 in window coordinates; use + /// [`Self::to_actions_in`] to float inside the dock area instead. pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool) -> Result>, DockError> { + self.actions(workspace, right_to_left, None) + } + + /// Like [`Self::to_actions`], with `window.panel.float` floating into `space`: what it moves + /// takes the size a drag would float it at, inside the dock area and clear of the app's + /// menu bar. Pass [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, + /// `width` and `height` replace the matching values, and a group or column that is already + /// a whole floating window keeps its rect. + pub fn to_actions_in(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { + self.actions(workspace, right_to_left, Some(space)) + } + + fn actions(&self, workspace: &Workspace

, right_to_left: bool, space: Option<&FloatSpace

>) -> Result>, DockError> { let layout = &workspace.layout; let zoned = Zoned { layout, right_to_left }; let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) }; @@ -359,13 +376,32 @@ impl DockCommand

{ rect }; let any = given.iter().any(Option::is_some); - match (Unit::of(*group, *column), window) { + let unit = Unit::of(*group, *column); + let whole_window = layout.floating.iter().any(|w| w.contains(panel) && w.groups.len() == 1); + // The rect a drag would float the unit at, inside the area. + let placed = space.map(|space| { + let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width); + let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT[2]), DEFAULT_FLOAT[3]); + let size = match unit { + Unit::Panel => space.panel(panel), + Unit::Group => space.group(panel), + Unit::Column => space.column(panel), + }; + space.place(size.unwrap_or(fallback)) + }); + match (unit, window) { (Unit::Panel, Some(base)) if layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1) => { one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }) } - (Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(DEFAULT_FLOAT) }), + (Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(placed.unwrap_or(DEFAULT_FLOAT)) }), (Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }), - (unit, _) => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?), + (Unit::Group, Some(_)) if whole_window => { + one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?) + } + (unit, _) => { + let float = placed.map(rect).or_else(|| any.then(|| rect(DEFAULT_FLOAT))); + one(unit.to(layout, panel, Target::Float { rect: float })?) + } } } Self::Dock { panel, anchor, zone, before, group, column, side } => { @@ -457,6 +493,27 @@ impl DockCommand

{ /// location; Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. pub fn apply(&self, workspace: &mut Workspace

, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { + self.apply_with(workspace, right_to_left, None, permissions) + } + + /// Like [`Self::apply`], floating into `space` as [`Self::to_actions_in`] does. + pub fn apply_in( + &self, + workspace: &mut Workspace

, + right_to_left: bool, + space: &FloatSpace

, + permissions: impl Fn(&P) -> Permissions, + ) -> Result, DockError> { + self.apply_with(workspace, right_to_left, Some(space), permissions) + } + + fn apply_with( + &self, + workspace: &mut Workspace

, + right_to_left: bool, + space: Option<&FloatSpace

>, + permissions: impl Fn(&P) -> Permissions, + ) -> Result, DockError> { let show = match self { Self::Activate { panel } | Self::Dock { panel, anchor: None, side: None, .. } @@ -471,7 +528,7 @@ impl DockCommand

{ workspace.locked = locked.unwrap_or(!workspace.locked); return Ok(Vec::new()); } - let actions = self.to_actions(workspace, right_to_left)?; + let actions = self.actions(workspace, right_to_left, space)?; workspace.apply_all(actions, permissions) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index c84051bf..34f93c5f 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -311,3 +311,48 @@ fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_directio assert_eq!(columns.len(), 2); assert_eq!(columns[1].groups[0].panels, vec![s("history")]); } + +/// `layers` prefers 200x150 when it floats. +struct Sized; + +impl DockContent

for Sized { + fn body(&mut self, _: &mut egui::Ui, _: &P) {} + fn permissions(&self, panel: &P) -> Permissions { + permissions(panel) + } + fn floating_size(&self, panel: &P) -> Option { + (panel == "layers").then_some(egui::vec2(200.0, 150.0)) + } +} + +#[test] +fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { + // The area starts under a 30 pt menu bar. + let area = egui::Rect::from_min_max(egui::pos2(0.0, 30.0), egui::pos2(800.0, 600.0)); + let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area); + let space = float_space::

(&d.ctx, d.id).expect("the area records its float space"); + assert_eq!(space.area, area); + let float = |params: Value| parse(params).unwrap().to_actions_in(&workspace(), false, &space).unwrap(); + + let panel = float(json!({"command": "window.panel.float", "panel": "layers"})); + assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }], "floating_size, inset in the area"); + let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] = + float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..] + else { + panic!() + }; + assert_eq!([x, y], [24.0, 54.0]); + assert!(width > 100.0 && width <= 240.0 && height <= 320.0, "the docked group's size, capped: {width}x{height}"); + + // Given values stay exactly; a lone floating window keeps its rect. + let given = float(json!({"command": "window.panel.float", "panel": "layers", "x": 5, "y": 6, "width": 100, "height": 90})); + assert_eq!(given, vec![Action::Float { panel: s("layers"), rect: [5.0, 6.0, 100.0, 90.0] }]); + let color = float(json!({"command": "window.panel.float", "panel": "color", "group": true})); + assert_eq!(color, vec![Action::MoveGroup { panel: s("color"), to: Target::Float { rect: None } }]); + + // Without recorded sizes a unit floats at the layout's size, shrunk into a small area. + let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0))); + let applied = + parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions_in(&workspace(), false, &small); + assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }])); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index f3c8c623..b8462ec3 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -27,8 +27,8 @@ pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ - DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, HidePanels, PanelLimits, Profile, begin_drag, cancel_drag, - is_dragging, + DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, Profile, begin_drag, + cancel_drag, float_space, is_dragging, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 32d7240a..86c57be5 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -209,6 +209,7 @@ impl Renderer<'_, P> { if !usable(rect) { return; } + self.record_column_float(column.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect(), rect); let bar = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.column_bar_height).min(rect.bottom()))); self.column_bar(ui, column, first, bar, rect, edge); let body = Rect::from_min_max(pos2(rect.left(), bar.bottom()), rect.max); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 2d1bc6f9..74912e96 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -32,6 +32,7 @@ const BORDER_BAND: f32 = 4.0; impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; + self.record_float(panels, rect.size(), rect.size()); let policy = self.permissions(selected); let splits = kind == GroupKind::Frame && policy.accepts_splits; let rtl = self.style.right_to_left; diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 7b908b4a..59e25124 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -188,6 +188,9 @@ impl Renderer<'_, P> { let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag()); let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; self.register_interaction(ui.ctx(), response.id); + let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); + let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); + self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text }; let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect())); @@ -195,8 +198,6 @@ impl Renderer<'_, P> { painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color)); if movable && response.drag_started() { let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); - let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); - let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); let drag = GroupDrag { panel: first.clone(), original: [source.left(), source.top(), source.width(), source.height()], diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 37414bc7..d5cdd4b9 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -41,6 +41,8 @@ pub(super) struct Renderer<'a, P> { pub(super) frame: FrameState

, /// [D] Flyout and iconic column state for this frame. pub(super) iconic: Iconic

, + /// What a drag would float from each drawn panel, group and column, for commands. + pub(super) float_space: crate::docking::FloatSpace

, } impl Renderer<'_, P> { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 2ab0a836..5d662980 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -289,6 +289,23 @@ impl Renderer<'_, P> { size.max(limits.min + header).min(limits.max + header) } + /// Record what a drag floats from `panels`, one group drawn at `group` whose panels drag + /// from `panel` rects, for commands that float without a rect. + pub(super) fn record_float(&mut self, panels: &[P], panel: Vec2, group: Vec2) { + let group = self.group_float_size(panels, group); + for p in panels { + let size = self.float_size(p, panel); + self.float_space.panels.push((p.clone(), size)); + self.float_space.groups.push((p.clone(), group)); + } + } + + /// Record what a drag floats from a dock column drawn at `rect`. + pub(super) fn record_column_float(&mut self, members: Vec

, rect: Rect) { + let size = self.group_float_size(&members, rect.size()); + self.float_space.columns.extend(members.into_iter().map(|p| (p, size))); + } + /// Size of a group or column floated by a drag or Float Tab Group: the source width, and the /// tallest member's floating size, else the source height capped like a panel leaving a /// dock column. A group that already floats keeps its size. diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 619a87cf..ce06a9b9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -218,6 +218,68 @@ impl

Default for DockOutput

{ } } +/// Room between the dock area's top-left corner and a unit a command floats. +const FLOAT_INSET: f32 = 24.0; + +/// Where a docking command floats a panel, group or column when it gives no rect (see +/// `DockCommand::to_actions_in`, feature `serde`). [`DockArea`] records one each frame; read it +/// with [`float_space`]. +#[derive(Clone, Debug, PartialEq)] +pub struct FloatSpace

{ + /// The rect the area drew in. A floated unit goes inside it, 24 points from its top-left + /// corner, so it stays clear of the app's menu bar. + pub area: Rect, + /// What a drag floats from each drawn panel: the panel alone, its group and its dock column. + pub(super) panels: Vec<(P, Vec2)>, + pub(super) groups: Vec<(P, Vec2)>, + pub(super) columns: Vec<(P, Vec2)>, +} + +impl FloatSpace

{ + /// An area with no recorded sizes: commands float units at the layout's sizes. + pub fn new(area: Rect) -> Self { + Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() } + } +} + +/// Lookups for `DockCommand`, which needs feature `serde`. +#[cfg_attr(not(feature = "serde"), allow(dead_code))] +impl FloatSpace

{ + pub(super) fn panel(&self, panel: &P) -> Option { + Self::find(&self.panels, panel) + } + + pub(super) fn group(&self, panel: &P) -> Option { + Self::find(&self.groups, panel) + } + + pub(super) fn column(&self, panel: &P) -> Option { + Self::find(&self.columns, panel) + } + + /// The first record wins: an iconic panel is recorded at its icon before its flyout. + fn find(sizes: &[(P, Vec2)], panel: &P) -> Option { + sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size) + } + + /// `[x, y, width, height]` of `size` inside the area, inset from its top-left corner and + /// shrunk to fit. + pub(super) fn place(&self, size: Vec2) -> [f32; 4] { + let size = size.min(self.area.size()); + let min = (self.area.min + Vec2::splat(FLOAT_INSET)).min(self.area.max - size).max(self.area.min); + [min.x, min.y, size.x, size.y] + } +} + +/// The [`FloatSpace`] the area recorded on its last frame. +pub fn float_space(ctx: &Context, area_id: Id) -> Option> { + ctx.data(|d| d.get_temp(float_space_key(area_id))) +} + +fn float_space_key(area: Id) -> Id { + area.with("float-space") +} + /// A request for host state outside the layout. #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] @@ -680,6 +742,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { output: DockOutput::default(), frame, iconic: super::render::Iconic::new(self.auto_collapse), + float_space: FloatSpace::new(rect), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); @@ -690,6 +753,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); + ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 0434d20d..3ab542f5 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -159,7 +159,8 @@ let command: DockCommand = serde_json::from_value(params)?; if command.is_app_owned() { return app.workspace_command(command); } -let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app.permissions(panel))?; +let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); +let closed = command.apply_in(&mut app.workspace, style.right_to_left, &space, |panel| app.permissions(panel))?; ``` | Id | Params (`?` is optional) | @@ -183,6 +184,13 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, |panel| app. `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when it floats. `side` is `Start` or `End`. +- `window.panel.float` without a rect floats what it moves at the size a drag floats it at: the + panel's `floating_size`, else its docked group or column size, capped like a drag. It goes + inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. + The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it + and `apply_in` and `to_actions_in` take it. `x`, `y`, `width` and `height` replace the matching + values, and a group or column that is already a whole floating window keeps its rect. + `apply` and `to_actions` have no area and float at 48, 48 in window coordinates. - Without a value, `minimized`, `iconic` and `locked` toggle. - `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`, From 3aded979b5f04a1ace40ccea5ad70236667338f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:07:52 +0000 Subject: [PATCH 034/160] docking: report flyout changes with DockEvent::FlyoutChanged --- crates/ui/src/docking/render/iconic.rs | 15 ++++++++ crates/ui/src/docking/surface.rs | 6 ++++ crates/ui/src/docking/trial_tests.rs | 49 +++++++++++++++++++++++++- docs/ui-docking.md | 4 ++- 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 59e25124..4b7b3a04 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -47,6 +47,10 @@ pub fn flyout(ctx: &Context, area_id: Id) -> O ctx.data(|d| d.get_temp::>(key(area_id))).map(|open| open.0) } +/// The flyout last reported by [`crate::docking::DockEvent::FlyoutChanged`]. +#[derive(Clone)] +struct Reported

(Option

); + /// The flyout an iconic column asks for this frame. struct Request

{ group: Group

, @@ -318,6 +322,17 @@ impl Renderer<'_, P> { self.iconic.rect = Some(response.rect); } + /// Report a flyout that differs from the last one reported. + pub(in crate::docking) fn report_flyout(&mut self, ctx: &Context) { + let shown = flyout::

(ctx, self.id); + let key = self.id.with("flyout-reported"); + let reported = ctx.data(|d| d.get_temp::>(key)).and_then(|r| r.0); + if shown != reported { + ctx.data_mut(|d| d.insert_temp(key, Reported(shown.clone()))); + self.output.events.push(crate::docking::DockEvent::FlyoutChanged { panel: shown }); + } + } + /// Escape (outside a drag) or a primary press outside the flyout, its column and any menu. fn flyout_dismissed(&self, ctx: &Context, id: Id, column: Rect) -> bool { let escape = !is_dragging::

(ctx, self.id) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ce06a9b9..73d4108a 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -290,6 +290,11 @@ pub enum DockEvent

{ /// [D] Auto-Collapse Iconic Panels was chosen in a column menu. The host flips its /// preference and passes it to [`DockArea::auto_collapse_iconic`]. ToggleAutoCollapse, + /// [D] The area's flyout opened, switched to another panel or closed (`None`), whatever + /// changed it: an icon, Escape, a press outside, a raised panel, a group that is no longer + /// iconic, or [`super::open_flyout`] and [`super::close_flyout`]. It comes once per change, + /// in the frame that draws the change. + FlyoutChanged { panel: Option

}, } /// Which panels [`DockArea::hide_panels`] hides. Protected panels always stay. @@ -752,6 +757,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { renderer.floating(ui.ctx(), &view.floating); renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); + renderer.report_flyout(ui.ctx()); super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index f7612993..3036490e 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -1,7 +1,7 @@ //! Fixes from the DesignCraft trial: group float size, tab label elision, the drag ghost and //! grip, double-clicks on minimized groups, pointer focus, label keys and borrowed lookups; then //! split permissions, body focus, right-to-left strips, close controls, menu and button names, -//! zone mapping, closed panels from `apply` and group heights. +//! zone mapping, closed panels from `apply`, group heights and flyout events. use super::*; @@ -390,3 +390,50 @@ fn a_group_placement_gives_the_inserted_group_its_height() { assert_eq!(heights, vec![(vec![4], None), (vec![8], None), (vec![7], Some(180.0)), (vec![2, 3], Some(90.0))]); assert_eq!(layout.apply(Action::OpenAt { panel: 9, anchor: 4, placement: below(Some(f32::NAN)) }), Err(DockError::InvalidGeometry)); } + +struct Icons; + +impl DockContent for Icons { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} +} + +#[test] +fn every_flyout_change_reports_one_event_whatever_caused_it() { + use crate::docking::testing::DockDriver; + let mut column = Column::new(vec![Group::new(vec![1, 2], 0), Group::new(vec![3], 0)], 240.0); + column.iconic = true; + let dock = Node::Dock { side: Side::Start, columns: vec![column] }; + let canvas = Node::Tabs { panels: vec![0], active: 0 }; + let layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(canvas) }), + floating: Vec::new(), + }; + let mut d = DockDriver::new(layout, Icons); + let flyouts = |outputs: Vec>| -> Vec> { + outputs + .into_iter() + .flat_map(|out| out.events) + .filter_map(|event| match event { + DockEvent::FlyoutChanged { panel } => Some(panel), + _ => None, + }) + .collect() + }; + let icon = |d: &DockDriver, panel| d.hit(&Hit::Icon(panel)).unwrap().center(); + let quiet = |d: &mut DockDriver| (0..3).map(|_| d.step(Vec::new())).collect::>(); + + let (one, two) = (icon(&d, 1), icon(&d, 2)); + assert_eq!(flyouts(vec![d.press(one), d.release(one)]), [Some(1)], "an icon opens it"); + assert_eq!(flyouts(quiet(&mut d)), [], "an open flyout reports nothing more"); + assert_eq!(flyouts(vec![d.press(two), d.release(two)]), [Some(2)], "another icon switches it"); + assert_eq!(flyouts(vec![d.key(egui::Key::Escape)]), [None], "Escape closes it"); + open_flyout(&d.ctx, d.id, 3u32); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [Some(3)], "open_flyout"); + close_flyout::(&d.ctx, d.id); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [None], "close_flyout"); + d.raise = Some(1); + assert_eq!(flyouts(vec![d.step(Vec::new())]), [Some(1)], "raising an iconic panel opens it"); + let outside = pos2(600.0, 400.0); + assert_eq!(flyouts(vec![d.press(outside), d.release(outside)]), [None], "a press outside closes it"); + assert_eq!(flyouts(quiet(&mut d)), []); +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 3ab542f5..400e2888 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -113,7 +113,9 @@ survives. - `focused`: the panel the user focused this frame: a clicked tab, a drop, a raised window, or a press of any pointer button in a panel body, bare and protected panels included. - `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and - `DockEvent::ToggleAutoCollapse` (Dock). + `DockEvent::ToggleAutoCollapse` (Dock), and `DockEvent::FlyoutChanged { panel }` (Dock): the + flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an + icon, Escape, a press outside, a raised panel, `open_flyout` or `close_flyout`. - `error`: a rejected gesture. Builder options on `DockArea`: From 6261d9d39a0a109a832685f37af6ce75321caea1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:11:07 +0000 Subject: [PATCH 035/160] docking: DockCommand takes the float space --- crates/ui/src/docking/command.rs | 78 ++++++++------------------ crates/ui/src/docking/command_tests.rs | 24 ++++---- crates/ui/src/docking/surface.rs | 9 ++- crates/ui/tests/docking_workflows.rs | 3 +- docs/ui-docking.md | 9 +-- 5 files changed, 52 insertions(+), 71 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 58ef542d..3ae6d2ae 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -8,19 +8,18 @@ //! params["command"] = json!(id); //! let command: DockCommand = serde_json::from_value(params)?; //! let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); -//! command.apply_in(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?; +//! command.apply(&mut app.workspace, style.right_to_left, &space, |p| app.permissions(p))?; //! ``` use std::hash::Hash; use serde::{Deserialize, Deserializer, Serialize}; -use super::{Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place}; +use super::{ + Action, DockError, FloatSpace, Layout, Permissions, Placement, Side, Target, Workspace, Zone, dock_model::Place, surface::DEFAULT_FLOAT, +}; use crate::layout::SplitSize; -/// Where a panel floats when a command gives no position or size and no [`FloatSpace`]. -const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; - /// A docking command, tagged by its control id under `"command"`. /// /// Ids and params (`?` marks optional ones): @@ -334,22 +333,12 @@ impl DockCommand

{ /// anchor they make a new column or group, and `right_to_left` is the /// [`super::DockStyle::right_to_left`] the layout is drawn with. /// - /// A `window.panel.float` without a rect floats at 48, 48 in window coordinates; use - /// [`Self::to_actions_in`] to float inside the dock area instead. - pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool) -> Result>, DockError> { - self.actions(workspace, right_to_left, None) - } - - /// Like [`Self::to_actions`], with `window.panel.float` floating into `space`: what it moves - /// takes the size a drag would float it at, inside the dock area and clear of the app's - /// menu bar. Pass [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, - /// `width` and `height` replace the matching values, and a group or column that is already - /// a whole floating window keeps its rect. - pub fn to_actions_in(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { - self.actions(workspace, right_to_left, Some(space)) - } - - fn actions(&self, workspace: &Workspace

, right_to_left: bool, space: Option<&FloatSpace

>) -> Result>, DockError> { + /// `space` is where `window.panel.float` floats what it moves when it gives no rect: at the + /// size a drag would float it at, inside the dock area and clear of the app's menu bar. Pass + /// [`super::float_space`] of the area that draws `workspace`. Given `x`, `y`, `width` and + /// `height` replace the matching values, and a group or column that is already a whole + /// floating window keeps its rect. + pub fn to_actions(&self, workspace: &Workspace

, right_to_left: bool, space: &FloatSpace

) -> Result>, DockError> { let layout = &workspace.layout; let zoned = Zoned { layout, right_to_left }; let shown = |panel: &P| if layout.contains(panel) { Ok(()) } else { Err(DockError::MissingPanel) }; @@ -377,31 +366,26 @@ impl DockCommand

{ }; let any = given.iter().any(Option::is_some); let unit = Unit::of(*group, *column); - let whole_window = layout.floating.iter().any(|w| w.contains(panel) && w.groups.len() == 1); + let lone = layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1); + let whole = layout.floating.iter().any(|w| w.contains(panel) && w.groups.len() == 1); // The rect a drag would float the unit at, inside the area. - let placed = space.map(|space| { + let placed = || { let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width); - let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT[2]), DEFAULT_FLOAT[3]); + let fallback = egui::vec2(docked.filter(|_| !matches!(unit, Unit::Panel)).unwrap_or(DEFAULT_FLOAT.x), DEFAULT_FLOAT.y); let size = match unit { Unit::Panel => space.panel(panel), Unit::Group => space.group(panel), Unit::Column => space.column(panel), }; - space.place(size.unwrap_or(fallback)) - }); + rect(space.place(size.unwrap_or(fallback))) + }; match (unit, window) { - (Unit::Panel, Some(base)) if layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1) => { - one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }) - } - (Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: rect(placed.unwrap_or(DEFAULT_FLOAT)) }), + (Unit::Panel, Some(base)) if lone => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }), (Unit::Column, Some(base)) => one(Action::MoveFloating { panel: panel.clone(), rect: rect(base) }), - (Unit::Group, Some(_)) if whole_window => { - one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(DEFAULT_FLOAT)) })?) - } - (unit, _) => { - let float = placed.map(rect).or_else(|| any.then(|| rect(DEFAULT_FLOAT))); - one(unit.to(layout, panel, Target::Float { rect: float })?) - } + // Without given values the window keeps its rect and native state. + (Unit::Group, Some(base)) if whole => one(unit.to(layout, panel, Target::Float { rect: any.then(|| rect(base)) })?), + (Unit::Panel, _) => one(Action::Float { panel: panel.clone(), rect: placed() }), + (unit, _) => one(unit.to(layout, panel, Target::Float { rect: Some(placed()) })?), } } Self::Dock { panel, anchor, zone, before, group, column, side } => { @@ -492,27 +476,13 @@ impl DockCommand

{ /// the application can release their state. Showing a hidden panel restores its last /// location; Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. - pub fn apply(&self, workspace: &mut Workspace

, right_to_left: bool, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { - self.apply_with(workspace, right_to_left, None, permissions) - } - - /// Like [`Self::apply`], floating into `space` as [`Self::to_actions_in`] does. - pub fn apply_in( + /// `space` is where `window.panel.float` floats, as for [`Self::to_actions`]. + pub fn apply( &self, workspace: &mut Workspace

, right_to_left: bool, space: &FloatSpace

, permissions: impl Fn(&P) -> Permissions, - ) -> Result, DockError> { - self.apply_with(workspace, right_to_left, Some(space), permissions) - } - - fn apply_with( - &self, - workspace: &mut Workspace

, - right_to_left: bool, - space: Option<&FloatSpace

>, - permissions: impl Fn(&P) -> Permissions, ) -> Result, DockError> { let show = match self { Self::Activate { panel } @@ -528,7 +498,7 @@ impl DockCommand

{ workspace.locked = locked.unwrap_or(!workspace.locked); return Ok(Vec::new()); } - let actions = self.actions(workspace, right_to_left, space)?; + let actions = self.to_actions(workspace, right_to_left, space)?; workspace.apply_all(actions, permissions) } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 34f93c5f..4f2be2f6 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -33,6 +33,11 @@ fn workspace() -> Workspace

{ workspace } +/// An 800x600 dock area that has drawn nothing. +fn space() -> FloatSpace

{ + FloatSpace::new(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))) +} + fn parse(params: Value) -> Result, serde_json::Error> { serde_json::from_value(params) } @@ -40,14 +45,14 @@ fn parse(params: Value) -> Result, serde_json::Error> { /// The actions `params` makes on a fresh workspace. fn actions(params: Value) -> Vec> { let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - command.to_actions(&workspace(), false).unwrap_or_else(|e| panic!("{params}: {e}")) + command.to_actions(&workspace(), false, &space()).unwrap_or_else(|e| panic!("{params}: {e}")) } /// Apply `params` to a fresh workspace; return it and the panels closed. fn applied(params: Value) -> (Workspace

, Vec

) { let mut workspace = workspace(); let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - let closed = command.apply(&mut workspace, false, permissions).unwrap_or_else(|e| panic!("{params}: {e}")); + let closed = command.apply(&mut workspace, false, &space(), permissions).unwrap_or_else(|e| panic!("{params}: {e}")); (workspace, closed) } @@ -56,7 +61,7 @@ fn rejected(params: Value, error: DockError) { let mut workspace = workspace(); let before = workspace.clone(); let command = parse(params.clone()).unwrap_or_else(|e| panic!("{params}: {e}")); - assert_eq!(command.apply(&mut workspace, false, permissions), Err(error), "{params}"); + assert_eq!(command.apply(&mut workspace, false, &space(), permissions), Err(error), "{params}"); assert_eq!(workspace, before, "{params}"); } @@ -112,11 +117,11 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { ); assert_eq!( actions(json!({"command": "window.panel.float", "panel": "layers", "group": true})), - vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: None } }] + vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }] ); assert_eq!( actions(json!({"command": "window.panel.float", "panel": "history", "column": true})), - vec![Action::MoveColumn { panel: s("history"), to: Target::Float { rect: None } }] + vec![Action::MoveColumn { panel: s("history"), to: Target::Float { rect: Some([24.0, 24.0, 240.0, 320.0]) } }] ); // dock: to an anchor, to a side, back into the dock, and a hidden panel to its last place. @@ -281,7 +286,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { let mut locked = workspace(); locked.locked = true; let before = locked.clone(); - assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, permissions).is_err()); + assert!(parse(json!({"command": "window.panel.window", "panel": "layers"})).unwrap().apply(&mut locked, false, &space(), permissions).is_err()); assert_eq!(locked, before); } @@ -289,7 +294,7 @@ fn dock_commands_parse_every_documented_shape_and_map_to_atomic_actions() { fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_direction() { let command = |zone: &str, anchor: &str| parse(json!({"command": "window.panel.move", "panel": "history", "anchor": anchor, "zone": zone})).unwrap(); - let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl) { + let placed = |zone: &str, anchor: &str, rtl: bool| match command(zone, anchor).to_actions(&workspace(), rtl, &space()) { Ok(actions) => match actions.as_slice() { [Action::Move { placement, .. }] => Ok(placement.clone()), other => panic!("{other:?}"), @@ -332,7 +337,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let d = testing::DockDriver::new(workspace().layout, Sized).with_area(area); let space = float_space::

(&d.ctx, d.id).expect("the area records its float space"); assert_eq!(space.area, area); - let float = |params: Value| parse(params).unwrap().to_actions_in(&workspace(), false, &space).unwrap(); + let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap(); let panel = float(json!({"command": "window.panel.float", "panel": "layers"})); assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }], "floating_size, inset in the area"); @@ -352,7 +357,6 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { // Without recorded sizes a unit floats at the layout's size, shrunk into a small area. let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0))); - let applied = - parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions_in(&workspace(), false, &small); + let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small); assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }])); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 73d4108a..26c92c1e 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -221,8 +221,12 @@ impl

Default for DockOutput

{ /// Room between the dock area's top-left corner and a unit a command floats. const FLOAT_INSET: f32 = 24.0; +/// Size a command floats a panel at when its [`FloatSpace`] never drew it. +#[cfg_attr(not(feature = "serde"), allow(dead_code))] +pub(super) const DEFAULT_FLOAT: Vec2 = Vec2::new(260.0, 320.0); + /// Where a docking command floats a panel, group or column when it gives no rect (see -/// `DockCommand::to_actions_in`, feature `serde`). [`DockArea`] records one each frame; read it +/// `DockCommand::to_actions`, feature `serde`). [`DockArea`] records one each frame; read it /// with [`float_space`]. #[derive(Clone, Debug, PartialEq)] pub struct FloatSpace

{ @@ -236,7 +240,8 @@ pub struct FloatSpace

{ } impl FloatSpace

{ - /// An area with no recorded sizes: commands float units at the layout's sizes. + /// An area with no recorded sizes: commands float a panel at 260x320, and a group or + /// column at its dock column's width (else 260) by 320. pub fn new(area: Rect) -> Self { Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() } } diff --git a/crates/ui/tests/docking_workflows.rs b/crates/ui/tests/docking_workflows.rs index aa7f2e3b..3f46b521 100644 --- a/crates/ui/tests/docking_workflows.rs +++ b/crates/ui/tests/docking_workflows.rs @@ -416,7 +416,8 @@ mod v1_fixtures { // Float the column by command, then drag its window back to the start edge. let float: DockCommand = serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap(); - let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false).unwrap(); + let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap(); + let actions = float.to_actions(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space).unwrap(); d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}")); d.settle(); assert!(start_columns(&d.layout).is_empty(), "{at}: floated"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 400e2888..3dd46036 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -162,7 +162,7 @@ if command.is_app_owned() { return app.workspace_command(command); } let space = float_space(ctx, DOCK_ID).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())); -let closed = command.apply_in(&mut app.workspace, style.right_to_left, &space, |panel| app.permissions(panel))?; +let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |panel| app.permissions(panel))?; ``` | Id | Params (`?` is optional) | @@ -190,9 +190,10 @@ let closed = command.apply_in(&mut app.workspace, style.right_to_left, &space, | panel's `floating_size`, else its docked group or column size, capped like a drag. It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it - and `apply_in` and `to_actions_in` take it. `x`, `y`, `width` and `height` replace the matching - values, and a group or column that is already a whole floating window keeps its rect. - `apply` and `to_actions` have no area and float at 48, 48 in window coordinates. + and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats + a panel at 260x320 and a group or column at its column width by 320. `x`, `y`, `width` and + `height` replace the matching values, and a group or column that is already a whole floating + window keeps its rect. - Without a value, `minimized`, `iconic` and `locked` toggle. - `ui.dock` operations are those of LightCraft, PDFCraft, CADCraft and SoundCraft: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`, `move {target, zone?, before?}`, From c715d3f25fdfd615ae1685daa328776e9d7ad9a8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:22:34 +0000 Subject: [PATCH 036/160] docking: hosts seed a FloatSpace with panel sizes --- crates/ui/src/docking/command_tests.rs | 11 +++++++++++ crates/ui/src/docking/surface.rs | 7 +++++++ docs/ui-docking.md | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 4f2be2f6..e109a08c 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -359,4 +359,15 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let small = FloatSpace::new(egui::Rect::from_min_size(egui::pos2(0.0, 30.0), egui::vec2(200.0, 200.0))); let applied = parse(json!({"command": "window.panel.float", "panel": "layers", "group": true})).unwrap().to_actions(&workspace(), false, &small); assert_eq!(applied, Ok(vec![Action::MoveGroup { panel: s("layers"), to: Target::Float { rect: Some([0.0, 30.0, 200.0, 200.0]) } }])); + + // A host seeds the size of a panel the area never drew; a recorded size still wins. + let seeded = FloatSpace::new(area).with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); + let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded); + assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }])); + let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); + assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }]); +} + +fn float_with(space: &FloatSpace

, panel: &str) -> Vec> { + parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap() } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 26c92c1e..dc8f1419 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -245,6 +245,13 @@ impl FloatSpace

{ pub fn new(area: Rect) -> Self { Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() } } + + /// Adds the size a command floats `panel` at alone when the area has not drawn it, such as + /// a hidden panel or a headless run. A size the area recorded for the panel takes precedence. + pub fn with_panel_size(mut self, panel: P, size: Vec2) -> Self { + self.panels.push((panel, size)); + self + } } /// Lookups for `DockCommand`, which needs feature `serde`. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 3dd46036..9705a620 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -191,7 +191,9 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats - a panel at 260x320 and a group or column at its column width by 320. `x`, `y`, `width` and + a panel at 260x320 and a group or column at its column width by 320. + `FloatSpace::with_panel_size(panel, size)` gives a panel the area has not drawn (a hidden + panel, a headless run) the host's own size; a size the area recorded wins. `x`, `y`, `width` and `height` replace the matching values, and a group or column that is already a whole floating window keeps its rect. - Without a value, `minimized`, `iconic` and `locked` toggle. From 5d5d11f8546990ff508eae1442e894151660d8b7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:30:02 +0000 Subject: [PATCH 037/160] docking: window.panel.dock to a side without a dock moves one panel of a shared group Action::MovePanel moves one panel to a Target; NewDock and Float give it a group of its own. DockCommand::Dock with a side that has no dock used to fail with InvalidPlacement for a panel that shares its group. --- crates/ui/src/docking/command.rs | 2 +- crates/ui/src/docking/command_tests.rs | 10 ++++++++++ crates/ui/src/docking/model.rs | 25 +++++++++++++++++++++++++ docs/ui-docking.md | 4 ++-- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 3ae6d2ae..7a146f95 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -284,7 +284,7 @@ impl Unit { let panel = panel.clone(); Ok(match (self, to) { (Self::Panel, Target::At { anchor, placement }) => Action::Move { panel, anchor, placement }, - (Self::Panel, _) => return Err(DockError::InvalidPlacement), + (Self::Panel, to) => Action::MovePanel { panel, to }, (Self::Group, to) => Action::MoveGroup { panel, to }, (Self::Column, to) if floating => Action::MoveWindow { panel, to }, (Self::Column, to) => Action::MoveColumn { panel, to }, diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index e109a08c..905e1452 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -371,3 +371,13 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { fn float_with(space: &FloatSpace

, panel: &str) -> Vec> { parse(json!({"command": "window.panel.float", "panel": panel})).unwrap().to_actions(&workspace(), false, space).unwrap() } + +#[test] +fn dock_to_a_side_without_a_dock_takes_one_panel_out_of_a_shared_group() { + let params = json!({"command": "window.panel.dock", "panel": "layers", "side": "End"}); + assert_eq!(actions(params.clone()), vec![Action::MovePanel { panel: s("layers"), to: Target::NewDock { side: Side::End } }]); + let (workspace, _) = applied(params); + let side = |panel: &str| workspace.layout.location(&s(panel)).unwrap().group.and_then(|g| g.column).map(|c| c.side); + assert_eq!(side("layers"), Some(Side::End)); + assert_eq!(side("channels"), Some(Side::Start)); +} diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 75391f6e..690137bb 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -310,6 +310,12 @@ pub enum Action

{ panel: P, rect: Option<[f32; 4]>, }, + /// Move one panel, without the rest of its group. `Target::At` is [`Action::Move`]; + /// `NewDock` and `Float` give the panel a group of its own. + MovePanel { + panel: P, + to: Target

, + }, } /// What may happen to a panel. [`Layout::apply_with_permissions`] enforces these for every @@ -726,6 +732,18 @@ impl Layout

{ Action::Close { panel } if !permissions(panel).closable => return Err(DockError::NotAllowed), Action::MoveFloating { panel, .. } if !permissions(panel).movable => return Err(DockError::NotAllowed), Action::Open { anchor: Some(anchor), .. } if !permissions(anchor).accepts_tabs => return Err(DockError::NotAllowed), + Action::MovePanel { panel, to } => { + let policy = permissions(panel); + let allowed = policy.movable + && match to { + Target::Float { .. } => policy.floatable, + Target::At { anchor, placement } => placement_allowed(placement, permissions(anchor)), + Target::NewDock { .. } => true, + }; + if !allowed { + return Err(DockError::NotAllowed); + } + } Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { let members = self.members(panel, unit_of(&action))?; if members.iter().any(|p| !permissions(p).movable) { @@ -899,6 +917,13 @@ impl Layout

{ let moving = self.take_unit(panel, unit)?; self.insert_moving(moving, to.clone()) } + Action::MovePanel { panel, to } => match to { + Target::At { anchor, placement } => self.apply_inner(Action::Move { panel, anchor, placement }), + to => { + let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; + self.insert_moving(Moving::group(Group::new(vec![entry.panel], 0)), to) + } + }, Action::CloseGroup { ref panel } | Action::CloseWindow { ref panel } => self.take_unit(panel, unit_of(&action)).map(|_| ()), Action::CloseOthers { panel } => { for other in self.members(&panel, Unit::Group)? { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 9705a620..336367e7 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -57,8 +57,8 @@ layout.apply(Action::Move { # Ok::<(), craft_ui::docking::DockError>(()) ``` -- `Move`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a - group, a dock column or a floating window, named by any panel in it. +- `Move`, `MovePanel`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and + `MoveWindow` move a group, a dock column or a floating window, named by any panel in it. - A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. - A `Placement` is a tab position, a split zone, a new group above or below (with an optional preferred `height`), a new column, or a new frame on an outer edge. From b64cc4eac6d787564fbd959a7fc543e5df5f779e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:30:02 +0000 Subject: [PATCH 038/160] docking: window.panel.minimize toggles an accordion entry both ways Without minimized the command read only dock and floating groups, so a closed accordion entry stayed closed. --- crates/ui/src/docking/command.rs | 9 ++++++++- crates/ui/src/docking/command_tests.rs | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 7a146f95..11a0a8df 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -426,7 +426,14 @@ impl DockCommand

{ Self::CloseOthers { panel } => one(Action::CloseOthers { panel: panel.clone() }), Self::Minimize { panel, minimized } => { let place = layout.place(panel).ok_or(DockError::MissingPanel)?; - let current = layout.group(&place).is_some_and(|g| g.minimized); + // An accordion entry is minimized while it is closed. + let current = match &place { + Place::Node(path) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, path)) { + Some(super::Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && !e.open), + _ => false, + }, + place => layout.group(place).is_some_and(|g| g.minimized), + }; one(Action::SetMinimized { panel: panel.clone(), minimized: minimized.unwrap_or(!current) }) } Self::Iconic { panel, iconic } => { diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 905e1452..307d8a86 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -381,3 +381,14 @@ fn dock_to_a_side_without_a_dock_takes_one_panel_out_of_a_shared_group() { assert_eq!(side("layers"), Some(Side::End)); assert_eq!(side("channels"), Some(Side::Start)); } + +#[test] +fn minimize_without_a_value_toggles_an_accordion_entry_both_ways() { + let entry = |panel: &str| StackEntry { panel: s(panel), open: true, height: None }; + let mut workspace = Workspace::new(Layout { root: Some(Node::Stack { entries: vec![entry("layers"), entry("history")] }), floating: Vec::new() }); + let toggle = parse(json!({"command": "window.panel.minimize", "panel": "layers"})).unwrap(); + toggle.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Open); + toggle.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Front); +} From 4325b9ccc1ce67670a702254b2f1eeeb3c746c95 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:30:02 +0000 Subject: [PATCH 039/160] docking: move_targets skips the panel's own group Moving a panel to a tab of its own group only reorders it, so a panel whose only other tabs are in its group gets no "Move Panel to Group" menu. --- crates/ui/src/docking/model.rs | 11 ++++++++--- crates/ui/src/docking/tests.rs | 9 +++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 690137bb..f4a0471a 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -525,10 +525,15 @@ impl Layout

{ self.panels().contains(&panel) } - /// The panels `panel` can join as a tab: every other panel whose permissions accept tabs. - /// A "Move Panel to Group" menu with an empty answer has nothing to offer and stays hidden. + /// The panels `panel` can join as a tab: every panel in another group whose permissions + /// accept tabs. A "Move Panel to Group" menu with an empty answer has nothing to offer and + /// stays hidden. pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> { - self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect() + let own = self.place(panel); + self.panels() + .into_iter() + .filter(|target| *target != panel && permissions(target).accepts_tabs && (own.is_none() || self.place(target) != own)) + .collect() } /// Capture a panel's nearest surviving group/split neighbor before hiding it. diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index ac070249..76124f5c 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -67,6 +67,15 @@ fn move_targets_skip_the_panel_itself_and_protected_anchors() { assert!(single.move_targets(&1, all).is_empty()); } +#[test] +fn move_targets_skip_the_panels_own_group() { + let layout = sample(); + let all = |_: &u32| Permissions::default(); + assert_eq!(layout.move_targets(&3, all), vec![&1, &5, &6]); + let pair = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + assert!(pair.move_targets(&1, all).is_empty()); +} + #[test] fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() { let layout = sample(); From 9359332fd7e61076f33c10a1ae97a0cfcf3a0177 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:30:02 +0000 Subject: [PATCH 040/160] panes: the API example gives the document focus on to_document --- docs/ui-panes.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ui-panes.md b/docs/ui-panes.md index fd3d9a91..43d6f5b5 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -42,7 +42,13 @@ for request in out.requests { PaneRequest::Focus(_) => {} } } -egui::CentralPanel::default().show(ui, |ui| document(ui)); +egui::CentralPanel::default().show(ui, |ui| { + let response = document(ui); + if out.to_document { + // F6 or Shift+F6 left the panes: the document takes keyboard focus. + response.request_focus(); + } +}); ``` | Piece | Behaviour | From bfda13d2afd92947f2fe0f671385d350b4d5932a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:32:15 +0000 Subject: [PATCH 041/160] docking: a tab dragged to an edge without a dock makes one A tab that shares its group moves alone into the new dock with Action::MovePanel, as window.panel.dock does (P6). --- crates/ui/src/docking/dock_tests.rs | 10 ++++++++++ crates/ui/src/docking/render/dock.rs | 8 +++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index b49c60a5..9a17ed7e 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -281,6 +281,16 @@ fn group_dropped_on_the_window_edge_without_a_dock_makes_a_new_dock() { } } +#[test] +fn tab_dragged_to_the_window_edge_without_a_dock_makes_a_dock_of_that_panel() { + let mut d = testing::DockDriver::new(docked(Side::Start, &[&[&[2, 3]]]), TestContent::default()); + let tab = d.hit(&Hit::Tab(3)).unwrap_or_else(|| panic!("{:?}", d.hits())); + let out = d.drag(tab.center(), pos2(795.0, 300.0)); + assert!(out.actions.contains(&Action::MovePanel { panel: 3, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions); + assert_eq!(columns(&d.layout, Side::End), vec![vec![vec![3]]]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]]]); +} + #[test] fn a_column_takes_groups_anywhere_in_it_and_as_tabs() { let windows = [(5, [400.0, 40.0, 150.0, 120.0]), (6, [400.0, 220.0, 150.0, 120.0]), (7, [400.0, 400.0, 150.0, 120.0])]; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 86c57be5..c2822cb6 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -461,8 +461,9 @@ impl Renderer<'_, P> { self.checked(action).map(|action| (action, drop.line)) } - /// The dock target a single tab drag would take: a new group or column of that panel. A new - /// dock only takes a panel that is alone in its group. + /// The dock target a single tab drag would take: a new group, column or dock of that panel. + /// A panel alone in its group takes the group's state to a new dock; one that shares its + /// group leaves the other tabs behind (P6). pub(super) fn dock_tab_proposal(&self, ctx: &Context, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { if self.dock_covered(ctx, pointer, None) { return None; @@ -476,7 +477,8 @@ impl Renderer<'_, P> { { Action::MoveGroup { panel: panel.clone(), to } } - Target::NewDock { .. } | Target::Float { .. } => return None, + to @ Target::NewDock { .. } => Action::MovePanel { panel: panel.clone(), to }, + Target::Float { .. } => return None, }; self.checked(action).map(|action| (action, drop.line)) } From e1b7733a1d30e868a474a96ee6f3cdd60bcde847 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 14:00:03 +0000 Subject: [PATCH 042/160] docking: tabs keep their names and the rest go to the overflow menu --- crates/ui/README.md | 6 +++ crates/ui/src/docking/render/group.rs | 9 +++- crates/ui/src/docking/surface.rs | 4 +- crates/ui/src/docking/tests.rs | 76 ++++++++++++++++++++------- crates/ui/src/docking/trial_tests.rs | 65 ++++++++++++----------- crates/ui/src/tabs/mod.rs | 47 ++++++++++++++++- crates/ui/src/tabs/tests.rs | 42 +++++++++++++++ docs/ui-docking.md | 9 ++++ 8 files changed, 203 insertions(+), 55 deletions(-) diff --git a/crates/ui/README.md b/crates/ui/README.md index a321c8da..593a628d 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -108,6 +108,12 @@ tabs before hiding them, keeps input order and retains the selected tab. Tiny st its width to zero. Negative/non-finite dimensions become zero and large dimensions are bounded. The work is linear in tab count with at most 32 fitting passes; the caller caps its own tab count. +`tabs::fit_whole(natural_widths, selected, available, overflow_width)` keeps every shown tab at its +natural width and sends the rest to overflow. Tabs are shown in order up to the first that does not +fit; the selected tab is always shown in its logical place, taking the place of the last tabs that +fit when it lies past them. Only a selected tab wider than the strip minus the overflow button is +narrowed. Docking tab strips use this policy. + `tabs::Tab` registers a tab at a supplied rectangle and invokes the application's painter: ```rust diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 74912e96..ebcf61f7 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -29,6 +29,9 @@ pub(super) struct TabGroup<'a, P> { /// The top band of a strip that drags the whole group. const BORDER_BAND: f32 = 4.0; +/// Width of the "…" button that lists the panels a strip has no room for. +const OVERFLOW_WIDTH: f32 = 22.0; + impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; @@ -70,7 +73,7 @@ impl Renderer<'_, P> { child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, panels, selected, floating); } - // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. + // The drag bar keeps its reserved width: tabs overflow before it narrows. let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); let tabs_width = (strip.width() - header_width - bar_width).max(0.0); let widths: Vec = panels @@ -84,7 +87,9 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); - let fit = tabs::fit(&widths, active, tabs_width, 40.0, 22.0); + // Tabs keep their full names; the rest go to the overflow menu. Only an active tab wider + // than the strip is cut, with its name in a tooltip. + let fit = tabs::fit_whole(&widths, active, tabs_width, OVERFLOW_WIDTH); let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); let mut shown = Vec::new(); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index dc8f1419..c50bf0db 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -41,8 +41,8 @@ pub struct DockStyle { pub float_group_label: String, /// Group menu item that moves the whole group into another group (submenu of targets). pub move_group_to_label: String, - /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, - /// before the bar narrows. + /// Minimum width of the drag bar left of the group header. Tabs overflow before the bar + /// narrows. pub drag_bar_width: f32, /// Painted under every floating window, so it stays opaque when the panel colours are not. pub floating_background: Color32, diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 76124f5c..013bb274 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1246,7 +1246,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 }, ); h.run_steps(2); - let tab = Id::new("action-label-test").with(("tab", &5_u32)); + let tab = Id::new("action-label-test").with(("tab", &6_u32)); let at = h.ctx.read_response(tab).unwrap().rect.center(); h.input_mut().events.extend([ Event::PointerMoved(at), @@ -1271,13 +1271,51 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { } #[test] -fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() { +fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { + use egui::accesskit::Role; + use egui_kittest::{Harness, kittest::Queryable}; + fn elided(shape: &egui::Shape) -> bool { + match shape { + egui::Shape::Text(text) => text.galley.elided, + egui::Shape::Vec(shapes) => shapes.iter().any(elided), + _ => false, + } + } + let area = Id::new("whole-tabs"); + let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] }; + let mut h = Harness::builder().with_size(vec2(260.0, 240.0)).build_ui(move |ui| { + DockArea::new(area).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |panel| format!("Panel {panel}"), + |_| Permissions::default(), + |_: &mut egui::Ui, _: &u32| {}, + ); + }); + h.run_steps(2); + let shown: Vec = (1..=6).filter(|p| h.ctx.read_response(area.with(("tab", p))).is_some()).collect(); + assert!((2..6).contains(&shown.len()), "{shown:?}"); + // The active last tab takes the place of the last tab that fits; the others keep their order. + let mut expected: Vec = (1..shown.len() as u32).collect(); + expected.push(6); + assert_eq!(shown, expected); + assert!(h.output().shapes.iter().all(|shape| !elided(&shape.shape)), "every shown tab keeps its full name"); + h.get_by_role_and_label(Role::Button, "Panels").click(); + h.run_steps(2); + let listed: Vec = (1..=6).filter(|p| h.query_by_role_and_label(Role::Button, &format!("Panel {p}")).is_some()).collect(); + let hidden: Vec = (1..=6).filter(|p| !shown.contains(p)).collect(); + assert_eq!(listed, hidden, "the overflow menu lists exactly the hidden panels"); +} + +#[test] +fn only_an_active_tab_wider_than_the_strip_is_truncated_and_named_in_a_tooltip() { let ctx = Context::default(); ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![] }; let label = |p: &u32| format!("Eigenschaften und Anpassungen {p}"); let run = |events: Vec| { - let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 200.0))), events, ..Default::default() }; + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(150.0, 200.0))), events, ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { DockArea::new(Id::new("truncate")).show( ui, @@ -1295,23 +1333,23 @@ fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tool }; run(vec![]); let shapes = run(vec![]); - let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).unwrap().rect; - for p in 1..=3 { - let tab = tab(p); - // The clip rect would hide an overflowing label; what is laid out must itself fit. - let painted: Vec = shapes - .iter() - .filter(|shape| matches!(&shape.shape, egui::Shape::Text(_))) - .map(|shape| shape.shape.visual_bounding_rect()) - .filter(|r| tab.contains(r.center())) - .collect(); - assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); - assert!(tab.contains_rect(painted[0]), "tab {p}: label {:?} overflows {tab:?}", painted[0]); - } - let at = tab(2).center(); - run(vec![Event::PointerMoved(at)]); + let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).map(|response| response.rect); + assert!(tab(2).is_none() && tab(3).is_none(), "the other tabs overflow instead of shrinking"); + let active = tab(1).unwrap(); + // The clip rect would hide an overflowing label; what is laid out must itself fit. + let painted: Vec<&egui::epaint::TextShape> = shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if active.contains(text.visual_bounding_rect().center()) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(painted.len(), 1, "{painted:?}"); + assert!(painted[0].galley.elided); + assert!(active.contains_rect(painted[0].visual_bounding_rect()), "label {:?} overflows {active:?}", painted[0].visual_bounding_rect()); + run(vec![Event::PointerMoved(active.center())]); let shapes = run(vec![]); - let full = label(&2); + let full = label(&1); // An elided galley still reports its whole text, so the cut tab label alone would match. assert!( shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == full && !text.galley.elided)), diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 3036490e..9338199b 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -79,39 +79,44 @@ impl DockContent for GalleyLabels { } #[test] -fn shrunk_tabs_end_their_labels_with_an_ellipsis_inside_the_tab_active_or_not() { +fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellipsis_inside_it() { let panels = [1, 2, 3, 4, 5, 6]; let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() }; - let ctx = Context::default(); - let mut content = GalleyLabels; - let mut shapes = Vec::new(); - for _ in 0..2 { - let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }; - let mut full = ctx.run_ui(input, |ui| { - let style = DockStyle::from_ui(ui); - ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 400.0))), |ui| { - DockArea::new(Id::new(AREA)).show_content(ui, &layout, &style, &mut content); + for width in [320.0, 90.0] { + let ctx = Context::default(); + let mut content = GalleyLabels; + let mut shapes = Vec::new(); + for _ in 0..2 { + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + let style = DockStyle::from_ui(ui); + ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 400.0))), |ui| { + DockArea::new(Id::new(AREA)).show_content(ui, &layout, &style, &mut content); + }); }); - }); - full.textures_delta.clear(); - shapes = full.shapes; - } - let texts: Vec<_> = flat(&shapes) - .into_iter() - .filter_map(|(clip, shape)| match shape { - egui::Shape::Text(text) => Some((clip, text)), - _ => None, - }) - .collect(); - for panel in panels { - let tab = ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect; - let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { - panic!("no label in tab {panel}") - }; - assert_eq!(last_char(&text.galley), Some('…'), "tab {panel}"); - let drawn = Rect::from_min_size(text.pos, text.galley.size()); - assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); - assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); + full.textures_delta.clear(); + shapes = full.shapes; + } + let texts: Vec<_> = flat(&shapes) + .into_iter() + .filter_map(|(clip, shape)| match shape { + egui::Shape::Text(text) => Some((clip, text)), + _ => None, + }) + .collect(); + let shown: Vec = panels.into_iter().filter(|p| ctx.read_response(Id::new(AREA).with(("tab", p))).is_some()).collect(); + assert!(shown.contains(&4) && shown.len() < panels.len(), "{width}: {shown:?}"); + for panel in shown { + let tab = ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect; + let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { + panic!("no label in tab {panel}") + }; + let cut = width < 100.0; + assert_eq!(last_char(&text.galley), if cut { Some('…') } else { char::from_digit(panel, 10) }, "{width}: tab {panel}"); + let drawn = Rect::from_min_size(text.pos, text.galley.size()); + assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); + assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); + } } } diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs index e82dcca1..e8b20378 100644 --- a/crates/ui/src/tabs/mod.rs +++ b/crates/ui/src/tabs/mod.rs @@ -1,8 +1,9 @@ //! Tab fitting and accessible tab interactions without owning a dock or document model. //! //! The caller owns identity, localization, selection, painting, overflow menus and drag actions. -//! [`fit`] only assigns widths; [`Tab`] returns the complete egui response, including double-click -//! and drag phases, so a panel strip can collapse, reorder or tear off according to its app. +//! [`fit`] (tabs shrink before they overflow) and [`fit_whole`] (tabs keep their full width) only +//! assign widths; [`Tab`] returns the complete egui response, including double-click and drag +//! phases, so a panel strip can collapse, reorder or tear off according to its app. use egui::{Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; @@ -70,6 +71,48 @@ pub fn fit(natural: &[f32], selected: usize, available: f32, minimum_width: f32, TabFit { shown, overflow, overflow_width } } +/// Fit tabs at their natural widths; the tabs that do not fit go to an overflow menu. +/// +/// Tabs are shown in input order up to the first one that does not fit beside the selected tab +/// and an overflow button of `overflow_width`. The selected tab (or the final tab if `selected` +/// is out of bounds) is always shown, in its logical place: when it lies past that run, it takes +/// the place of the last tabs that fit. Only a selected tab wider than the strip minus the button +/// gets less than its natural width; its painter elides the label. A lone tab never reserves a +/// button. +/// +/// Dimensions are sanitized as in [`fit`], and the shown widths plus `overflow_width` never +/// exceed the sanitized available width. Work and storage are linear in the input count. +pub fn fit_whole(natural: &[f32], selected: usize, available: f32, overflow_width: f32) -> TabFit { + let available = extent(available); + let natural: Vec = natural.iter().map(|&w| extent(w)).collect(); + if natural.iter().map(|&w| f64::from(w)).sum::() <= f64::from(available) { + return TabFit { shown: natural.into_iter().enumerate().collect(), ..Default::default() }; + } + if let [width] = natural[..] { + return TabFit { shown: vec![(0, width.min(available))], ..Default::default() }; + } + let overflow_width = extent(overflow_width).min(available); + let budget = available - overflow_width; + let selected = selected.min(natural.len().saturating_sub(1)); + let active = natural.get(selected).copied().unwrap_or(0.0).min(budget); + let mut room = f64::from(budget - active); + let mut shown = Vec::new(); + let mut overflow = Vec::new(); + let mut full = false; + for (i, &width) in natural.iter().enumerate() { + if i == selected { + shown.push((i, active)); + } else if !full && f64::from(width) <= room { + room -= f64::from(width); + shown.push((i, width)); + } else { + full = true; + overflow.push(i); + } + } + TabFit { shown, overflow, overflow_width } +} + fn squeeze(natural: &[f32], indices: &[usize], total: f32, minimum: f32) -> Vec<(usize, f32)> { let width = |i: usize| natural.get(i).copied().unwrap_or(0.0); let sum_at = |cap: f32| indices.iter().map(|&i| f64::from(width(i).min(cap.max(width(i).min(minimum))))).sum::(); diff --git a/crates/ui/src/tabs/tests.rs b/crates/ui/src/tabs/tests.rs index 6fb37e31..13dcb702 100644 --- a/crates/ui/src/tabs/tests.rs +++ b/crates/ui/src/tabs/tests.rs @@ -72,6 +72,48 @@ fn many_tabs_keep_the_last_selected_without_quadratic_membership_scans() { assert!(total(&f) <= 240.001); } +#[test] +fn whole_tabs_keep_their_width_and_the_selected_tab_replaces_the_last_that_fits() { + let natural = [60.0, 70.0, 80.0, 50.0, 90.0, 40.0]; + // 200 points: 22 for the button leaves 178. + let f = fit_whole(&natural, 0, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (1, 70.0)]); + assert_eq!(f.overflow, [2, 3, 4, 5]); + assert_eq!(f.overflow_width, 22.0); + // The last tab, selected, takes the place of tab 1 and keeps its order. + let f = fit_whole(&natural, 5, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (1, 70.0), (5, 40.0)]); + assert_eq!(f.overflow, [2, 3, 4]); + let f = fit_whole(&natural, 4, 200.0, 22.0); + assert_eq!(f.shown, [(0, 60.0), (4, 90.0)]); + assert_eq!(f.overflow, [1, 2, 3, 5]); + // Everything fits: no button. + let f = fit_whole(&natural, 4, 390.0, 22.0); + assert_eq!(f.shown.len(), 6); + assert!(f.overflow.is_empty() && f.overflow_width == 0.0); +} + +#[test] +fn only_a_selected_tab_wider_than_the_strip_is_narrowed() { + let f = fit_whole(&[60.0, 300.0, 70.0], 1, 200.0, 22.0); + assert_eq!(f.shown, [(1, 178.0)]); + assert_eq!(f.overflow, [0, 2]); + for selected in [0, 1, 2, usize::MAX] { + for available in [0.0, 1.0, 21.0, 22.0, 100.0, 200.0, 500.0] { + let natural = [60.0, 300.0, 70.0]; + let f = fit_whole(&natural, selected, available, 22.0); + let selected = selected.min(2); + assert!(f.shown.iter().all(|&(i, w)| i == selected || w == natural[i]), "{selected} at {available}: {f:?}"); + assert!(f.shown.iter().any(|&(i, _)| i == selected)); + assert_eq!(f.shown.len() + f.overflow.len(), 3); + assert!(total(&f) <= available + 0.001, "{available}: {f:?}"); + } + } + assert_eq!(fit_whole(&[80.0], 0, 10.0, 22.0).shown, [(0, 10.0)]); + let f = fit_whole(&[f32::NAN, f32::INFINITY, -5.0, 30.0], usize::MAX, f32::NAN, f32::MAX); + assert!(f.shown.iter().all(|&(_, w)| w.is_finite() && w >= 0.0) && total(&f) == 0.0); +} + struct TabTest { ctx: Context, labels: [String; 2], diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 336367e7..8a01aa5f 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -137,6 +137,15 @@ panel body. `begin_drag` hands a drag that started in the app's own chrome to th `docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview docking and `craft_ui::panes` both draw) for apps that draw their own windows. +## Tab strips + +Every shown tab carries its panel's full name. Tabs that do not fit go to the "…" button at the +end of the strip, whose menu lists the hidden panels; choosing one makes it active. The active tab +is always shown, in its logical place: when it lies past the tabs that fit, it takes the place of +the last of them. Only an active tab wider than the strip alone is cut with "…", and its tooltip +names it in full. The drag bar keeps its reserved width until the tabs overflow. A tab dropped on +the "…" button goes to the end of the group. + ## Gestures - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags From a014337eb2e7f1b440bf34017d46aaf1d41b4bcd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 14:02:59 +0000 Subject: [PATCH 043/160] docking: a floated group takes its content height --- crates/ui/src/docking/command_tests.rs | 5 ++- crates/ui/src/docking/group_tests.rs | 54 +++++++++++++++++++++++-- crates/ui/src/docking/iconic_tests.rs | 6 ++- crates/ui/src/docking/render/iconic.rs | 8 +++- crates/ui/src/docking/render/mod.rs | 1 + crates/ui/src/docking/render/preview.rs | 52 +++++++++++++++++++----- crates/ui/src/docking/surface.rs | 34 ++++++++++++++-- crates/ui/src/docking/trial_tests.rs | 4 +- docs/ui-docking.md | 22 +++++++++- 9 files changed, 158 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 307d8a86..78d46dd4 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -340,7 +340,8 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let float = |params: Value| parse(params).unwrap().to_actions(&workspace(), false, &space).unwrap(); let panel = float(json!({"command": "window.panel.float", "panel": "layers"})); - assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }], "floating_size, inset in the area"); + // The floating_size width, and an empty body's least fitted height under its 150 points. + assert_eq!(panel, vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }], "floating_size, inset in the area"); let [Action::MoveGroup { to: Target::Float { rect: Some([x, y, width, height]) }, .. }] = float(json!({"command": "window.panel.float", "panel": "history", "group": true}))[..] else { @@ -365,7 +366,7 @@ fn float_without_a_rect_takes_the_drag_size_inside_the_dock_area() { let applied = parse(json!({"command": "window.panel.float", "panel": "layers"})).unwrap().to_actions(&workspace(), false, &seeded); assert_eq!(applied, Ok(vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 180.0, 240.0] }])); let recorded = space.clone().with_panel_size(s("layers"), egui::vec2(180.0, 240.0)); - assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 150.0] }]); + assert_eq!(float_with(&recorded, "layers"), vec![Action::Float { panel: s("layers"), rect: [24.0, 54.0, 200.0, 120.0] }]); } fn float_with(space: &FloatSpace

, panel: &str) -> Vec> { diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 0577a628..71c9f113 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -50,11 +50,16 @@ struct TestContent { floating_size: Option, host_owned: bool, whole_group: bool, + /// Height every body takes; `None` draws a one-line label. + body_height: Option, } impl DockContent for TestContent { fn body(&mut self, ui: &mut egui::Ui, _: &u32) { - ui.label("Body"); + match self.body_height { + Some(height) => _ = ui.allocate_exact_size(vec2(10.0, height), egui::Sense::hover()), + None => _ = ui.label("Body"), + } } fn logical_label(&self, panel: &u32) -> String { format!("Panel {panel}") @@ -349,12 +354,12 @@ fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_un } #[test] -fn floated_panel_uses_floating_size_not_the_column_height() { +fn floated_panel_fits_its_content_up_to_floating_size_not_the_column_height() { for size in [Some(vec2(200.0, 150.0)), None] { let mut layout = docked(); let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; - let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(200.0), ..Default::default() }; let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); let out = d.drag(d.tab(3).center(), pos2(700.0, 100.0)); let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { @@ -363,9 +368,10 @@ fn floated_panel_uses_floating_size_not_the_column_height() { }) else { panic!("{:?}", out.actions) }; + let fitted = crate::docking::chrome::TITLE_HEIGHT + d.rect("drag-bar", 2).height() + 200.0; match size { Some(size) => assert_eq!((width, height), (size.x, size.y)), - None => assert!(height <= 320.0, "{height}"), + None => assert_eq!(height, fitted), } } } @@ -542,3 +548,43 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); } + +#[test] +fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() { + let title = crate::docking::chrome::TITLE_HEIGHT; + let float = |body: f32, floating_size: Option| { + let content = TestContent { body_height: Some(body), floating_size, ..Default::default() }; + let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let strip = d.rect("drag-bar", 4).height(); + let recorded = crate::docking::float_space::(&d.ctx, Id::new(AREA)).unwrap().panel(&4).unwrap(); + let target = pos2(400.0, 300.0); + d.hold(d.tab(4).center(), target); + d.modifiers = Modifiers::COMMAND; + let out = d.step(vec![button(target, false)]); + let Some([_, _, width, height]) = out.actions.iter().find_map(|a| match a { + Action::Float { panel: 4, rect } => Some(*rect), + _ => None, + }) else { + panic!("{:?}", out.actions) + }; + assert_eq!(recorded, vec2(width, height), "a command floats at the size a drag does"); + (strip, vec2(width, height)) + }; + let (strip, size) = float(150.0, None); + assert_eq!(size.y, title + strip + 150.0); + assert_eq!(float(0.0, None).1.y, 120.0, "an empty panel floats at the least fitted height"); + assert_eq!(float(600.0, None).1.y, 320.0, "a tall docked panel keeps the docked cap"); + // The host's floating size gives the width and the most height. + assert_eq!(float(150.0, Some(vec2(300.0, 360.0))).1, vec2(300.0, title + strip + 150.0)); + assert_eq!(float(150.0, Some(vec2(300.0, 160.0))).1, vec2(300.0, 160.0)); + + // A group takes its active panel's content height. + let content = TestContent { body_height: Some(150.0), ..Default::default() }; + let mut d = Driver::with(docked(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let target = pos2(400.0, 300.0); + d.hold(d.rect("drag-bar", 2).center(), target); + d.modifiers = Modifiers::COMMAND; + let out = d.step(vec![button(target, false)]); + let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; + assert_eq!(*height, title + strip + 150.0); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index b9ad4a88..a90cc298 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -193,9 +193,13 @@ fn dragging_a_flyout_strip_floats_its_group() { let mut d = DockDriver::new(docked(&[&[1, 2], &[3]], true), Content); d.click(hit(&d, Hit::Icon(1)).center()); d.settle(); + // A one-line body: the flyout and the floated group take the least fitted height. + let flyout = d.ctx.memory(|m| m.area_rect(d.id.with("flyout"))).unwrap(); + assert_eq!(flyout.height(), 120.0); let bar = hit(&d, Hit::DragBar(1)); let out = d.drag(bar.center(), CANVAS); - assert!(matches!(out.actions.as_slice(), [Action::MoveGroup { panel: 1, to: Target::Float { .. } }]), "{:?}", out.actions); + let [Action::MoveGroup { panel: 1, to: Target::Float { rect: Some(rect) } }] = out.actions.as_slice() else { panic!("{:?}", out.actions) }; + assert_eq!(rect[3], 120.0); assert_eq!(groups(&d.layout), vec![vec![3]]); assert_eq!(d.layout.floating.len(), 1); assert_eq!(d.layout.floating.first().map(|w| w.panels().copied().collect::>()), Some(vec![1, 2])); diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 4b7b3a04..1be3872f 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -299,13 +299,17 @@ impl Renderer<'_, P> { let screen = ctx.content_rect(); let bounds = self.workspace.intersect(screen); let width = request.width.max(self.style.min_pane).min(bounds.width()); - let height = self + let max = self .content .floating_size(&active) .map(|size| size.y) .or(request.group.height) .filter(|h| h.is_finite() && *h > 0.0) - .unwrap_or(FLYOUT_HEIGHT) + .unwrap_or(FLYOUT_HEIGHT); + // A flyout fits the content its panel last drew, up to that height. + let height = self + .fitted_height(&active, self.style.tab_height, max) + .unwrap_or(max) .max(self.style.tab_height + self.style.min_pane) .min(bounds.height()); let left = match request.edge { diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index d5cdd4b9..d099edeb 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -218,6 +218,7 @@ impl Renderer<'_, P> { ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); + self.float_space.record_content(panel.clone(), child.min_rect().height()); // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 5d662980..13f36e5a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -5,7 +5,9 @@ use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2}; use super::{Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ - Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, + Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Node, Placement, Target, Zone, cancel_drag, + dock_model::{Place, node_at}, + resolve_drop, surface::TAB_PADDING, }; @@ -17,6 +19,9 @@ fn float_only(ctx: &Context) -> bool { /// Height of a panel floated out of a dock column when the host gives no floating size. const DOCKED_FLOAT_HEIGHT: f32 = 320.0; +/// Least height of a floating window or flyout fitted to a short panel's content. +pub(super) const MIN_FITTED_HEIGHT: f32 = 120.0; + impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { let ctx = ui.ctx(); @@ -274,21 +279,30 @@ impl Renderer<'_, P> { } /// Size of a panel floated by a drag or the Float menu: the host's floating size, else the - /// source size within the panel limits. A panel leaving a dock column does not take the - /// column's height. + /// source size within the panel limits. Out of a dock column, or with a floating size, the + /// height fits the content the panel last drew, up to that height (320 in a dock). pub(super) fn float_size(&self, panel: &P, source: Vec2) -> Vec2 { + let chrome = self.style.title_height + self.style.tab_height; if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { - return size; + return vec2(size.x, self.fitted_height(panel, chrome, size.y).unwrap_or(size.y)); } let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { - size.y = size.y.min(DOCKED_FLOAT_HEIGHT); + size.y = self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT).unwrap_or(size.y.min(DOCKED_FLOAT_HEIGHT)); } let limits = self.limits(panel); let header = vec2(0.0, self.style.tab_height); size.max(limits.min + header).min(limits.max + header) } + /// Height of a window around `panel`'s group: `chrome` (title bar and tab strip) and the + /// content height the panel last drew, at least [`MIN_FITTED_HEIGHT`] and at most `max`. + /// `None` for a panel the area has not drawn. + pub(super) fn fitted_height(&self, panel: &P, chrome: f32, max: f32) -> Option { + let content = self.float_space.content(panel)?; + Some((chrome + content).max(MIN_FITTED_HEIGHT).min(max)) + } + /// Record what a drag floats from `panels`, one group drawn at `group` whose panels drag /// from `panel` rects, for commands that float without a rect. pub(super) fn record_float(&mut self, panels: &[P], panel: Vec2, group: Vec2) { @@ -307,17 +321,33 @@ impl Renderer<'_, P> { } /// Size of a group or column floated by a drag or Float Tab Group: the source width, and the - /// tallest member's floating size, else the source height capped like a panel leaving a - /// dock column. A group that already floats keeps its size. + /// tallest member's floating height, else the source height capped like a panel leaving a + /// dock column. A group fits that height to its active panel's content. A group that already + /// floats keeps its size. pub(super) fn group_float_size(&self, members: &[P], source: Vec2) -> Vec2 { - let place = members.first().and_then(|panel| self.layout.place(panel)); - if matches!(place, Some(Place::Floating { .. }) | None) { + let Some(place) = members.first().and_then(|panel| self.layout.place(panel)) else { return source }; + if matches!(place, Place::Floating { .. }) { return source; } let valid = |s: &Vec2| s.is_finite() && s.x > 0.0 && s.y > 0.0; let host = members.iter().filter_map(|panel| self.content.floating_size(panel).filter(valid)).map(|s| s.y).reduce(f32::max); - let docked = matches!(place, Some(Place::Dock { .. })); - vec2(source.x, host.unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + let docked = matches!(place, Place::Dock { .. }); + let chrome = self.style.title_height + self.style.tab_height; + let max = host.or(docked.then_some(DOCKED_FLOAT_HEIGHT)); + let fitted = max.and_then(|max| self.group_active(members, &place).and_then(|active| self.fitted_height(active, chrome, max))); + vec2(source.x, fitted.or(host).unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) + } + + /// The active panel of the group at `place` when `members` are that whole group. + fn group_active<'a>(&'a self, members: &[P], place: &Place) -> Option<&'a P> { + let (panels, active) = match place { + Place::Node(path) => match self.layout.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Tabs { panels, active }) => (panels, *active), + _ => return None, + }, + _ => self.layout.group(place).map(|group| (&group.panels, group.active))?, + }; + (panels.len() == members.len() && members.iter().all(|m| panels.contains(m))).then(|| panels.get(active)).flatten() } /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c50bf0db..f53707f0 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -237,13 +237,16 @@ pub struct FloatSpace

{ pub(super) panels: Vec<(P, Vec2)>, pub(super) groups: Vec<(P, Vec2)>, pub(super) columns: Vec<(P, Vec2)>, + /// The height each panel's content used when it was last drawn, which fits a floated group + /// to its content. + pub(super) contents: Vec<(P, f32)>, } impl FloatSpace

{ /// An area with no recorded sizes: commands float a panel at 260x320, and a group or /// column at its dock column's width (else 260) by 320. pub fn new(area: Rect) -> Self { - Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new() } + Self { area, panels: Vec::new(), groups: Vec::new(), columns: Vec::new(), contents: Vec::new() } } /// Adds the size a command floats `panel` at alone when the area has not drawn it, such as @@ -254,6 +257,16 @@ impl FloatSpace

{ } } +impl FloatSpace

{ + pub(super) fn record_content(&mut self, panel: P, height: f32) { + let height = if height.is_finite() { height.max(0.0) } else { 0.0 }; + match self.contents.iter_mut().find(|(p, _)| *p == panel) { + Some((_, recorded)) => *recorded = height, + None => self.contents.push((panel, height)), + } + } +} + /// Lookups for `DockCommand`, which needs feature `serde`. #[cfg_attr(not(feature = "serde"), allow(dead_code))] impl FloatSpace

{ @@ -269,6 +282,10 @@ impl FloatSpace

{ Self::find(&self.columns, panel) } + pub(super) fn content(&self, panel: &P) -> Option { + self.contents.iter().find(|(p, _)| p == panel).map(|(_, height)| *height) + } + /// The first record wins: an iconic panel is recorded at its icon before its flyout. fn find(sizes: &[(P, Vec2)], panel: &P) -> Option { sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size) @@ -445,8 +462,11 @@ pub trait DockContent

{ fn limits(&self, _panel: &P) -> PanelLimits { PanelLimits::default() } - /// Preferred floating size, independent of the current dock geometry. `None` falls back - /// to the panel limits. + /// Preferred floating size, independent of the current dock geometry: the width of a + /// floated panel and the most height it takes. A floated panel or group, and a flyout, fit + /// their height to the content the active panel last drew (at least 120 points), up to this + /// height, or up to 320 out of a dock when this is `None`. A rect a command gives and a + /// window the user resized keep their size. fn floating_size(&self, _panel: &P) -> Option { None } @@ -759,7 +779,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { output: DockOutput::default(), frame, iconic: super::render::Iconic::new(self.auto_collapse), - float_space: FloatSpace::new(rect), + float_space: FloatSpace { + // A panel keeps the content height it last drew while hidden or minimized. + contents: float_space::

(ui.ctx(), self.id) + .map(|space| space.contents.into_iter().filter(|(panel, _)| layout.contains(panel)).collect()) + .unwrap_or_default(), + ..FloatSpace::new(rect) + }, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 9338199b..584f28e9 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -50,14 +50,14 @@ fn a_full_height_docked_group_floats_at_the_host_or_capped_height_inside_the_win let mut layout = docked(); let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![2, 3], 1)], 240.0)] }; - let content = TestContent { protected: vec![0], floating_size: size, ..Default::default() }; + let content = TestContent { protected: vec![0], floating_size: size, body_height: Some(600.0), ..Default::default() }; let mut d = Driver::with(layout, content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); let bar = d.rect("drag-bar", 2); let out = d.drag(bar.center(), pos2(420.0, 300.0)); let Some(Target::Float { rect: Some([x, y, width, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; match size { Some(size) => assert_eq!(*height, size.y), - None => assert!(*height <= 320.0, "{height}"), + None => assert_eq!(*height, 320.0), } assert!(*width > 100.0 && *width <= 240.0, "{width}"); let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 8a01aa5f..c7643033 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -146,6 +146,24 @@ the last of them. Only an active tab wider than the strip alone is cut with "… names it in full. The drag bar keeps its reserved width until the tabs overflow. A tab dropped on the "…" button goes to the end of the group. +## Floating size + +A panel or group floated by a drag, a menu or a command without a rect takes its content height: +the title bar, the tab strip and the height the active panel's content used when it was last +drawn (the content ui's `min_rect`), at least 120 points. The most it takes is the height of +`DockContent::floating_size`, or 320 points for a panel or group leaving a dock. A short panel +therefore floats as a short window. A panel's width is its `floating_size` width, else its docked +width; a group keeps its docked width. +A flyout fits its content the same way, without the title bar. + +- A panel the area has not drawn floats at the `floating_size` height, else the docked height + capped at 320, or at a `FloatSpace::with_panel_size` size for commands. +- A group from a frame without a `floating_size` keeps its frame size. A dock column of several + groups floats at the tallest `floating_size` height, else its column height capped at 320. +- A rect from a command and a window the user resized keep their size; floating windows are not + refitted while open. +- Docked layout does not change: a column's last group still fills the column. + ## Gestures - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags @@ -195,8 +213,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when it floats. `side` is `Start` or `End`. -- `window.panel.float` without a rect floats what it moves at the size a drag floats it at: the - panel's `floating_size`, else its docked group or column size, capped like a drag. It goes +- `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see + Floating size). It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. The area records that geometry in a `FloatSpace` each frame; `float_space(ctx, id)` reads it and `apply` and `to_actions` take it. Before the area has drawn, `FloatSpace::new(area)` floats From 13be52fbaa2c4936c8d7b3d2b5abe6ab72892123 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 14:05:26 +0000 Subject: [PATCH 044/160] docking: focus only follows the user's input --- crates/ui/src/docking/group_tests.rs | 37 ++++++++++++++++++++ crates/ui/src/docking/render/floating.rs | 2 -- crates/ui/src/docking/render/native.rs | 1 - crates/ui/src/docking/surface.rs | 43 +++++++++++++++++++++--- crates/ui/src/docking/tests.rs | 3 +- docs/ui-docking.md | 11 +++--- 6 files changed, 85 insertions(+), 12 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 71c9f113..85ff5506 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -588,3 +588,40 @@ fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() { let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) }; assert_eq!(*height, title + strip + 150.0); } + +#[test] +fn tab_focus_follows_only_the_users_input() { + let mut d = Driver::new(docked()); + let tab = Id::new(AREA).with(("tab", &4u32)); + // Keyboard focus as Tab gives it. + d.ctx.memory_mut(|m| m.request_focus(tab)); + d.step(vec![]); + assert!(d.ctx.memory(|m| m.has_focus(tab))); + + // A reset that moves the panel takes its focus away and reports no new focus. + let mut reset = docked(); + let Some(Node::Split { first, .. }) = &mut reset.root else { panic!() }; + let Node::Dock { columns, .. } = first.as_mut() else { panic!() }; + columns[0].groups.reverse(); + d.layout = reset; + let out = d.step(vec![]); + assert_eq!(out.focused, None); + d.step(vec![]); + assert_eq!(d.ctx.memory(|m| m.focused()), None, "the moved tab gives up keyboard focus"); + + // A user drag keeps the dragged tab's focus where the user put it. + let mut d = Driver::new(docked()); + d.ctx.memory_mut(|m| m.request_focus(tab)); + d.drag(d.tab(4).center(), d.tab(2).center()); + d.step(vec![]); + assert!(d.layout.place(&4) != docked().place(&4)); + assert!(d.ctx.memory(|m| m.has_focus(tab)), "a tab moved by the user keeps focus"); + + // A closed panel's tab gives focus up too. + let out = d.step(vec![]); + assert!(out.actions.is_empty()); + d.layout.apply(Action::Close { panel: 4 }).unwrap(); + d.step(vec![]); + d.step(vec![]); + assert_eq!(d.ctx.memory(|m| m.focused()), None); +} diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 1ac0b65f..bfcabce0 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -66,8 +66,6 @@ impl Renderer<'_, P> { let id = self.id.with(("floating", first)); if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.move_to_top(FloatingFrame::layer(id)); - ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first))))); - self.output.focused = self.activation.clone(); } let movable = window.panels().all(|panel| self.permissions(panel).movable); let style = ChromeStyle::from_dock(self.style); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 05fe8196..94627bd1 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -79,7 +79,6 @@ impl Renderer<'_, P> { .with_min_inner_size(MIN_SIZE); if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); - self.output.focused = self.activation.clone(); } let (targets, stacks) = (self.targets.len(), self.frame.stacks.len()); let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index f53707f0..fb092503 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -201,8 +201,9 @@ impl DockStyle { pub struct DockOutput

{ pub actions: Vec>, pub error: Option, - /// The panel the user focused this frame: a clicked tab or menu entry, a drop, a raised - /// window, or a press of any pointer button in a panel body, stripless panels included. + /// The panel the user focused this frame: a clicked tab or menu entry, a drop, or a press + /// of any pointer button in a panel body, stripless panels included. Only user input sets + /// it; [`DockArea::raise_panel`] and host commands do not. pub focused: Option

, /// Whole-floating-group gesture identity; host may display its own group drop preview. pub group_drag: Option

, @@ -309,6 +310,37 @@ fn float_space_key(area: Id) -> Id { area.with("float-space") } +/// The panel whose tab held keyboard focus at the end of the last frame, where it was, and +/// whether that frame's own actions may have moved it. +type TabFocus

= (P, super::dock_model::Place, bool); + +fn tab_focus_key(area: Id) -> Id { + area.with("tab-focus") +} + +/// Keyboard focus stays on a tab only while its panel stays where the user put it: a panel +/// hidden, closed or moved by the host (a command or a workspace reset) gives focus up. +fn release_moved_tab_focus(ctx: &Context, area: Id, layout: &Layout

) { + let Some((panel, place, moved_by_area)) = ctx.data(|d| d.get_temp::>(tab_focus_key(area))) else { return }; + let tab = area.with(("tab", &panel)); + let place_now = layout.place(&panel); + if ctx.memory(|m| m.has_focus(tab)) && (place_now.is_none() || (place_now != Some(place) && !moved_by_area)) { + ctx.memory_mut(|m| m.surrender_focus(tab)); + } +} + +fn record_tab_focus(ctx: &Context, area: Id, layout: &Layout

, acted: bool) { + let focused = ctx.memory(|m| m.focused()); + let tab = focused.and_then(|id| layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == id)); + let record = tab.and_then(|panel| Some((panel.clone(), layout.place(panel)?, acted))); + ctx.data_mut(|d| match record { + Some(record) => { + d.insert_temp(tab_focus_key(area), record); + } + None => d.remove::>(tab_focus_key(area)), + }); +} + /// A request for host state outside the layout. #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] @@ -692,8 +724,9 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { false } - /// Raise and focus a panel's owning floating area on this frame. Apply Activate through - /// the application's command path as well to select its tab. + /// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate + /// through the application's command path as well to select its tab. A raise is not user + /// input: it moves no keyboard focus and is not reported as [`DockOutput::focused`]. pub fn raise_panel(mut self, panel: P) -> Self { self.activation = Some(panel); self @@ -764,6 +797,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + release_moved_tab_focus(ui.ctx(), self.id, layout); let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); let mut renderer = Renderer { id: self.id, @@ -797,6 +831,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { renderer.finish_drag(ui.ctx()); renderer.report_flyout(ui.ctx()); super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); + record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); renderer.output diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 013bb274..f934f9d1 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -912,7 +912,8 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() { custom_frame(&ctx, &layout, &mut content, vec![], &[], false); } let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], true); - assert_eq!(out.focused, Some(5)); + assert_eq!(out.focused, None, "a programmatic raise is not user focus"); + assert_eq!(ctx.memory(|m| m.focused()), None, "a raise draws no keyboard-focus ring"); assert!(out.actions.is_empty()); assert!(content.headers >= 3 && content.bodies >= 3); assert!(content.labels.iter().any(|s| s == "Logical 5")); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index c7643033..bcd62c86 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -110,8 +110,10 @@ survives. `DockOutput` also has: -- `focused`: the panel the user focused this frame: a clicked tab, a drop, a raised window, or a - press of any pointer button in a panel body, bare and protected panels included. +- `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any + pointer button in a panel body, bare and protected panels included. Commands, `raise_panel` and + workspace resets never set it; a host that tracks the last focused panel clears it when a reset + or command hides, closes or moves that panel. - `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and `DockEvent::ToggleAutoCollapse` (Dock), and `DockEvent::FlyoutChanged { panel }` (Dock): the flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an @@ -125,7 +127,7 @@ Builder options on `DockArea`: | `maximized(panel)` | Maximize Frame: that frame fills the area. | | `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. | | `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | -| `raise_panel(panel)` | Raise and focus a panel's floating window this frame. | +| `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. | | `blocked_rects(rects)` | App overlays that docking gestures must ignore. | `DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items @@ -133,7 +135,8 @@ and the floating window ×. They hide controls only: a panel that stays `closabl by the app's own Close items and commands. `DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a -panel body. `begin_drag` hands a drag that started in the app's own chrome to the area. +panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a +workspace reset hides, closes or moves its panel, and then gives it up. `begin_drag` hands a drag that started in the app's own chrome to the area. `docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview docking and `craft_ui::panes` both draw) for apps that draw their own windows. From 787b70627461a3c988157ed776e8f6051cbc94c3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 15:37:00 +0000 Subject: [PATCH 045/160] docs: say when a tab is cut and that the drag bar keeps its width --- docs/ui-docking.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ui-docking.md b/docs/ui-docking.md index bcd62c86..a9862d81 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -145,9 +145,10 @@ docking and `craft_ui::panes` both draw) for apps that draw their own windows. Every shown tab carries its panel's full name. Tabs that do not fit go to the "…" button at the end of the strip, whose menu lists the hidden panels; choosing one makes it active. The active tab is always shown, in its logical place: when it lies past the tabs that fit, it takes the place of -the last of them. Only an active tab wider than the strip alone is cut with "…", and its tooltip -names it in full. The drag bar keeps its reserved width until the tabs overflow. A tab dropped on -the "…" button goes to the end of the group. +the last of them. Only an active tab wider than the room beside the "…" button, or a lone tab +wider than the strip, is cut with "…", and its tooltip names it in full. The drag bar always keeps +its reserved width: tabs go to the overflow menu before it narrows, and only a strip narrower than +the bar itself shrinks it. A tab dropped on the "…" button goes to the end of the group. ## Floating size From d3079230dadf36b5e77fd9c3b08fcad193ad637a Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:54:23 +0000 Subject: [PATCH 046/160] docking: group_header_width gets the tab strip width A header that draws its own tabs, such as a timeline's open sequences, sizes them to the room the strip has. --- crates/ui/src/docking/group_tests.rs | 6 +++++- crates/ui/src/docking/render/group.rs | 2 +- crates/ui/src/docking/surface.rs | 10 +++++++--- crates/ui/src/docking/tests.rs | 2 +- docs/ui-docking.md | 5 +++-- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 85ff5506..81b0c32f 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -47,6 +47,8 @@ struct TestContent { protected: Vec, header: f32, header_clicks: usize, + /// The strip width the last header was sized for. + strip_width: std::cell::Cell, floating_size: Option, host_owned: bool, whole_group: bool, @@ -74,7 +76,8 @@ impl DockContent for TestContent { self.whole_group |= request.whole_group; self.host_owned.then(DropFeedback::default) } - fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + fn group_header_width(&self, _: &[u32], _: &u32, _: bool, strip_width: f32) -> f32 { + self.strip_width.set(strip_width); self.header } fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { @@ -253,6 +256,7 @@ fn drag_bar_survives_a_strip_full_of_tabs() { let bar = d.rect("drag-bar", 1); assert!(bar.width() >= 40.0, "{bar:?}"); assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header"); + assert_eq!(d.content.strip_width.get(), 300.0, "the header is sized for the whole strip"); assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs overflow before the bar narrows"); let out = d.drag(bar.center(), pos2(500.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index ebcf61f7..b87dfca2 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -65,7 +65,7 @@ impl Renderer<'_, P> { r } }; - let requested = self.content.group_header_width(panels, selected, floating); + let requested = self.content.group_header_width(panels, selected, floating, strip.width()); let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 }; if header_width > 0.0 { let header = mirror(Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max)); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index fb092503..411fb03a 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -535,9 +535,13 @@ pub trait DockContent

{ logical.into() } fn panel_menu(&mut self, _ui: &mut Ui, _panel: &P) {} - fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 { + /// Width of the group header at the strip's end (the host's ≡ and its own tabs). + /// `strip_width` is the whole tab strip; the header gets at most that, and the drag bar and + /// the group's tabs share what is left. + fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool, _strip_width: f32) -> f32 { 0.0 } + /// Draw the group header into `ui.max_rect()`, sized by [`Self::group_header_width`]. fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {} } @@ -614,8 +618,8 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn panel_menu(&mut self, ui: &mut Ui, panel: &P) { self.inner.panel_menu(ui, panel); } - fn group_header_width(&self, panels: &[P], active: &P, floating: bool) -> f32 { - self.inner.group_header_width(panels, active, floating) + fn group_header_width(&self, panels: &[P], active: &P, floating: bool, strip_width: f32) -> f32 { + self.inner.group_header_width(panels, active, floating, strip_width) } fn group_header(&mut self, ui: &mut Ui, panels: &[P], active: &P, floating: bool) { self.inner.group_header(ui, panels, active, floating); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index f934f9d1..4668b1eb 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -871,7 +871,7 @@ impl DockContent for CustomContent { self.menus += 1; ui.button("App menu").clicked(); } - fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 { + fn group_header_width(&self, _: &[u32], _: &u32, _: bool, _: f32) -> f32 { 40.0 } fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index a9862d81..7ca9779d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -103,8 +103,9 @@ returns a `DockOutput`. It does not change the layout. The app applies `output.a its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, -permissions, size limits, preferred floating size, panel menu and group header. `bare` hides the -tab strip of a lone panel. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +permissions, size limits, preferred floating size, panel menu and group header. The group header +sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that +draws its own tabs can size them to the room left. `bare` hides the tab strip of a lone panel. An icon is an image (`icon`), or drawn in code by `paint_icon`, which gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory survives. From b94df6c8b6e979672bf96157ca68096c718d0ab3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:55:43 +0000 Subject: [PATCH 047/160] docking: wheel selects tabs and middle click closes a tab Both are on in the Frame profile and off by default in the Dock profile; DockStyle::wheel_selects_tabs and middle_click_closes_tabs set them. A middle click closes only a closable panel. --- crates/ui/src/docking/group_tests.rs | 37 +++++++++++++++++++++ crates/ui/src/docking/render/group.rs | 46 +++++++++++++++++++++++++++ crates/ui/src/docking/surface.rs | 16 ++++++++++ docs/ui-docking.md | 3 ++ 4 files changed, 102 insertions(+) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 81b0c32f..cad73cfc 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -629,3 +629,40 @@ fn tab_focus_follows_only_the_users_input() { d.step(vec![]); assert_eq!(d.ctx.memory(|m| m.focused()), None); } + +#[test] +fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() { + let second = |d: &Driver| match &d.layout.root { + Some(Node::Split { second, .. }) => (**second).clone(), + _ => panic!("{:?}", d.layout.root), + }; + let wheel = |unit, y| Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }; + let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE }; + + // Off by default in the Dock profile. + let mut d = Driver::new(frames()); + let over = d.tab(2).center(); + d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); + d.step(vec![middle(over, true)]); + d.step(vec![middle(over, false)]); + assert_eq!(second(&d), tabs(&[2, 3], 1)); + + let mut d = Driver::new(frames()); + d.tweak = |style| style.profile = Profile::Frame; + d.step(vec![]); + let over = d.tab(2).center(); + d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "wheel up selects the previous tab"); + d.step(vec![wheel(egui::MouseWheelUnit::Line, 1.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "and stops at the first"); + d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "trackpad scrolling adds up to a notch"); + let out = d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]); + assert_eq!(second(&d), tabs(&[2, 3], 1)); + assert_eq!(out.focused, Some(3)); + + d.step(vec![middle(over, true)]); + let out = d.step(vec![middle(over, false)]); + assert_eq!(out.actions, vec![Action::Close { panel: 2 }]); + assert_eq!(second(&d), tabs(&[3], 0)); +} diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index b87dfca2..cb44cd8a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -32,6 +32,9 @@ const BORDER_BAND: f32 = 4.0; /// Width of the "…" button that lists the panels a strip has no room for. const OVERFLOW_WIDTH: f32 = 22.0; +/// Points of smooth (trackpad) scrolling that make one wheel notch. +const WHEEL_NOTCH: f32 = 40.0; + impl Renderer<'_, P> { pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) { let Some(selected) = panels.get(active) else { return }; @@ -127,6 +130,10 @@ impl Renderer<'_, P> { // Registered last, so the band wins over the tabs below it. let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())))); self.group_handle(ui, "drag-border", border, &owner, movable); + if self.style.wheel_selects_tabs() { + let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom()))); + self.wheel_tabs(ui, tabs_area, panels, active); + } // Logical order, hidden tabs included without a hit area, for no-op detection. let tabs = panels.iter().map(|panel| (panel.clone(), shown.iter().find(|(p, _)| p == panel).map_or(Rect::NOTHING, |(_, rect)| *rect))).collect(); @@ -200,6 +207,42 @@ impl Renderer<'_, P> { egui::Popup::menu(&response).open_memory(command).show(|ui| self.group_menu(ui, owner, owner.selected)); } + /// The wheel over a group's strip: down selects the next tab, up the previous, one tab per + /// notch, stopping at the ends. Trackpad scrolling adds up to a notch. + fn wheel_tabs(&mut self, ui: &Ui, strip: Rect, panels: &[P], active: usize) { + let key = self.id.with(("tab-wheel", panels.first())); + if !ui.rect_contains_pointer(strip) { + ui.ctx().data_mut(|d| d.remove::(key)); + return; + } + let notches: f32 = ui.input(|i| { + i.events + .iter() + .map(|event| match event { + egui::Event::MouseWheel { unit, delta, modifiers, .. } if !modifiers.command && !modifiers.ctrl => match unit { + egui::MouseWheelUnit::Point => delta.y / WHEEL_NOTCH, + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => delta.y, + }, + _ => 0.0, + }) + .sum() + }); + if notches == 0.0 || !notches.is_finite() { + return; + } + let total = ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(0.0) + notches; + let steps = total.trunc(); + ui.ctx().data_mut(|d| d.insert_temp(key, total - steps)); + // A wheel turned down scrolls content up: a negative delta selects the next tab. + let to = (active as i64 - steps as i64).clamp(0, panels.len().saturating_sub(1) as i64) as usize; + if to != active + && let Some(panel) = panels.get(to) + { + self.output.actions.push(Action::Activate { panel: panel.clone() }); + self.output.focused = Some(panel.clone()); + } + } + fn start_group_drag(&mut self, ui: &Ui, panels: &[P], rect: Rect) { let Some(first) = panels.first() else { return }; let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(rect.center()); @@ -361,6 +404,9 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } } + if self.style.middle_click_closes_tabs() && policy.closable && response.clicked_by(egui::PointerButton::Middle) { + self.output.actions.push(Action::Close { panel: panel.clone() }); + } if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { self.latch_undock(ui.ctx(), owner.map(|owner| owner.kind)); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 411fb03a..99a8e4b9 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -99,6 +99,12 @@ pub struct DockStyle { pub close_menu_items: bool, /// Show × on the title bar of a floating window whose panels are all closable. pub close_button: bool, + /// The mouse wheel over a tab strip selects the next or previous tab, one per notch. + /// `None`: on in [`Profile::Frame`], off in [`Profile::Dock`]. + pub wheel_selects_tabs: Option, + /// A middle click on a tab closes its panel when the panel is closable. `None`: on in + /// [`Profile::Frame`], off in [`Profile::Dock`]. + pub middle_click_closes_tabs: Option, } /// Which arrangement a [`DockStyle`] renders and labels. @@ -167,9 +173,19 @@ impl DockStyle { new_window_label: "Move Panel to New Window".into(), close_menu_items: true, close_button: true, + wheel_selects_tabs: None, + middle_click_closes_tabs: None, } } + pub(super) fn wheel_selects_tabs(&self) -> bool { + self.wheel_selects_tabs.unwrap_or(self.profile == Profile::Frame) + } + + pub(super) fn middle_click_closes_tabs(&self) -> bool { + self.middle_click_closes_tabs.unwrap_or(self.profile == Profile::Frame) + } + pub(super) fn valid(&self) -> bool { [ self.tab_height, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 7ca9779d..d159c887 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -176,6 +176,9 @@ A flyout fits its content the same way, without the title bar. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. - Double-click a tab or drag bar to minimize a group to its strip. +- In the Frame profile, the mouse wheel over a tab strip selects the next or previous tab, and a + middle click on a closable panel's tab closes it. `DockStyle::wheel_selects_tabs` and + `middle_click_closes_tabs` turn either on or off in any profile. - A column's »/« button collapses it to icons, one icon per panel with a separator between groups. Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the canvas side. Drag an icon to move its panel, or the grip above an icon group to move the group. From 30cebdc0f2d57941669cda8490394b4e4e5211fe Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:57:14 +0000 Subject: [PATCH 048/160] docking: content that fills its body floats at floating_size or 320 A panel body that reaches the bottom of its rect has no height of its own, so a floated panel or group falls back to floating_size, else 320 points, instead of the whole frame height. A body in a window fitted to its recorded height keeps that height. Panels leaving a frame fit their content like panels leaving a dock. --- crates/ui/src/docking/group_tests.rs | 27 +++++++++++++++++++++++ crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/preview.rs | 13 +++++++---- crates/ui/src/docking/surface.rs | 29 ++++++++++++++++++------- docs/ui-docking.md | 8 ++++--- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index cad73cfc..62d6013f 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -54,10 +54,16 @@ struct TestContent { whole_group: bool, /// Height every body takes; `None` draws a one-line label. body_height: Option, + /// Every body takes its whole rect. + fill: bool, } impl DockContent for TestContent { fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + if self.fill { + ui.allocate_rect(ui.max_rect(), egui::Sense::hover()); + return; + } match self.body_height { Some(height) => _ = ui.allocate_exact_size(vec2(10.0, height), egui::Sense::hover()), None => _ = ui.label("Body"), @@ -593,6 +599,27 @@ fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() { assert_eq!(*height, title + strip + 150.0); } +#[test] +fn a_panel_that_fills_its_frame_floats_at_its_floating_size_or_320() { + let float = |floating_size: Option| { + let content = TestContent { fill: true, floating_size, ..Default::default() }; + let mut d = Driver::with(frames(), content, Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 600.0))); + let target = pos2(400.0, 300.0); + d.hold(d.tab(3).center(), target); + d.modifiers = Modifiers::COMMAND; + let out = d.step(vec![button(target, false)]); + out.actions + .iter() + .find_map(|a| match a { + Action::Float { panel: 3, rect } => Some(rect[3]), + _ => None, + }) + .unwrap_or_else(|| panic!("{:?}", out.actions)) + }; + assert_eq!(float(None), 320.0); + assert_eq!(float(Some(vec2(300.0, 400.0))), 400.0); +} + #[test] fn tab_focus_follows_only_the_users_input() { let mut d = Driver::new(docked()); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index d099edeb..167f68d9 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -218,7 +218,7 @@ impl Renderer<'_, P> { ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); - self.float_space.record_content(panel.clone(), child.min_rect().height()); + self.float_space.record_content(panel.clone(), child.min_rect().height(), rect.height()); // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 13f36e5a..861e9afd 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -279,16 +279,21 @@ impl Renderer<'_, P> { } /// Size of a panel floated by a drag or the Float menu: the host's floating size, else the - /// source size within the panel limits. Out of a dock column, or with a floating size, the - /// height fits the content the panel last drew, up to that height (320 in a dock). + /// source size within the panel limits. Out of a dock or frame, or with a floating size, the + /// height fits the content the panel last drew, up to that height (else 320). Content that + /// filled its body floats at that height. pub(super) fn float_size(&self, panel: &P, source: Vec2) -> Vec2 { let chrome = self.style.title_height + self.style.tab_height; if let Some(size) = self.content.floating_size(panel).filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0) { return vec2(size.x, self.fitted_height(panel, chrome, size.y).unwrap_or(size.y)); } let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); - if matches!(self.layout.place(panel), Some(Place::Dock { .. })) { - size.y = self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT).unwrap_or(size.y.min(DOCKED_FLOAT_HEIGHT)); + if matches!(self.layout.place(panel), Some(Place::Dock { .. } | Place::Node(_))) { + size.y = match self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT) { + Some(height) => height, + None if self.float_space.filled(panel) => DOCKED_FLOAT_HEIGHT, + None => size.y.min(DOCKED_FLOAT_HEIGHT), + }; } let limits = self.limits(panel); let header = vec2(0.0, self.style.tab_height); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 99a8e4b9..9c1dc6b3 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -255,8 +255,8 @@ pub struct FloatSpace

{ pub(super) groups: Vec<(P, Vec2)>, pub(super) columns: Vec<(P, Vec2)>, /// The height each panel's content used when it was last drawn, which fits a floated group - /// to its content. - pub(super) contents: Vec<(P, f32)>, + /// to its content; `None` for content that filled its body. + pub(super) contents: Vec<(P, Option)>, } impl FloatSpace

{ @@ -275,9 +275,16 @@ impl FloatSpace

{ } impl FloatSpace

{ - pub(super) fn record_content(&mut self, panel: P, height: f32) { - let height = if height.is_finite() { height.max(0.0) } else { 0.0 }; - match self.contents.iter_mut().find(|(p, _)| *p == panel) { + /// Record the height `panel`'s content used in a body `available` tall. Content that + /// reaches the body's bottom filled it and has no height of its own, unless the body was + /// sized to the height recorded before (a fitted window). + pub(super) fn record_content(&mut self, panel: P, used: f32, available: f32) { + const TOLERANCE: f32 = 1.0; + let used = if used.is_finite() { used.max(0.0) } else { 0.0 }; + let recorded = self.contents.iter_mut().find(|(p, _)| *p == panel); + let fitted = recorded.as_ref().and_then(|(_, height)| *height).is_some_and(|height| height >= available - TOLERANCE); + let height = (used < available - TOLERANCE || fitted).then_some(used); + match recorded { Some((_, recorded)) => *recorded = height, None => self.contents.push((panel, height)), } @@ -299,8 +306,13 @@ impl FloatSpace

{ Self::find(&self.columns, panel) } + /// Whether `panel`'s content filled its body when it was last drawn. + pub(super) fn filled(&self, panel: &P) -> bool { + self.contents.iter().any(|(p, height)| p == panel && height.is_none()) + } + pub(super) fn content(&self, panel: &P) -> Option { - self.contents.iter().find(|(p, _)| p == panel).map(|(_, height)| *height) + self.contents.iter().find(|(p, _)| p == panel).and_then(|(_, height)| *height) } /// The first record wins: an iconic panel is recorded at its icon before its flyout. @@ -513,8 +525,9 @@ pub trait DockContent

{ /// Preferred floating size, independent of the current dock geometry: the width of a /// floated panel and the most height it takes. A floated panel or group, and a flyout, fit /// their height to the content the active panel last drew (at least 120 points), up to this - /// height, or up to 320 out of a dock when this is `None`. A rect a command gives and a - /// window the user resized keep their size. + /// height, or up to 320 out of a dock when this is `None`. Content that filled its body + /// takes this height, else 320. A rect a command gives and a window the user resized keep + /// their size. fn floating_size(&self, _panel: &P) -> Option { None } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index d159c887..86d92121 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -156,9 +156,11 @@ the bar itself shrinks it. A tab dropped on the "…" button goes to the end of A panel or group floated by a drag, a menu or a command without a rect takes its content height: the title bar, the tab strip and the height the active panel's content used when it was last drawn (the content ui's `min_rect`), at least 120 points. The most it takes is the height of -`DockContent::floating_size`, or 320 points for a panel or group leaving a dock. A short panel -therefore floats as a short window. A panel's width is its `floating_size` width, else its docked -width; a group keeps its docked width. +`DockContent::floating_size`, or 320 points for a panel leaving a dock or frame and a group +leaving a dock. A short panel therefore floats as a short window. Content that reaches the bottom +of its body (a panel that allocates its whole rect) has no height of its own: it floats at the +`floating_size` height, else at 320 points. A panel's width is its `floating_size` width, else +its docked width; a group keeps its docked width. A flyout fits its content the same way, without the title bar. - A panel the area has not drawn floats at the `floating_size` height, else the docked height From 89849272a060e13fd53857ef59891aa6b8fc13ec Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:58:10 +0000 Subject: [PATCH 049/160] docking: public widget ids and floating window layers DockArea::hit_id gives the egui id of the control a Hit names, and DockArea::floating_layer the layer of a floating window, so apps and their tests stop rebuilding internal ids. The divider between stacked groups has one id in docks and floating windows. --- crates/ui/src/docking/floating_tests.rs | 2 +- crates/ui/src/docking/hits.rs | 89 +++++++++++++++++------- crates/ui/src/docking/render/floating.rs | 2 +- crates/ui/src/docking/surface.rs | 2 +- crates/ui/src/docking/tests.rs | 5 +- docs/ui-docking.md | 5 ++ 6 files changed, 77 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 008994c8..5e3c1ba7 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -286,7 +286,7 @@ fn floating_stack_seams_and_bands_insert_groups() { // The divider between two open groups resizes both. let mut d = Driver::new(workspace(), Profile::Dock); - let divider = d.response(Id::new(AREA).with(("floating-divider", Some(&5u32)))).unwrap().center(); + let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::Group(5u32)))).unwrap().center(); d.drag(divider, divider + vec2(0.0, 40.0)); let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}"); diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 26c25d91..e7728967 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -2,9 +2,9 @@ use std::{collections::HashMap, fmt::Debug, hash::Hash}; -use egui::{Context, Id, Rect}; +use egui::{Context, Id, LayerId, Order, Rect}; -use super::{DockArea, Layout, Node, render::RESIZE_HANDLES}; +use super::{DockArea, Floating, Layout, Node, render::RESIZE_HANDLES}; /// A rendered docking control that starts a gesture. Groups, columns and windows are named by /// their first panel. @@ -76,40 +76,81 @@ impl DockArea<'_, P> { pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit

, Rect)> { ctx.data(|d| d.get_temp::, Rect)>>(key(id))).unwrap_or_default() } + + /// The egui widget id of the control `hit` names in the area `id`, for tests and + /// automation that focus a control or read its response. + pub fn hit_id(id: Id, hit: &Hit

) -> Id { + match hit { + Hit::Tab(p) => id.with(("tab", p)), + Hit::Icon(p) => id.with(("icon", p)), + Hit::Overflow(p) => id.with(("overflow", p)), + Hit::StackToggle(p) => id.with(("collapse", p)), + Hit::Divider(Divider::Stack(p)) => id.with(("stack-resize", p)), + Hit::DragBar(p) => id.with(("drag-bar", Some(p))), + Hit::Border(p) => id.with(("drag-border", Some(p))), + Hit::IconGrip(p) => id.with(("icon-grip", Some(p))), + Hit::ColumnBar(p) => id.with(("column-bar", Some(p))), + Hit::Collapse(p) => id.with(("column-collapse", Some(p))), + Hit::Divider(Divider::Column(p)) => id.with(("column-resize", Some(p))), + Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))), + Hit::Divider(Divider::Split(path)) => id.with(("split", path)), + Hit::Title(p) => id.with(("move-group", Some(p))), + Hit::Close(p) => id.with(("move-group", Some(p))).with("close"), + Hit::Resize(p, edge) => { + let name = match edge { + ResizeEdge::BottomRight => RESIZE_HANDLES[0], + ResizeEdge::Left => RESIZE_HANDLES[1], + ResizeEdge::Right => RESIZE_HANDLES[2], + ResizeEdge::Bottom => RESIZE_HANDLES[3], + ResizeEdge::BottomLeft => RESIZE_HANDLES[4], + }; + id.with(("floating", p)).with(name) + } + } + } + + /// The egui layer of a floating window in the area `id`; `None` for an empty window. Of two + /// layers, the later in `ctx.memory(|m| m.layer_ids())` is drawn on top, and + /// `ctx.memory(|m| m.area_rect(layer.id))` is the window's rect. + pub fn floating_layer(id: Id, window: &Floating

) -> Option { + window.panels().next().map(|first| LayerId::new(Order::Middle, id.with(("floating", first)))) + } } /// Name the controls the renderer registered this frame and store their rects. pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { let mut names: HashMap> = HashMap::new(); + let mut name = |hit: Hit

| { + names.insert(DockArea::

::hit_id(area, &hit), hit); + }; for panel in layout.panels() { let p = || panel.clone(); - let some = Some(panel); - names.insert(area.with(("tab", panel)), Hit::Tab(p())); - names.insert(area.with(("icon", panel)), Hit::Icon(p())); - names.insert(area.with(("overflow", panel)), Hit::Overflow(p())); - names.insert(area.with(("collapse", panel)), Hit::StackToggle(p())); - names.insert(area.with(("stack-resize", panel)), Hit::Divider(Divider::Stack(p()))); - names.insert(area.with(("drag-bar", some)), Hit::DragBar(p())); - names.insert(area.with(("drag-border", some)), Hit::Border(p())); - names.insert(area.with(("icon-grip", some)), Hit::IconGrip(p())); - names.insert(area.with(("column-bar", some)), Hit::ColumnBar(p())); - names.insert(area.with(("column-collapse", some)), Hit::Collapse(p())); - names.insert(area.with(("column-resize", some)), Hit::Divider(Divider::Column(p()))); - names.insert(area.with(("group-resize", some)), Hit::Divider(Divider::Group(p()))); - names.insert(area.with(("floating-divider", some)), Hit::Divider(Divider::Group(p()))); - let title = area.with(("move-group", some)); - names.insert(title, Hit::Title(p())); - names.insert(title.with("close"), Hit::Close(p())); - let window = area.with(("floating", panel)); - let edges = [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft]; - for (name, edge) in RESIZE_HANDLES.iter().zip(edges) { - names.insert(window.with(*name), Hit::Resize(p(), edge)); + for hit in [ + Hit::Tab(p()), + Hit::Icon(p()), + Hit::Overflow(p()), + Hit::StackToggle(p()), + Hit::Divider(Divider::Stack(p())), + Hit::DragBar(p()), + Hit::Border(p()), + Hit::IconGrip(p()), + Hit::ColumnBar(p()), + Hit::Collapse(p()), + Hit::Divider(Divider::Column(p())), + Hit::Divider(Divider::Group(p())), + Hit::Title(p()), + Hit::Close(p()), + ] { + name(hit); + } + for edge in [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft] { + name(Hit::Resize(p(), edge)); } } let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); while let Some((node, path)) = nodes.pop() { if let Node::Split { first, second, .. } = node { - names.insert(area.with(("split", &path)), Hit::Divider(Divider::Split(path.clone()))); + name(Hit::Divider(Divider::Split(path.clone()))); let mut second_path = path.clone(); second_path.push(true); let mut first_path = path; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index bfcabce0..1e46077f 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -196,7 +196,7 @@ impl Renderer<'_, P> { let pair_size = a.height() + b.height(); let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) }; let response = ResizeHandle::new( - self.id.with(("floating-divider", upper.panels.first())), + self.id.with(("group-resize", upper.panels.first())), SplitAxis::Vertical, divider, &self.style.resize_label, diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 9c1dc6b3..1a162b68 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -712,7 +712,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) || matches(self.id.with(("move-group", Some(first))).with("close")) }) - || floating.groups.iter().any(|g| matches(self.id.with(("floating-divider", g.panels.first())))) + || floating.groups.iter().any(|g| matches(self.id.with(("group-resize", g.panels.first())))) { return true; } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 4668b1eb..d2e9ae76 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -537,7 +537,10 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() { for _ in 0..3 { step(&ctx, &mut layout, Vec::new()); } - let id = Id::new("test-area").with(("floating", &5u32)).with("resize"); + let id = DockArea::hit_id(Id::new("test-area"), &Hit::Resize(5u32, ResizeEdge::BottomRight)); + let layer = DockArea::floating_layer(Id::new("test-area"), &layout.floating[0]).unwrap(); + let [x, y, width, height] = layout.floating[0].rect; + assert_eq!(ctx.memory(|m| m.area_rect(layer.id)), Some(Rect::from_min_size(pos2(x, y), vec2(width, height)))); ctx.memory_mut(|memory| memory.request_focus(id)); let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; step(&ctx, &mut layout, vec![key(egui::Key::ArrowRight), key(egui::Key::ArrowDown)]); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 86d92121..86bc0779 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -265,6 +265,11 @@ rect: tabs, drag bars, borders, column bars, »/« buttons, title bars, close bu grips, resize edges and dividers. It is always compiled, so an app can return it from a `ui.docking` control method next to the layout and the focused panel. +`DockArea::hit_id(id, &hit)` is the egui widget id of a control, for tests and automation that +focus it or read its response. `DockArea::floating_layer(id, &window)` is a floating window's +egui layer: compare its place in `memory.layer_ids()` with another layer to tell whether the +window covers it, and read its rect with `memory.area_rect(layer.id)`. + The `testing` feature adds `docking::testing::DockDriver`. It runs a `DockArea` in a headless egui context with real pointer and key events, and applies the actions each frame emits. From 38e2b27a303945f16265549a61119a96c25c936c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:59:02 +0000 Subject: [PATCH 050/160] docking: window.panel.dock with an edge zone and no anchor docks at that edge A left, right, top or bottom zone without anchor or side maps to Placement::Edge, anchored on any docked panel that does not move. A hidden panel is shown and moved in one step, all or nothing. --- crates/ui/src/docking/command.rs | 48 +++++++++++++++++++++++--- crates/ui/src/docking/command_tests.rs | 12 +++++++ docs/ui-docking.md | 2 ++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 11a0a8df..ad51a3af 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -81,8 +81,10 @@ pub enum DockCommand

{ column: bool, }, /// To `anchor`; else to a new column of the dock on `side` (a new dock when it has none); - /// else a floating panel joins the first dock group (or frame) as a tab, and a docked one - /// stays. A hidden panel without `anchor` and `side` is shown at its last location. + /// else with a `left`, `right`, `top` or `bottom` zone to a new frame on that outer edge + /// ([`Placement::Edge`]); else a floating panel joins the first dock group (or frame) as a + /// tab, and a docked one stays. A hidden panel without `anchor` and `side` is shown at its + /// last location, then moved to the edge a zone names. #[serde(rename = "window.panel.dock")] Dock { panel: P, @@ -406,10 +408,22 @@ impl DockCommand

{ }; return one(unit.to(layout, panel, to)?); } + let members = members(layout, panel, unit); + if let Some(zone) = self.edge() { + if before.is_some() { + return Err(DockError::InvalidPlacement); + } + let anchor = layout + .panels() + .into_iter() + .find(|p| !members.contains(p) && matches!(layout.place(p), Some(Place::Dock { .. } | Place::Node(_)))) + .cloned() + .ok_or(DockError::MissingTarget)?; + return one(unit.to(layout, panel, Target::At { anchor, placement: Placement::Edge(zone) })?); + } if !matches!(layout.place(panel), Some(Place::Floating { .. })) { return Ok(Vec::new()); } - let members = members(layout, panel, unit); let panels = layout.panels(); let free = || panels.iter().copied().filter(|p| !members.contains(p)); let docked = free().find(|p| matches!(layout.place(p), Some(Place::Dock { .. }))); @@ -498,8 +512,16 @@ impl DockCommand

{ _ => None, }; if let Some(panel) = show.filter(|p| !workspace.layout.contains(p)) { - workspace.set_visible(panel.clone(), true, None)?; - return Ok(Vec::new()); + if self.edge().is_none() { + workspace.set_visible(panel.clone(), true, None)?; + return Ok(Vec::new()); + } + // Shown, then moved to the edge, all or nothing. + let mut shown = workspace.clone(); + shown.set_visible(panel.clone(), true, None)?; + let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?; + *workspace = shown; + return Ok(closed); } if let Self::Lock { locked } = self { workspace.locked = locked.unwrap_or(!workspace.locked); @@ -510,6 +532,22 @@ impl DockCommand

{ } } +impl

DockCommand

{ + /// The outer edge `window.panel.dock` goes to: a side zone without an anchor or side. + fn edge(&self) -> Option { + match self { + Self::Dock { anchor: None, side: None, zone: Some(zone), .. } => match zone { + ZoneName::Left => Some(Zone::Left), + ZoneName::Right => Some(Zone::Right), + ZoneName::Top => Some(Zone::Top), + ZoneName::Bottom => Some(Zone::Bottom), + _ => None, + }, + _ => None, + } + } +} + /// The first panel of the last column of the dock on `side`. fn last_column(layout: &Layout

, side: Side) -> Option

{ let path = layout.dock_path(side)?; diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 78d46dd4..1a8a6bf2 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -393,3 +393,15 @@ fn minimize_without_a_value_toggles_an_accordion_entry_both_ways() { toggle.apply(&mut workspace, false, &space(), permissions).unwrap(); assert_eq!(workspace.layout.panel_state(&s("layers")), PanelState::Front); } + +#[test] +fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() { + let edge = |panel: &str, zone| Action::Move { panel: s(panel), anchor: s("layers"), placement: Placement::Edge(zone) }; + assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "color", "zone": "left"})), vec![edge("color", Zone::Left)]); + assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "history", "zone": "bottom"})), vec![edge("history", Zone::Bottom)]); + let (moved, _) = applied(json!({"command": "window.panel.dock", "panel": "color", "zone": "right"})); + assert!(matches!(moved.layout.place(&s("color")), Some(crate::docking::dock_model::Place::Node(_))), "{:?}", moved.layout); + let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches", "zone": "top"})); + assert!(matches!(shown.layout.place(&s("swatches")), Some(crate::docking::dock_model::Place::Node(_))) && shown.hidden.is_empty(), "{:?}", shown.layout); + rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement); +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 86bc0779..befbf0b6 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -223,6 +223,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when it floats. `side` is `Start` or `End`. +- `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a + new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. - `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see Floating size). It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. From 877f676e199cd3d4bed134c5930eae16923ff9e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 00:59:33 +0000 Subject: [PATCH 051/160] docking: menu item labels in title case Float Panel, Close Panel and Move Group To match the other menu items. Control names that only screen readers announce stay in sentence case. --- crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/labels.rs | 10 ++++++---- crates/ui/src/docking/render/group.rs | 2 +- crates/ui/src/docking/surface.rs | 8 ++++---- crates/ui/src/docking/tests.rs | 2 +- crates/ui/src/docking/trial_tests.rs | 4 ++-- docs/ui-docking.md | 2 +- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 62d6013f..e6f62fcd 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -531,7 +531,7 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { h.run_steps(1); h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); - for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group to"] { + for label in ["Close Tab Group", "Minimize", "Float Tab Group", "Move Group To"] { assert_eq!(h.query_all_by_label(label).count(), 1, "{label}"); } h.get_by_label("Close Tab Group").click(); diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 7e8a60ee..4a4799c5 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -4,7 +4,9 @@ use super::DockStyle; impl DockStyle { /// Replace every `*_label` field with `translate(key)`, keeping the English default where it - /// returns `None`. An application translates the docking chrome in one place: + /// returns `None`. The defaults of menu items are in title case; those of controls, which + /// only screen readers announce, in sentence case. An application translates the docking + /// chrome in one place: /// /// ``` /// fn dock_style(ui: &egui::Ui) -> craft_ui::docking::DockStyle { @@ -17,8 +19,8 @@ impl DockStyle { /// /// | Key | Field | English default | /// |---|---|---| - /// | `dock.float` | `float_label` | Float panel | - /// | `dock.close` | `close_label` | Close panel | + /// | `dock.float` | `float_label` | Float Panel | + /// | `dock.close` | `close_label` | Close Panel | /// | `dock.movePanelTo` | `move_panel_to_label` | Move Panel to Group | /// | `dock.panels` | `panels_label` | Panels | /// | `dock.resize` | `resize_label` | Resize panels | @@ -28,7 +30,7 @@ impl DockStyle { /// | `dock.minimize` | `minimize_label` | Minimize | /// | `dock.expand` | `expand_label` | Expand | /// | `dock.floatGroup` | `float_group_label` | Float Tab Group | - /// | `dock.moveGroupTo` | `move_group_to_label` | Move Group to | + /// | `dock.moveGroupTo` | `move_group_to_label` | Move Group To | /// | `dock.moveColumn` | `move_column_label` | Move panel column | /// | `dock.collapseToIcons` | `collapse_to_icons_label` | Collapse to Icons | /// | `dock.expandPanels` | `expand_panels_label` | Expand Panels | diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index cb44cd8a..e38208a1 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -258,7 +258,7 @@ impl Renderer<'_, P> { } /// The group items of the menu tail: Close Tab Group, Minimize or Expand, Float Tab Group - /// and Move Group to. `panel` is the panel whose menu this is. + /// and Move Group To. `panel` is the panel whose menu this is. fn group_menu(&mut self, ui: &mut Ui, owner: &TabGroup<'_, P>, panel: &P) { let all = |check: &dyn Fn(crate::docking::Permissions) -> bool| owner.panels.iter().all(|p| check(self.permissions(p))); let closable = self.style.close_menu_items && all(&|p| p.closable); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 1a162b68..1a93cac2 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -93,7 +93,7 @@ pub struct DockStyle { pub restore_frame_label: String, /// Shown where native windows are available (feature `native-windows`). pub new_window_label: String, - /// Show the built-in Close items in tab and group menus (Close panel, Close Tab Group, + /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, /// Close Other Panels in Group) for closable panels. A host that draws its own Close items /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. pub close_menu_items: bool, @@ -130,8 +130,8 @@ impl DockStyle { border: visuals.widgets.noninteractive.bg_stroke, accent: visuals.selection.stroke.color, font: FontId::proportional(12.0), - float_label: "Float panel".into(), - close_label: "Close panel".into(), + float_label: "Float Panel".into(), + close_label: "Close Panel".into(), move_panel_to_label: "Move Panel to Group".into(), panels_label: "Panels".into(), resize_label: "Resize panels".into(), @@ -141,7 +141,7 @@ impl DockStyle { minimize_label: "Minimize".into(), expand_label: "Expand".into(), float_group_label: "Float Tab Group".into(), - move_group_to_label: "Move Group to".into(), + move_group_to_label: "Move Group To".into(), drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index d2e9ae76..7dc09345 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1260,7 +1260,7 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert!(egui::Popup::is_any_open(&h.ctx)); - for logical in ["Float panel", "Move Panel to Group", "Close panel"] { + for logical in ["Float Panel", "Move Panel to Group", "Close Panel"] { assert!( h.output().shapes.iter().any(|shape| paints(&shape.shape, &format!("Action visual: {logical}"))), "custom action must paint: {logical}" diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 584f28e9..2afee3cb 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -319,8 +319,8 @@ fn hidden_close_controls_leave_closing_to_the_host() { d.step(vec![secondary(false)]); d.step(vec![]); let painted = |d: &Driver, label: &str| texts(d).iter().any(|(_, t)| t.galley.text() == label); - assert!(painted(&d, "Float panel"), "the tab menu is open"); - assert!(!painted(&d, "Close panel") && !painted(&d, "Close Tab Group")); + assert!(painted(&d, "Float Panel"), "the tab menu is open"); + assert!(!painted(&d, "Close Panel") && !painted(&d, "Close Tab Group")); // The panels stay closable: the host's own Close applies and reports what it closed. assert_eq!(d.layout.apply_with_permissions(Action::CloseWindow { panel: 5 }, |p| d.content.permissions(p)), Ok(vec![5, 6])); } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index befbf0b6..1a1aae4d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -250,7 +250,7 @@ the app's command registry. App-specific aliases stay in the app. ## Labels Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English -default. `DockStyle::localized` sets them all from one keyed lookup: +default: title case for menu items, sentence case for names only screen readers announce. `DockStyle::localized` sets them all from one keyed lookup: ```rust,ignore let style = DockStyle::from_ui(ui).localized(|key| app.strings.get(key).cloned()); From a76d196b80b06c98b34b6a78a973d4b02a810e54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 01:00:22 +0000 Subject: [PATCH 052/160] docking: dividers and resize grips keep focus after a pointer drag Arrow keys resize the handle right after a drag. Focus that came from the pointer draws no highlight until a key is pressed, and hovering still focuses nothing. --- crates/ui/src/docking/chrome.rs | 6 ++---- crates/ui/src/docking/tests.rs | 2 +- crates/ui/src/layout/mod.rs | 1 + crates/ui/src/layout/resize.rs | 25 ++++++++++++++++++------- crates/ui/src/layout/tests.rs | 15 ++++++++------- docs/ui-docking.md | 4 +++- 6 files changed, 33 insertions(+), 20 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 3aeab529..a9024e7d 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -194,6 +194,7 @@ impl<'a> FloatingFrame<'a> { started = Some(response.id); } } + let quiet = crate::layout::pointer_focus(ui, &response); let mut delta = Vec2::ZERO; if response.has_focus() { ui.memory_mut(|memory| { @@ -230,13 +231,10 @@ impl<'a> FloatingFrame<'a> { { resized = Some(next); } - if response.drag_stopped() { - response.surrender_focus(); - } if cancelled || !ui.input(|input| input.pointer.primary_down()) { ui.ctx().data_mut(|data| data.remove::(anchor_id)); } - if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { + if name == "resize" && (response.hovered() || response.dragged() || (response.has_focus() && !quiet)) { ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent)); } responses.push(response); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 7dc09345..e752bb51 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1478,7 +1478,7 @@ fn pointer_floating_resize_stops_at_edges_and_waits_for_cursor_to_return() { step(&ctx, &mut layout, vec![Event::PointerMoved(start)]); assert_eq!(layout.floating[0].rect, original, "{name}: returning to the original handle restores its geometry"); step(&ctx, &mut layout, vec![pointer_button(start, false)]); - assert!(!ctx.memory(|memory| memory.has_focus(id))); + assert!(ctx.memory(|memory| memory.has_focus(id)), "{name}: the grip keeps keyboard focus after the drag"); } } diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs index 335dd072..92cff198 100644 --- a/crates/ui/src/layout/mod.rs +++ b/crates/ui/src/layout/mod.rs @@ -9,6 +9,7 @@ mod resize; mod toolbar; pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect}; +pub(crate) use resize::pointer_focus; pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse}; pub use toolbar::{ToolbarFit, fit_toolbar}; diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index c0de628f..422c6546 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -24,7 +24,8 @@ pub struct ResizeResponse { /// A focusable divider with pointer, arrow-key and accessibility resizing. /// /// This widget never stores or changes a pane size. IDs and labels are separate, so translated -/// labels do not change identity. Pointer drags retain egui's immediate `Sense::drag` behavior. +/// labels do not change identity. Pointer drags retain egui's immediate `Sense::drag` behavior +/// and leave keyboard focus on the handle, highlighted once a key is pressed. /// Only arrows along the split axis are consumed, while this handle has focus. Tab remains /// available for normal focus traversal. pub struct ResizeHandle<'a> { @@ -82,15 +83,12 @@ impl<'a> ResizeHandle<'a> { let mut response = ui.interact(if valid { hit } else { inert }, self.id, if valid { Sense::drag() } else { Sense::hover() }); let enabled = valid && response.enabled(); let mut delta = 0.0; - // Pointer resizing borrows focus only for the gesture. Keyboard focus acquired - // through traversal remains intact until the user chooses another control. - if response.drag_stopped() { - response.surrender_focus(); - } + let mut quiet = false; if enabled { if response.drag_started() { response.request_focus(); } + quiet = pointer_focus(ui, &response); if response.dragged() { let drag = response.drag_delta(); delta = match self.axis { @@ -181,7 +179,7 @@ impl<'a> ResizeHandle<'a> { if enabled { let fill = if response.dragged() { Some(self.style.dragged) - } else if response.has_focus() { + } else if response.has_focus() && !quiet { Some(self.style.focused) } else if response.hovered() { Some(self.style.hovered) @@ -201,3 +199,16 @@ impl<'a> ResizeHandle<'a> { ResizeResponse { response, delta } } } + +/// A pointer drag focuses its handle, and the handle keeps focus after the release so arrow +/// keys resize it right away. That focus draws no highlight until a key is pressed. Call after +/// the handle requests focus; true while its focus came from the pointer. +pub(crate) fn pointer_focus(ui: &Ui, response: &Response) -> bool { + let key = response.id.with("pointer-focus"); + if response.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(key, true)); + } else if !response.has_focus() || ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) { + ui.ctx().data_mut(|d| d.remove::(key)); + } + ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(false) +} diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index 1a23ac02..660eb111 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -171,14 +171,15 @@ fn resize_handle_preserves_pointer_drag_lifecycle_and_reports_per_frame_delta() let (response, _) = h.frame(vec![pointer(center + vec2(16.0, 0.0), false)]); assert!(response.response.drag_stopped()); assert_eq!(response.delta, 0.0); - assert!(!response.response.has_focus(), "pointer release must not leave a sticky focus highlight"); + assert!(response.response.has_focus(), "a pointer drag leaves keyboard focus on the handle"); let (_, output) = h.frame(vec![Event::PointerMoved(pos2(300.0, 180.0))]); - assert!( - !output - .shapes - .iter() - .any(|shape| { matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == h.rect && rect.fill == Color32::BLUE) }) - ); + let handle = h.rect; + let highlighted = + |output: &FullOutput| output.shapes.iter().any(|shape| matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == handle && rect.fill == Color32::BLUE)); + assert!(!highlighted(&output), "focus from the pointer draws no highlight"); + let (response, output) = h.frame(vec![key(Key::ArrowRight)]); + assert_eq!(response.delta, 8.0, "arrow keys resize right after the drag"); + assert!(highlighted(&output)); assert_eq!(h.value, 100.0, "the component never owns pane state"); } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 1a1aae4d..9a0e924e 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -137,7 +137,9 @@ by the app's own Close items and commands. `DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a -workspace reset hides, closes or moves its panel, and then gives it up. `begin_drag` hands a drag that started in the app's own chrome to the area. +workspace reset hides, closes or moves its panel, and then gives it up. A divider or a floating +window's resize grip keeps keyboard focus after a pointer drag, so arrow keys resize it right +away; its focus highlight shows once a key is pressed. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. `docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview docking and `craft_ui::panes` both draw) for apps that draw their own windows. From 95c81fa6de8d511924d7b9d83990767bbad5bd69 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 01:00:30 +0000 Subject: [PATCH 053/160] docking: say that the drag bar's menu opens on a right click A left click on the drag bar focuses it and opens nothing, as designed; the Hit::DragBar docs said otherwise. --- crates/ui/src/docking/hits.rs | 3 ++- crates/ui/src/docking/render/group.rs | 5 +++-- docs/ui-docking.md | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index e7728967..baff6508 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -14,7 +14,8 @@ use super::{DockArea, Floating, Layout, Node, render::RESIZE_HANDLES}; pub enum Hit

{ /// A tab: click activates, drag moves the panel (S1). Tab(P), - /// A group's drag bar: click opens the group menu, drag moves the group (S2). + /// A group's drag bar: drag moves the group; a right click, Enter or Space opens the group + /// menu (S2). DragBar(P), /// The top border of a group's strip: drag moves the group (S3). Border(P), diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index e38208a1..0c01112e 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -152,8 +152,9 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); } - /// The drag bar (`drag-bar`: click, double-click, menu and drag) or the top border band - /// (`drag-border`: drag only). Both move the whole group. + /// The drag bar (`drag-bar`: drag, double-click, and the group menu on a right click, Enter + /// or Space) or the top border band (`drag-border`: drag only). Both move the whole group; + /// a left click on the bar only focuses it. fn group_handle(&mut self, ui: &mut Ui, name: &'static str, hit: Rect, owner: &TabGroup<'_, P>, movable: bool) { if !crate::docking::targets::usable(hit) { return; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 9a0e924e..c971e847 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -180,6 +180,8 @@ A flyout fits its content the same way, without the title bar. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. - Double-click a tab or drag bar to minimize a group to its strip. +- Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused + drag bar opens it too. A left click on the drag bar only focuses it. - In the Frame profile, the mouse wheel over a tab strip selects the next or previous tab, and a middle click on a closable panel's tab closes it. `DockStyle::wheel_selects_tabs` and `middle_click_closes_tabs` turn either on or off in any profile. From 3600104e7632336be5312b6e6f62b5acf2ea37e7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 01:00:56 +0000 Subject: [PATCH 054/160] docking: rustfmt the new tests --- crates/ui/src/docking/command_tests.rs | 6 +++++- crates/ui/src/layout/tests.rs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 1a8a6bf2..1f071169 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -402,6 +402,10 @@ fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() { let (moved, _) = applied(json!({"command": "window.panel.dock", "panel": "color", "zone": "right"})); assert!(matches!(moved.layout.place(&s("color")), Some(crate::docking::dock_model::Place::Node(_))), "{:?}", moved.layout); let (shown, _) = applied(json!({"command": "window.panel.dock", "panel": "swatches", "zone": "top"})); - assert!(matches!(shown.layout.place(&s("swatches")), Some(crate::docking::dock_model::Place::Node(_))) && shown.hidden.is_empty(), "{:?}", shown.layout); + assert!( + matches!(shown.layout.place(&s("swatches")), Some(crate::docking::dock_model::Place::Node(_))) && shown.hidden.is_empty(), + "{:?}", + shown.layout + ); rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement); } diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index 660eb111..72544c50 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -174,8 +174,9 @@ fn resize_handle_preserves_pointer_drag_lifecycle_and_reports_per_frame_delta() assert!(response.response.has_focus(), "a pointer drag leaves keyboard focus on the handle"); let (_, output) = h.frame(vec![Event::PointerMoved(pos2(300.0, 180.0))]); let handle = h.rect; - let highlighted = - |output: &FullOutput| output.shapes.iter().any(|shape| matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == handle && rect.fill == Color32::BLUE)); + let highlighted = |output: &FullOutput| { + output.shapes.iter().any(|shape| matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == handle && rect.fill == Color32::BLUE)) + }; assert!(!highlighted(&output), "focus from the pointer draws no highlight"); let (response, output) = h.frame(vec![key(Key::ArrowRight)]); assert_eq!(response.delta, 8.0, "arrow keys resize right after the drag"); From ceb75f965e30d7de7deaf336728ac26f9121c7c8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:24:45 +0000 Subject: [PATCH 055/160] ui: use US spelling --- crates/ui/src/docking/chrome.rs | 12 ++++++------ crates/ui/src/docking/command.rs | 2 +- crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/hits.rs | 2 +- crates/ui/src/docking/model.rs | 2 +- crates/ui/src/docking/render/dock.rs | 2 +- crates/ui/src/docking/render/group.rs | 2 +- crates/ui/src/docking/render/iconic.rs | 2 +- crates/ui/src/docking/render/preview.rs | 2 +- crates/ui/src/docking/surface.rs | 4 ++-- crates/ui/src/docking/trial_tests.rs | 4 ++-- crates/ui/src/docking/workspace.rs | 2 +- crates/ui/src/panes/mod.rs | 12 ++++++------ crates/ui/src/panes/tests.rs | 2 +- docs/ui-docking.md | 2 +- docs/ui-panes.md | 4 ++-- 16 files changed, 29 insertions(+), 29 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index a9024e7d..3d445e82 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -17,7 +17,7 @@ pub const TITLE_HEIGHT: f32 = 14.0; /// Names of the resize handles, appended to the frame id: `id.with(name)`. pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; -/// Colours and font of the frame and title bar. +/// Colors and font of the frame and title bar. #[derive(Clone, Debug)] pub struct ChromeStyle { /// Painted under the contents, so the window stays opaque when the contents are not. @@ -168,7 +168,7 @@ impl<'a> FloatingFrame<'a> { let mut started = None; let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; - let cancelled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + let canceled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); for (name, handle, direction) in handles(rect) { if !super::targets::usable(handle) { continue; @@ -226,12 +226,12 @@ impl<'a> FloatingFrame<'a> { None }; if response.enabled() - && !cancelled + && !canceled && let Some(next) = next.filter(|next| *next != rect) { resized = Some(next); } - if cancelled || !ui.input(|input| input.pointer.primary_down()) { + if canceled || !ui.input(|input| input.pointer.primary_down()) { ui.ctx().data_mut(|data| data.remove::(anchor_id)); } if name == "resize" && (response.hovered() || response.dragged() || (response.has_focus() && !quiet)) { @@ -387,7 +387,7 @@ pub enum MoveStep { /// The rect follows the pointer, inside the bounds. Moving(Rect), /// Escape or window focus loss ended the move: the rect it started from. - Cancelled(Rect), + Canceled(Rect), /// Released over `pointer`, with the rect where it was dropped. Dropped { rect: Rect, pointer: Pos2 }, } @@ -407,7 +407,7 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key)); if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { forget(); - return Some(MoveStep::Cancelled(start)); + return Some(MoveStep::Canceled(start)); } let pointer = ctx.pointer_latest_pos().unwrap_or(origin); let moved = clamp(start.translate(pointer - origin), bounds); diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index ad51a3af..e377f256 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -159,7 +159,7 @@ pub enum UiDock

{ } /// A `ui.dock` operation, tagged by `operation`. `move` takes `target` (the anchor), `zone` -/// (default `center`) and `before`, which only applies to a centre move. +/// (default `center`) and `before`, which only applies to a center move. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "camelCase", bound(serialize = "P: Serialize", deserialize = "P: Deserialize<'de>"))] #[non_exhaustive] diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index e6f62fcd..cdcfab24 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -303,7 +303,7 @@ fn group_drop_on_a_frame_edge_splits_and_on_a_protected_canvas_floats() { assert_eq!(**first, tabs(&[2, 3], 1)); assert_eq!(**second, tabs(&[1], 0)); - // A protected canvas takes nothing, at its centre or its edge: no join, no split, no + // A protected canvas takes nothing, at its center or its edge: no join, no split, no // preview; the group floats there. for at in [pos2(150.0, 300.0), pos2(20.0, 300.0)] { let mut d = Driver::with(frames(), canvas(), area); diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index baff6508..053732bb 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -73,7 +73,7 @@ fn key(area: Id) -> Id { impl DockArea<'_, P> { /// Every gesture source the area drew in its last frame, with its rect in points. Hosts /// return these from a `ui.docking` control method beside the layout and `focused`; tests - /// drag from their centres. + /// drag from their centers. pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit

, Rect)> { ctx.data(|d| d.get_temp::, Rect)>>(key(id))).unwrap_or_default() } diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index f4a0471a..3ca0c100 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -563,7 +563,7 @@ impl Layout

{ if location.anchor.is_some() { return Ok(location); } - // Alone in its group: anchor to the neighbouring group, then the neighbouring column. + // Alone in its group: anchor to the neighboring group, then the neighboring column. let first = |g: Option<&Group

>| g.and_then(|g| g.panels.first()).cloned(); if let Some(anchor) = first(groups.get(group + 1)) { location.anchor = Some(anchor); diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index c2822cb6..3709d169 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -1,7 +1,7 @@ //! [D] The dock profile: side docks of columns, each a column bar above stacked groups. //! //! Columns run from the window edge inwards. Every column's inner edge resizes it; the last -//! column's inner edge is the dock's inner edge. Behaviour follows VectorCraft `floating.rs` +//! column's inner edge is the dock's inner edge. Behavior follows VectorCraft `floating.rs` //! (column layout, drop geometry) and PhotoCraft `dock.rs` (group heights). use std::{fmt::Debug, hash::Hash}; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 0c01112e..74a33b06 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -176,7 +176,7 @@ impl Renderer<'_, P> { } let active = response.has_focus() || response.hovered(); if movable { - // A grip mark at rest, centred in the bar's reserved width beside the header. + // A grip mark at rest, centerd in the bar's reserved width beside the header. let grip = if self.style.right_to_left { Rect::from_min_max(hit.min, pos2((hit.left() + self.style.drag_bar_width).min(hit.right()), hit.bottom())) } else { diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 1be3872f..8949e334 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -1,6 +1,6 @@ //! [D] Iconic columns: one icon per panel, groups apart, and the flyout an icon opens. //! -//! Behaviour follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side, +//! Behavior follows VectorCraft `dock.rs` (icon column, flyouts beside it on the canvas side, //! names when the column is widened) and PhotoCraft `dock.rs` (Auto-Collapse Iconic Panels). use std::{fmt::Debug, hash::Hash}; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 861e9afd..aff38e95 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -77,7 +77,7 @@ impl Renderer<'_, P> { /// The valid target under the pointer for a whole group. A target holding the group /// itself is a no-op proposal; a target the model would reject is no target. A window - /// moving live looks through itself and does not split frames or join a body's centre. + /// moving live looks through itself and does not split frames or join a body's center. pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { let window = drag.gesture == GroupGesture::Window; let valid = |proposal: DropProposal

| { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 1a93cac2..36b037b3 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -44,7 +44,7 @@ pub struct DockStyle { /// Minimum width of the drag bar left of the group header. Tabs overflow before the bar /// narrows. pub drag_bar_width: f32, - /// Painted under every floating window, so it stays opaque when the panel colours are not. + /// Painted under every floating window, so it stays opaque when the panel colors are not. pub floating_background: Color32, pub floating_stroke: Stroke, /// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with @@ -454,7 +454,7 @@ pub(super) enum GroupGesture { pub(super) struct GroupDrag

{ /// The first member; identifies the gesture. pub(super) panel: P, - /// The floating window rect at the press, restored when a window move is cancelled. + /// The floating window rect at the press, restored when a window move is canceled. pub(super) original: [f32; 4], pub(super) gesture: GroupGesture, pub(super) members: Vec

, diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 2afee3cb..15b4535d 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -121,7 +121,7 @@ fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellips } #[test] -fn drag_bar_shows_a_grip_at_rest_and_a_group_drag_paints_a_filled_labelled_ghost() { +fn drag_bar_shows_a_grip_at_rest_and_a_group_drag_paints_a_filled_labeled_ghost() { let mut d = Driver::new(frames()); let visuals = d.ctx.global_style().visuals.clone(); let bar = d.rect("drag-bar", 2); @@ -248,7 +248,7 @@ fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() { assert!(second.right() <= first.left() + 0.5, "{first:?} {second:?}"); assert!(bar.right() <= second.left() + 0.5 && bar.left() >= 304.0 + 30.0 - 0.5, "the bar sits between the tabs and the header: {bar:?}"); - // Just right of the first tab's centre is before it in a right-to-left strip. + // Just right of the first tab's center is before it in a right-to-left strip. let out = d.drag(d.tab(1).center(), pos2(first.center().x + 6.0, first.center().y)); assert!(out.actions.contains(&Action::Move { panel: 1, anchor: 3, placement: Placement::Tab { before: Some(2) } }), "{:?}", out.actions); diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 81052e56..06cc91bc 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -80,7 +80,7 @@ impl Workspace

{ } /// Apply one action. Closed panels record where they go back, so each one can be reopened - /// with [`Self::set_visible`]. Honours [`Self::locked`]. Returns the panels the action + /// with [`Self::set_visible`]. Honors [`Self::locked`]. Returns the panels the action /// closed, in group order, so the application can release their state. pub fn apply(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.apply_all([action], permissions) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 9e2aeca4..75b20ddd 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -202,7 +202,7 @@ struct Memory { #[derive(Clone, Copy, Debug)] struct DragOut { pane: Id, - cancelled: bool, + canceled: bool, } struct Shown

{ @@ -556,11 +556,11 @@ impl PaneHost { requests: &mut Vec>, ) { if bar.drag_started() { - memory.drag_out = Some(DragOut { pane: s.id, cancelled: false }); + memory.drag_out = Some(DragOut { pane: s.id, canceled: false }); } let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { - drag.cancelled = true; + drag.canceled = true; } let near = rect.expand(DOCK_SNAP); let Some(pointer) = ctx.pointer_latest_pos() else { return }; @@ -570,7 +570,7 @@ impl PaneHost { let target = self.snap(pointer, bounds); if bar.drag_stopped() { memory.drag_out = None; - if drag.cancelled || !outside { + if drag.canceled || !outside { return; } match target { @@ -588,7 +588,7 @@ impl PaneHost { return; } memory.drag_out = Some(drag); - if drag.cancelled || !outside { + if drag.canceled || !outside { return; } let shape = match target.map(|t| self.side(t)) { @@ -635,7 +635,7 @@ impl PaneHost { } if content.movable(&s.pane) { match chrome::live_move(ctx, &shown.inner, shown.rect, bounds) { - Some(MoveStep::Moving(next) | MoveStep::Cancelled(next)) if next != shown.rect => { + Some(MoveStep::Moving(next) | MoveStep::Canceled(next)) if next != shown.rect => { requests.push(PaneRequest::FloatRect(s.pane.clone(), rect_array(next))); } Some(MoveStep::Dropped { rect, pointer }) => { diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index 16ea6dd0..e8ab8527 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -1,4 +1,4 @@ -//! Behaviour tests for the pane host, driven by egui pointer and key events in an 800 × 600 +//! Behavior tests for the pane host, driven by egui pointer and key events in an 800 × 600 //! window. The harness's root ui has an 8 pt margin, so the host spans x 8..792, y 8..592. use std::collections::HashMap; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index c971e847..122f0bae 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -64,7 +64,7 @@ layout.apply(Action::Move { preferred `height`), a new column, or a new frame on an outer edge. - Containers take different placements: a dock column takes groups and columns, not splits. `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or - centre zone onto what the anchor's container takes: a split in the frame tree, a new column on + center zone onto what the anchor's container takes: a split in the frame tree, a new column on that side or a new group above or below in a dock, a group above or below in a floating window. - Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move a floating window to its own OS window (`SetNative`, feature `native-windows`). diff --git a/docs/ui-panes.md b/docs/ui-panes.md index 43d6f5b5..b9c859ab 100644 --- a/docs/ui-panes.md +++ b/docs/ui-panes.md @@ -51,7 +51,7 @@ egui::CentralPanel::default().show(ui, |ui| { }); ``` -| Piece | Behaviour | +| Piece | Behavior | |---|---| | `Edge::{Start, End, Bottom}` | Logical edges. `Start` is left, or right with `.rtl(true)`. | | `SidePolicy::Columns` | Every docked pane shows; panes on one edge sit side by side in `open` order, the first outermost. | @@ -84,7 +84,7 @@ State: Accessibility, Alt Text, Restrict Editing and Comments at `End`. Default widths as today. - `Close(p)` runs the pane's existing command (`view.navigationPane {value: false}`, …). Command ids and `nav_pane` in `ui.json` stay as they are. -- Styles takes #2584's behaviour through the host: map `StylesPaneDock::{Left, Right, Floating}` +- Styles takes #2584's behavior through the host: map `StylesPaneDock::{Left, Right, Floating}` to `Place` (`Left` is `Docked(Start)` in left-to-right interfaces), run `view.stylesPaneDock` on `Place`, keep the float rect in UI prefs. #2584's own drag, snap and `float_rect` code goes. - Other panes return `None` from `states`; their widths stay session-only, as now. From cef258a356b7fa475af8418ee3d73699a3e52ccb Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:42:36 +0000 Subject: [PATCH 056/160] docking: a column's first tab docks beside its own column A dock target named by the dragged panel itself anchors on another panel of the same group or column, or leaves the panel in place when it is alone there, instead of failing with InvalidPlacement. --- crates/ui/src/docking/dock_tests.rs | 23 +++++++++++++++++++++++ crates/ui/src/docking/render/dock.rs | 12 +++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 9a17ed7e..8f950481 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -481,3 +481,26 @@ fn right_to_left_mirrors_docks_their_edges_and_new_docks() { d.settle(); assert_eq!(d.rect("column-collapse", 5).left(), 0.0); } + +#[test] +fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel() { + // Two columns, 0..240 and 240..480; the dragged tab is the first panel of the last one. + let wide = |columns: &[&[&[u32]]]| { + let mut layout = docked(Side::Start, columns); + if let Some(Node::Split { size, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(480.0); + } + layout + }; + let mut d = Driver::new(wide(&[&[&[2]], &[&[3, 4]]])); + let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0)); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]); + assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![2]], vec![vec![4]], vec![vec![3]]]); + + // Alone in its column, the panel stays where it is. + let layout = wide(&[&[&[2]], &[&[3]]]); + let mut d = Driver::new(layout.clone()); + let out = d.drag(d.tab(3).center(), pos2(484.0, 300.0)); + assert_eq!(out.actions, vec![]); + assert_eq!(d.layout, layout); +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 3709d169..96936f7a 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,7 +10,8 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer}; use crate::docking::{ - Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Profile, Side, Target, + Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Profile, Side, Target, + dock_model::Unit, targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, }; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; @@ -471,6 +472,15 @@ impl Renderer<'_, P> { let candidates = self.dock_candidates(); let drop = resolve_dock(pointer, &candidates)?; let action = match drop.target.to() { + // A target named by the dragged panel is its own group or column: another panel + // there names the same one. With none, the drop leaves the panel where it is. + Target::At { anchor, placement } if &anchor == panel => { + let unit = if matches!(placement, Placement::Column { .. }) { Unit::Column } else { Unit::Group }; + match self.layout.members(panel, unit).ok()?.into_iter().find(|p| p != panel) { + Some(anchor) => Action::Move { panel: panel.clone(), anchor, placement }, + None => return Some((None, drop.line)), + } + } Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, to @ Target::NewDock { .. } if self.layout.place(panel).and_then(|place| self.layout.group(&place)).is_some_and(|g| g.panels.len() == 1) => From a55cbe870ed6f4d178ced15a7ad0b1bae7e90780 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:42:52 +0000 Subject: [PATCH 057/160] docking: a huge wheel delta over a tab strip stops at the last tab The step count saturates instead of overflowing the index subtraction. --- crates/ui/src/docking/group_tests.rs | 4 ++++ crates/ui/src/docking/render/group.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index cdcfab24..059634b8 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -687,6 +687,10 @@ fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() { let out = d.step(vec![wheel(egui::MouseWheelUnit::Point, -30.0)]); assert_eq!(second(&d), tabs(&[2, 3], 1)); assert_eq!(out.focused, Some(3)); + d.step(vec![wheel(egui::MouseWheelUnit::Line, -1e30)]); + assert_eq!(second(&d), tabs(&[2, 3], 1), "a huge delta stops at the last tab"); + d.step(vec![wheel(egui::MouseWheelUnit::Line, 1e30)]); + assert_eq!(second(&d), tabs(&[2, 3], 0), "and at the first"); d.step(vec![middle(over, true)]); let out = d.step(vec![middle(over, false)]); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 74a33b06..bb313e1f 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -235,7 +235,7 @@ impl Renderer<'_, P> { let steps = total.trunc(); ui.ctx().data_mut(|d| d.insert_temp(key, total - steps)); // A wheel turned down scrolls content up: a negative delta selects the next tab. - let to = (active as i64 - steps as i64).clamp(0, panels.len().saturating_sub(1) as i64) as usize; + let to = (active as i64).saturating_sub(steps as i64).clamp(0, panels.len().saturating_sub(1) as i64) as usize; if to != active && let Some(panel) = panels.get(to) { From ae0c19b49bf5f04bfdd42425210e09459b1e5b48 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:44:25 +0000 Subject: [PATCH 058/160] docking: hidden floating windows do not cover dock targets Dock targets check the floating windows drawn this frame instead of every window's last egui area rect. floating_layer says that an undrawn window keeps its last area rect. --- crates/ui/src/docking/floating_tests.rs | 17 +++++++++++++++++ crates/ui/src/docking/hits.rs | 4 +++- crates/ui/src/docking/render/dock.rs | 20 +++++++++----------- crates/ui/src/docking/render/floating.rs | 4 ++-- crates/ui/src/docking/render/preview.rs | 8 ++++---- docs/ui-docking.md | 4 +++- 6 files changed, 38 insertions(+), 19 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 5e3c1ba7..da5ae98c 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -592,3 +592,20 @@ fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() { let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; assert!(matches!(**second, Node::Split { size, .. } if size != SplitSize::Ratio(0.5))); } + +#[test] +fn a_hidden_floating_window_does_not_cover_dock_targets() { + // Window 5 covers the column's seam at x 150..240 until it is hidden. + let mut layout = workspace(); + layout.floating = vec![Floating::single(vec![5], 0, [150.0, 250.0, 200.0, 150.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0])]; + let mut d = Driver::new(layout, Profile::Dock); + d.hide = Hide::Keep; + d.content.keep = vec![2, 3, 4, 8]; + d.step(vec![]); + d.step(vec![]); + d.drag(d.title(8).center(), pos2(200.0, 309.0)); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) }; + let Node::Dock { columns, .. } = &**first else { panic!("{first:?}") }; + let groups: Vec> = columns[0].groups.iter().map(|g| g.panels.clone()).collect(); + assert_eq!(groups, vec![vec![2, 3], vec![8], vec![4]]); +} diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 053732bb..c2eb05f4 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -112,7 +112,9 @@ impl DockArea<'_, P> { /// The egui layer of a floating window in the area `id`; `None` for an empty window. Of two /// layers, the later in `ctx.memory(|m| m.layer_ids())` is drawn on top, and - /// `ctx.memory(|m| m.area_rect(layer.id))` is the window's rect. + /// `ctx.memory(|m| m.area_rect(layer.id))` is the window's rect. A window the area does not + /// draw (all its panels hidden, or a frame maximized) keeps its last rect there: check + /// `ctx.memory(|m| m.areas().is_visible(&layer))` before treating it as covering anything. pub fn floating_layer(id: Id, window: &Floating

) -> Option { window.panels().next().map(|first| LayerId::new(Order::Middle, id.with(("floating", first)))) } diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 96936f7a..c5e88e4b 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -412,15 +412,13 @@ impl Renderer<'_, P> { }); } - /// Whether a floating window (other than the one following the pointer) or a host overlay - /// covers `pointer`. Covered dock targets take nothing. - fn dock_covered(&self, ctx: &Context, pointer: Pos2, moving: Option<&P>) -> bool { + /// Whether a floating window drawn this frame (other than the one following the pointer) + /// or a host overlay covers `pointer`. Covered dock targets take nothing. + fn dock_covered(&self, pointer: Pos2, moving: Option<&P>) -> bool { self.blockers.iter().any(|r| r.contains(pointer)) || self.iconic.covers(pointer) - || self.layout.floating.iter().any(|window| { - let Some(first) = window.panels().next() else { return false }; - !moving.is_some_and(|p| window.contains(p)) - && ctx.memory(|m| m.area_rect(self.id.with(("floating", first)))).is_some_and(|r| r.contains(pointer)) + || self.frame.stacks.iter().any(|window| { + window.window.contains(pointer) && !moving.is_some_and(|p| window.groups.iter().any(|(panels, ..)| panels.contains(p))) }) } @@ -451,9 +449,9 @@ impl Renderer<'_, P> { /// The dock target a whole group or column drag would take at `pointer`, with the action /// and its line. A target that leaves the layout unchanged is a no-op: `None` action. - pub(super) fn dock_group_proposal(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { + pub(super) fn dock_group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<(Option>, Rect)> { let moving = (drag.gesture == GroupGesture::Window).then_some(&drag.panel); - if self.dock_covered(ctx, pointer, moving) { + if self.dock_covered(pointer, moving) { return None; } let candidates = self.dock_candidates(); @@ -465,8 +463,8 @@ impl Renderer<'_, P> { /// The dock target a single tab drag would take: a new group, column or dock of that panel. /// A panel alone in its group takes the group's state to a new dock; one that shares its /// group leaves the other tabs behind (P6). - pub(super) fn dock_tab_proposal(&self, ctx: &Context, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { - if self.dock_covered(ctx, pointer, None) { + pub(super) fn dock_tab_proposal(&self, panel: &P, pointer: Pos2) -> Option<(Option>, Rect)> { + if self.dock_covered(pointer, None) { return None; } let candidates = self.dock_candidates(); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 1e46077f..f0045e23 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -310,8 +310,8 @@ impl Renderer<'_, P> { /// Release of a title bar drag. The window already sits where it was dragged; a dock or /// group target docks or merges every group in it, else hosts get the release in /// `group_drop`. - pub(super) fn drop_window(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { - if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) { + pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { self.output.actions.extend(action); return; } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index aff38e95..4feed8b9 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -66,7 +66,7 @@ impl Renderer<'_, P> { } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { - GroupGesture::Window => self.drop_window(ctx, &drag, pointer, float_only), + GroupGesture::Window => self.drop_window(&drag, pointer, float_only), GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { @@ -113,7 +113,7 @@ impl Renderer<'_, P> { if pointer.distance(drag.origin) < 2.0 { return; } - if !float_only && let Some((action, _)) = self.dock_group_proposal(ctx, drag, pointer) { + if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { self.output.actions.extend(action); return; } @@ -162,7 +162,7 @@ impl Renderer<'_, P> { if float_only { return; } - if let Some((action, line)) = self.dock_group_proposal(ctx, drag, pointer) { + if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { if action.is_some() { self.paint_dock_line(ctx, line); } @@ -214,7 +214,7 @@ impl Renderer<'_, P> { } return; } - let dock = if float_only || gone { None } else { self.dock_tab_proposal(ctx, &drag.panel, pointer) }; + let dock = if float_only || gone { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; if let Some((action, line)) = dock { if released { cancel_drag::

(ctx, self.id); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 122f0bae..df9f3f6d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -274,7 +274,9 @@ grips, resize edges and dividers. It is always compiled, so an app can return it `DockArea::hit_id(id, &hit)` is the egui widget id of a control, for tests and automation that focus it or read its response. `DockArea::floating_layer(id, &window)` is a floating window's egui layer: compare its place in `memory.layer_ids()` with another layer to tell whether the -window covers it, and read its rect with `memory.area_rect(layer.id)`. +window covers it, and read its rect with `memory.area_rect(layer.id)`. A window the area does not +draw (its panels hidden, or a frame maximized) keeps its last rect there, so check +`memory.areas().is_visible(&layer)` first. The `testing` feature adds `docking::testing::DockDriver`. It runs a `DockArea` in a headless egui context with real pointer and key events, and applies the actions each frame emits. From 815d6480163752340b1c500672dca632d3ab8a09 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:45:18 +0000 Subject: [PATCH 059/160] docking: split divider hits name the layout path while panels are hidden A split's handle id and its Hit::Divider use the layout path that ResizeSplit takes, also when hiding panels collapses a split in the drawn tree. --- crates/ui/src/docking/floating_tests.rs | 5 ++++- crates/ui/src/docking/render/mod.rs | 6 ++++-- crates/ui/src/docking/surface.rs | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index da5ae98c..76175b2c 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -586,7 +586,10 @@ fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() { d.step(vec![]); d.step(vec![]); assert_eq!(d.tab(1).map(|r| r.left()), Some(0.0), "the hidden frame's space goes to the others"); - let divider = d.response(Id::new(AREA).with(("split", &Vec::::new()))).unwrap().center(); + // Hits and widget ids name the split by its layout path, as `ResizeSplit` does. + let hit = Hit::Divider(Divider::Split(vec![true])); + assert!(DockArea::::hit_rects(&d.ctx, Id::new(AREA)).iter().any(|(h, _)| *h == hit), "the hits name the layout path"); + let divider = d.response(DockArea::::hit_id(Id::new(AREA), &hit)).unwrap().center(); let out = d.hold(divider, divider + vec2(0.0, 60.0)); assert!(out.actions.iter().any(|a| matches!(a, Action::ResizeSplit { path, .. } if path == &vec![true])), "{:?}", out.actions); let Some(Node::Split { size: SplitSize::FixedFirst(200.0), second, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 167f68d9..869c8d0c 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -95,8 +95,10 @@ impl Renderer<'_, P> { path.pop(); // The divider's inner-edge hit margin must win over child scrollbars, just // as accordion dividers win over the adjacent panel's content. + // Named by its layout path, which `ResizeSplit` takes and hits report. + let layout_path = self.original_path(path); let response = ResizeHandle::new( - self.id.with(("split", &*path)), + self.id.with(("split", &layout_path)), *axis, split.divider, &self.style.resize_label, @@ -108,7 +110,7 @@ impl Renderer<'_, P> { if response.delta != 0.0 && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) { - self.output.actions.push(Action::ResizeSplit { path: self.original_path(path), size }); + self.output.actions.push(Action::ResizeSplit { path: layout_path, size }); } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 36b037b3..234c7c4a 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -863,7 +863,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); renderer.report_flyout(ui.ctx()); - super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions); + super::hits::record(ui.ctx(), self.id, layout, &renderer.owned_interactions); record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); From 91e8cf401e92775fc324e3d60c95805b26dd5312 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:46:31 +0000 Subject: [PATCH 060/160] docking: chrome_has_focus recognizes every control a Hit names It checks the same widget ids as hit_rects and hit_id, which adds icons and icon grips. --- crates/ui/src/docking/hits.rs | 10 ++++- crates/ui/src/docking/iconic_tests.rs | 16 ++++++++ crates/ui/src/docking/surface.rs | 59 ++------------------------- docs/ui-docking.md | 4 +- 4 files changed, 29 insertions(+), 60 deletions(-) diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index c2eb05f4..be32efae 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -120,8 +120,8 @@ impl DockArea<'_, P> { } } -/// Name the controls the renderer registered this frame and store their rects. -pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { +/// Every control the area `area` can draw for `layout`, by widget id. +pub(super) fn named(area: Id, layout: &Layout

) -> HashMap> { let mut names: HashMap> = HashMap::new(); let mut name = |hit: Hit

| { names.insert(DockArea::

::hit_id(area, &hit), hit); @@ -162,6 +162,12 @@ pub(super) fn record(ctx: nodes.push((first, first_path)); } } + names +} + +/// Name the controls the renderer registered this frame and store their rects. +pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { + let mut names = named(area, layout); let hits: Vec<(Hit

, Rect)> = owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect(); ctx.data_mut(|d| d.insert_temp(key(area), hits)); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index a90cc298..b71ad904 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -371,3 +371,19 @@ fn an_icon_painted_in_code_replaces_the_initial_and_others_fall_back() { assert_eq!(initials.len(), 1, "only panel 2 draws its initial: {initials:?}"); assert!(two.contains(initials[0])); } + +#[test] +fn chrome_has_focus_covers_every_drawn_control() { + let mut layout = docked(&[&[1, 2], &[3]], true); + layout.floating.push(Floating::single(vec![5, 6], 0, [400.0, 100.0, 240.0, 200.0])); + let mut d = Driver::new(layout, Content); + d.settle(); + let hits = d.hits(); + assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Icon(_))), "{hits:?}"); + assert!(hits.iter().any(|(h, _)| matches!(h, Hit::IconGrip(_))), "{hits:?}"); + assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Resize(_, ResizeEdge::Left))), "{hits:?}"); + for (hit, _) in hits { + d.ctx.memory_mut(|m| m.request_focus(DockArea::::hit_id(d.id, &hit))); + assert!(DockArea::::new(d.id).chrome_has_focus(&d.ctx, &d.layout), "{hit:?}"); + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 234c7c4a..dc1c2a87 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -3,8 +3,8 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; use super::{ - Action, DockError, DropTarget, Layout, Node, Permissions, - render::{RESIZE_HANDLES, Renderer}, + Action, DockError, DropTarget, Layout, Permissions, + render::Renderer, resize::AnchoredResize, }; @@ -701,60 +701,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { if layout.validate().is_err() || ctx.read_response(focused).is_none() { return false; } - let matches = |id: Id| id == focused; - let group = |panels: &[P]| { - panels.iter().any(|panel| matches(self.id.with(("tab", panel))) || matches(self.id.with(("overflow", panel)))) - || ["move-group", "drag-bar", "drag-border"].iter().any(|name| matches(self.id.with((*name, panels.first())))) - }; - for floating in &layout.floating { - if floating.groups.iter().any(|g| group(&g.panels)) - || floating.panels().next().is_some_and(|first| { - RESIZE_HANDLES.iter().any(|name| matches(self.id.with(("floating", first)).with(*name))) - || matches(self.id.with(("move-group", Some(first))).with("close")) - }) - || floating.groups.iter().any(|g| matches(self.id.with(("group-resize", g.panels.first())))) - { - return true; - } - } - let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); - while let Some((node, path)) = nodes.pop() { - match node { - Node::Tabs { panels, .. } if group(panels) => return true, - Node::Dock { columns, .. } - if columns.iter().flat_map(|c| c.groups.iter()).any(|g| { - group(&g.panels) - || ["column-bar", "column-collapse", "column-resize", "group-resize"] - .iter() - .any(|name| matches(self.id.with((*name, g.panels.first())))) - }) => - { - return true; - } - Node::Stack { entries } => { - if entries.iter().any(|entry| { - matches(self.id.with(("tab", &entry.panel))) - || matches(self.id.with(("collapse", &entry.panel))) - || matches(self.id.with(("stack-resize", &entry.panel))) - }) { - return true; - } - } - Node::Split { first, second, .. } => { - if matches(self.id.with(("split", &path))) { - return true; - } - let mut second_path = path.clone(); - second_path.push(true); - let mut first_path = path; - first_path.push(false); - nodes.push((second, second_path)); - nodes.push((first, first_path)); - } - _ => {} - } - } - false + super::hits::named(self.id, layout).contains_key(&focused) } /// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate diff --git a/docs/ui-docking.md b/docs/ui-docking.md index df9f3f6d..5ceb81fd 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -135,8 +135,8 @@ Builder options on `DockArea`: and the floating window ×. They hide controls only: a panel that stays `closable` still closes by the app's own Close items and commands. -`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a -panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a +`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome (any control a +`Hit` names) rather than on a panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a workspace reset hides, closes or moves its panel, and then gives it up. A divider or a floating window's resize grip keeps keyboard focus after a pointer drag, so arrow keys resize it right away; its focus highlight shows once a key is pressed. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. From 46f2ffd654fe8e99c9a7aa47538e7d5802454124 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:47:12 +0000 Subject: [PATCH 061/160] docking: a press in a group header focuses the shown panel Host widgets in the header, such as a menu button or the host's own tabs, focus the panel the way a press on its tab or body does. --- crates/ui/src/docking/group_tests.rs | 5 ++++- crates/ui/src/docking/render/group.rs | 6 +++++- crates/ui/src/docking/surface.rs | 3 ++- docs/ui-docking.md | 3 ++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 059634b8..c87ab8f9 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -284,7 +284,10 @@ fn top_border_drags_the_group_and_the_menu_button_only_clicks() { assert!(held.group_drag.is_none()); let out = d.step(vec![button(menu + vec2(0.0, 200.0), false)]); assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveGroup { .. } | Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); - d.click(menu); + // A press in the header focuses the group's shown panel, like a press on its tab. + let pressed = d.step(vec![Event::PointerMoved(menu), button(menu, true)]); + assert_eq!(pressed.focused, Some(3)); + d.step(vec![button(menu, false)]); assert_eq!(d.content.header_clicks, 1); let out = d.drag(over_tab, d.tab(1).center()); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index bb313e1f..83c24ea8 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -75,6 +75,10 @@ impl Renderer<'_, P> { let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header)); child.set_clip_rect(header.intersect(ui.clip_rect())); self.content.group_header(&mut child, panels, selected, floating); + // A press in the header focuses the shown panel, as a press on its tab does. + if child.rect_contains_pointer(header) && child.input(|i| i.pointer.any_pressed()) { + self.output.focused = Some(selected.clone()); + } } // The drag bar keeps its reserved width: tabs overflow before it narrows. let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); @@ -176,7 +180,7 @@ impl Renderer<'_, P> { } let active = response.has_focus() || response.hovered(); if movable { - // A grip mark at rest, centerd in the bar's reserved width beside the header. + // A grip mark at rest, centered in the bar's reserved width beside the header. let grip = if self.style.right_to_left { Rect::from_min_max(hit.min, pos2((hit.left() + self.style.drag_bar_width).min(hit.right()), hit.bottom())) } else { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index dc1c2a87..ff5789a0 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -218,7 +218,8 @@ pub struct DockOutput

{ pub actions: Vec>, pub error: Option, /// The panel the user focused this frame: a clicked tab or menu entry, a drop, or a press - /// of any pointer button in a panel body, stripless panels included. Only user input sets + /// of any pointer button in a panel body (stripless panels included) or in a group header, + /// which focuses the group's shown panel. Only user input sets /// it; [`DockArea::raise_panel`] and host commands do not. pub focused: Option

, /// Whole-floating-group gesture identity; host may display its own group drop preview. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 5ceb81fd..a7586f9e 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -112,7 +112,8 @@ survives. `DockOutput` also has: - `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any - pointer button in a panel body, bare and protected panels included. Commands, `raise_panel` and + pointer button in a panel body (bare and protected panels included) or in the group header, + which focuses the group's shown panel. Commands, `raise_panel` and workspace resets never set it; a host that tracks the last focused panel clears it when a reset or command hides, closes or moves that panel. - `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and From 72a0a4dc78caf00c0b2a9c6811d65193932243d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:51:13 +0000 Subject: [PATCH 062/160] docking: rustfmt --- crates/ui/src/docking/render/dock.rs | 8 +++++--- crates/ui/src/docking/surface.rs | 6 +----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index c5e88e4b..83696661 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -417,9 +417,11 @@ impl Renderer<'_, P> { fn dock_covered(&self, pointer: Pos2, moving: Option<&P>) -> bool { self.blockers.iter().any(|r| r.contains(pointer)) || self.iconic.covers(pointer) - || self.frame.stacks.iter().any(|window| { - window.window.contains(pointer) && !moving.is_some_and(|p| window.groups.iter().any(|(panels, ..)| panels.contains(p))) - }) + || self + .frame + .stacks + .iter() + .any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.groups.iter().any(|(panels, ..)| panels.contains(p)))) } /// Dock targets, with the new-dock bands along the docked area's left and right edges that diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index ff5789a0..c8fdd84b 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -2,11 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use super::{ - Action, DockError, DropTarget, Layout, Permissions, - render::Renderer, - resize::AnchoredResize, -}; +use super::{Action, DockError, DropTarget, Layout, Permissions, render::Renderer, resize::AnchoredResize}; /// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. pub(super) const TAB_PADDING: f32 = 24.0; From 07585888d6db7033099e915f5ad2317eec38c40d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 03:51:38 +0000 Subject: [PATCH 063/160] docking: floating windows resize from the top edge and corners A resizable FloatingFrame keeps a 4 pt band above its contents for the Top, TopLeft and TopRight handles, so they take no title bar moves or button clicks. They keep the opposite edges in place, respect the size limits and the screen, take arrow keys when focused, and have hit ids. Moves carry the whole frame rect, band included. Ported from #189. Co-authored-by: filipessmedeiros-collab <343926472+filipessmedeiros-collab@users.noreply.github.com> --- crates/ui/src/docking/chrome.rs | 36 ++++++++++++++++-------- crates/ui/src/docking/floating_tests.rs | 33 +++++++++++++++++++--- crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/hits.rs | 17 ++++++++++- crates/ui/src/docking/iconic_tests.rs | 2 +- crates/ui/src/docking/render/floating.rs | 24 +++++++++------- crates/ui/src/docking/resize.rs | 30 +++++++++----------- crates/ui/src/docking/tests.rs | 2 +- crates/ui/src/panes/tests.rs | 2 +- docs/ui-docking.md | 4 +++ 10 files changed, 104 insertions(+), 48 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 3d445e82..a29f5afc 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -15,7 +15,12 @@ use super::{DockStyle, PanelLimits, resize::AnchoredResize}; pub const TITLE_HEIGHT: f32 = 14.0; /// Names of the resize handles, appended to the frame id: `id.with(name)`. -pub const RESIZE_HANDLES: [&str; 5] = ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left"]; +pub const RESIZE_HANDLES: [&str; 8] = + ["resize", "resize-left", "resize-right", "resize-bottom", "resize-bottom-left", "resize-top", "resize-top-left", "resize-top-right"]; + +/// Height of the top resize band of a resizable frame. The frame's contents start below it, so +/// the band takes no moves or clicks from a title bar. +pub const RESIZE_BAND: f32 = 4.0; /// Colors and font of the frame and title bar. #[derive(Clone, Debug)] @@ -51,9 +56,12 @@ impl ChromeStyle { } } -/// Resize handle hit rects and the edges they move: both sides, the bottom and both bottom -/// corners. The top edge belongs to the title bar. -fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { +/// Resize handle hit rects and the edges they move: every side and corner. The top handles +/// fill the band above the contents; the others lie over the contents' edges. +fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 8] { + let band_bottom = (rect.top() + RESIZE_BAND).min(rect.bottom()); + let left_corner = (rect.left() + 12.0).min(rect.right()); + let right_corner = (rect.right() - 12.0).max(rect.left()); [ ("resize", Rect::from_min_max((rect.max - vec2(12.0, 12.0)).max(rect.min), rect.max), vec2(1.0, 1.0)), ( @@ -73,12 +81,12 @@ fn handles(rect: Rect) -> [(&'static str, Rect, Vec2); 5] { ), ( "resize-bottom-left", - Rect::from_min_max( - pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), - pos2((rect.left() + 12.0).min(rect.right()), rect.bottom()), - ), + Rect::from_min_max(pos2(rect.left(), (rect.bottom() - 12.0).max(rect.top())), pos2(left_corner, rect.bottom())), vec2(-1.0, 1.0), ), + ("resize-top", Rect::from_min_max(pos2(left_corner, rect.top()), pos2(right_corner, band_bottom)), vec2(0.0, -1.0)), + ("resize-top-left", Rect::from_min_max(rect.min, pos2(left_corner, band_bottom)), vec2(-1.0, -1.0)), + ("resize-top-right", Rect::from_min_max(pos2(right_corner, rect.top()), pos2(rect.right(), band_bottom)), vec2(1.0, -1.0)), ] } @@ -118,7 +126,8 @@ impl<'a> FloatingFrame<'a> { self } - /// Size limits of a resize. The frame itself is drawn at the given rect. + /// Size limits of a resize, top resize band included. The frame itself is drawn at the + /// given rect. pub fn limits(mut self, limits: PanelLimits) -> Self { self.limits = limits; self @@ -139,7 +148,8 @@ impl<'a> FloatingFrame<'a> { LayerId::new(Order::Middle, id) } - /// Draw the frame, then `add_contents` with the frame's rect, then the outline and handles. + /// Draw the frame, then `add_contents` with the rect for its contents (the frame's rect below + /// the top resize band when resizable), then the outline and handles. pub fn show(self, ctx: &Context, style: &ChromeStyle, add_contents: impl FnOnce(&mut Ui, Rect) -> R) -> FrameResponse { let bounds = self.bounds.unwrap_or_else(|| ctx.content_rect()); let rect = clamp(self.rect, bounds); @@ -155,7 +165,9 @@ impl<'a> FloatingFrame<'a> { ui.set_min_size(rect.size()); ui.allocate_rect(rect, Sense::hover()); ui.painter().rect_filled(rect, 0.0, style.background); - let inner = add_contents(ui, rect); + let contents = + if self.resizable { Rect::from_min_max(pos2(rect.left(), (rect.top() + RESIZE_BAND).min(rect.bottom())), rect.max) } else { rect }; + let inner = add_contents(ui, contents); ui.painter().rect_stroke(rect, 0.0, style.stroke, StrokeKind::Outside); let (resized, resize_started, handles) = if self.resizable { self.handles(ui, rect, bounds, style) } else { (None, None, Vec::new()) }; FrameResponse { inner, rect, layer: Self::layer(self.id), resized, resize_started, handles } @@ -167,7 +179,7 @@ impl<'a> FloatingFrame<'a> { let mut resized = None; let mut started = None; let mut responses = Vec::new(); - let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; + let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; let canceled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); for (name, handle, direction) in handles(rect) { if !super::targets::usable(handle) { diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 76175b2c..1ecdb730 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -289,7 +289,7 @@ fn floating_stack_seams_and_bands_insert_groups() { let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::Group(5u32)))).unwrap().center(); d.drag(divider, divider + vec2(0.0, 40.0)); let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); - assert!((heights[0] - 181.5).abs() < 2.0 && (heights[1] - 101.5).abs() < 2.0, "{heights:?}"); + assert!((heights[0] - 179.5).abs() < 2.0 && (heights[1] - 99.5).abs() < 2.0, "{heights:?}"); } #[test] @@ -456,7 +456,7 @@ fn hide_panels_keeps_protected_panels_and_the_except_set() { } // A window keeps its rect with its hidden groups left out. d.step(vec![]); - assert_eq!(d.title(6).min, pos2(300.0, 80.0)); + assert_eq!(d.title(6).min, pos2(300.0, 84.0), "below the resize band"); } #[test] @@ -468,7 +468,7 @@ fn native_window_falls_back_in_window_when_viewports_are_embedded() { assert!(d.ctx.embed_viewports()); let out = d.step(vec![]); assert!(out.actions.is_empty(), "{out:?}"); - assert_eq!(d.title(5).min, pos2(300.0, 80.0)); + assert_eq!(d.title(5).min, pos2(300.0, 84.0)); assert_eq!(d.layout, layout); } @@ -513,7 +513,7 @@ fn native_window_close_returns_the_group_to_its_in_window_rect() { assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]); d.step(vec![]); d.step(vec![]); - assert_eq!(d.title(5).min, pos2(300.0, 80.0)); + assert_eq!(d.title(5).min, pos2(300.0, 84.0)); } #[test] @@ -612,3 +612,28 @@ fn a_hidden_floating_window_does_not_cover_dock_targets() { let groups: Vec> = columns[0].groups.iter().map(|g| g.panels.clone()).collect(); assert_eq!(groups, vec![vec![2, 3], vec![8], vec![4]]); } + +#[test] +fn top_edges_resize_from_the_band_above_the_title_bar() { + // W2 holds [8] at [560, 300, 200, 200]; each top handle keeps the opposite edges. + let area = Id::new(AREA); + for (edge, by, expected) in [ + (ResizeEdge::Top, vec2(0.0, -40.0), [560.0, 260.0, 200.0, 240.0]), + (ResizeEdge::TopLeft, vec2(-30.0, 20.0), [530.0, 320.0, 230.0, 180.0]), + (ResizeEdge::TopRight, vec2(20.0, -10.0), [560.0, 290.0, 220.0, 210.0]), + ] { + let mut d = Driver::new(workspace(), Profile::Dock); + let handle = d.response(DockArea::hit_id(area, &Hit::Resize(8u32, edge))).unwrap(); + assert_eq!(handle.bottom(), d.title(8).top(), "{edge:?} lies above the title bar"); + d.drag(handle.center(), handle.center() + by); + assert_eq!(window_of(&d.layout, 8).unwrap().rect, expected, "{edge:?}"); + } + // The title bar still moves the window, and arrow keys move the focused top edge. + let mut d = Driver::new(workspace(), Profile::Dock); + let title = d.title(8); + d.drag(pos2(title.center().x, title.top() + 1.0), pos2(title.center().x - 50.0, title.top() + 1.0)); + assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 300.0, 200.0, 200.0]); + d.ctx.memory_mut(|m| m.request_focus(DockArea::hit_id(area, &Hit::Resize(8u32, ResizeEdge::Top)))); + d.step(vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 292.0, 200.0, 208.0]); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index c87ab8f9..bface8f2 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -556,7 +556,7 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { let mut d = Driver::new(layout); let from = d.rect("drag-bar", 6).center(); let out = d.drag(from, pos2(700.0, 300.0)); - assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 191.5]) })), "{:?}", out.actions); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 189.5]) })), "{:?}", out.actions); assert_eq!(d.layout.floating.len(), 2); assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index be32efae..e3869fe4 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -49,6 +49,9 @@ pub enum ResizeEdge { Bottom, BottomLeft, BottomRight, + Top, + TopLeft, + TopRight, } /// A divider that resizes what is on either side of it. @@ -104,6 +107,9 @@ impl DockArea<'_, P> { ResizeEdge::Right => RESIZE_HANDLES[2], ResizeEdge::Bottom => RESIZE_HANDLES[3], ResizeEdge::BottomLeft => RESIZE_HANDLES[4], + ResizeEdge::Top => RESIZE_HANDLES[5], + ResizeEdge::TopLeft => RESIZE_HANDLES[6], + ResizeEdge::TopRight => RESIZE_HANDLES[7], }; id.with(("floating", p)).with(name) } @@ -146,7 +152,16 @@ pub(super) fn named(area: ] { name(hit); } - for edge in [ResizeEdge::BottomRight, ResizeEdge::Left, ResizeEdge::Right, ResizeEdge::Bottom, ResizeEdge::BottomLeft] { + for edge in [ + ResizeEdge::BottomRight, + ResizeEdge::Left, + ResizeEdge::Right, + ResizeEdge::Bottom, + ResizeEdge::BottomLeft, + ResizeEdge::Top, + ResizeEdge::TopLeft, + ResizeEdge::TopRight, + ] { name(Hit::Resize(p(), edge)); } } diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index b71ad904..d1008d3e 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -312,7 +312,7 @@ fn hit_rects_cover_every_gesture_source() { assert!(kinds(kind) > 0, "{name}: {hits:?}"); } assert!(hits.iter().any(|(h, _)| matches!(h, Hit::Divider(Divider::Stack(10))))); - assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 5); + assert_eq!(hits.iter().filter(|(h, _)| matches!(h, Hit::Resize(..))).count(), 8); for (hit, rect) in hits.iter().filter(|(h, _)| !matches!(h, Hit::Overflow(_) | Hit::Collapse(_) | Hit::StackToggle(_) | Hit::Close(_))) { let mut d = DockDriver::new(layout.clone(), Content); let from = rect.center(); diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index f0045e23..25ab4101 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2}; use super::{GroupKind, Renderer, clamp_float, node_limits}; -use crate::docking::chrome::{ChromeStyle, FloatingFrame, TitleBar}; +use crate::docking::chrome::{ChromeStyle, FloatingFrame, RESIZE_BAND, TitleBar}; use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target}; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; @@ -43,7 +43,10 @@ impl Renderer<'_, P> { /// Size limits of a floating window: its title bar, each group's strip and the bodies of /// the groups that are not minimized. pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { - let mut limits = PanelLimits { min: vec2(0.0, self.style.title_height), max: vec2(1_000_000.0, self.style.title_height) }; + // A movable window is resizable, with a resize band above its title bar. + let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; + let chrome = band + self.style.title_height; + let mut limits = PanelLimits { min: vec2(0.0, chrome), max: vec2(1_000_000.0, chrome) }; for (index, group) in window.groups.iter().enumerate() { let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content); let gap = if index > 0 { GAP } else { 0.0 }; @@ -62,7 +65,7 @@ impl Renderer<'_, P> { let [x, y, width, height] = window.rect; let limits = self.window_limits(window); let size = vec2(width, height).max(limits.min).min(limits.max); - let rect = Rect::from_min_size(pos2(x, y), size); + let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), screen); let id = self.id.with(("floating", first)); if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.move_to_top(FloatingFrame::layer(id)); @@ -73,7 +76,7 @@ impl Renderer<'_, P> { let response = FloatingFrame::new(id, rect).bounds(screen).limits(limits).resizable(movable).resize_label(&resize_label).show( ctx, &style, - |ui, rect| self.window_contents(ui, window, rect, &style), + |ui, contents| self.window_contents(ui, window, contents, rect, &style), ); for handle in &response.handles { self.register_interaction(ctx, handle.id); @@ -94,12 +97,13 @@ impl Renderer<'_, P> { } } - /// The title bar, then the stacked groups below it. - pub(super) fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, rect: Rect, style: &ChromeStyle) { - let title = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.title_height).min(rect.bottom()))); - self.title_bar(ui, window, title, rect, style); - let body = Rect::from_min_max(pos2(rect.left(), title.bottom()), rect.max); - self.floating_stack(ui, &window.groups, body, rect); + /// The title bar, then the stacked groups below it, in `contents`. Moves carry the whole + /// `frame`, resize band included. + fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, contents: Rect, frame: Rect, style: &ChromeStyle) { + let title = Rect::from_min_max(contents.min, pos2(contents.right(), (contents.top() + self.style.title_height).min(contents.bottom()))); + self.title_bar(ui, window, title, frame, style); + let body = Rect::from_min_max(pos2(contents.left(), title.bottom()), contents.max); + self.floating_stack(ui, &window.groups, body, frame); } /// Drag moves the whole window live, double-click minimizes or expands every group, × diff --git a/crates/ui/src/docking/resize.rs b/crates/ui/src/docking/resize.rs index 48e50f4e..24d62c29 100644 --- a/crates/ui/src/docking/resize.rs +++ b/crates/ui/src/docking/resize.rs @@ -81,26 +81,22 @@ mod tests { let start = Rect::from_min_max(pos2(100.0, 80.0), pos2(400.0, 300.0)); let viewport = Rect::from_min_max(Pos2::ZERO, pos2(800.0, 600.0)); let limits = PanelLimits { min: vec2(160.0, 120.0), max: vec2(500.0, 400.0) }; - for direction in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0)] { - let edge = pos2(if direction.x < 0.0 { start.left() } else { start.right() }, start.bottom()); + for direction in + [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0), vec2(0.0, -1.0), vec2(-1.0, -1.0), vec2(1.0, -1.0)] + { + let edge = + pos2(if direction.x < 0.0 { start.left() } else { start.right() }, if direction.y < 0.0 { start.top() } else { start.bottom() }); let pointer = edge + vec2(2.0, -3.0); let resize = AnchoredResize::new(start, pointer, direction).unwrap(); let rect = resize.rect(pointer + vec2(20.0, 25.0), viewport, limits).unwrap(); - assert_eq!(rect.top(), start.top()); - if direction.x < 0.0 { - assert_eq!(rect.right(), start.right()); - assert_eq!(rect.left(), start.left() + 20.0); - } else { - assert_eq!(rect.left(), start.left()); - } - if direction.x > 0.0 { - assert_eq!(rect.right(), start.right() + 20.0); - } - if direction.y > 0.0 { - assert_eq!(rect.bottom(), start.bottom() + 25.0); - } else { - assert_eq!(rect.bottom(), start.bottom()); - } + // A moving edge follows the pointer; every other edge stays. + let moved = |d: f32, lo: f32, hi: f32, by: f32| match d { + d if d < 0.0 => (lo + by, hi), + d if d > 0.0 => (lo, hi + by), + _ => (lo, hi), + }; + assert_eq!((rect.left(), rect.right()), moved(direction.x, start.left(), start.right(), 20.0), "{direction:?}"); + assert_eq!((rect.top(), rect.bottom()), moved(direction.y, start.top(), start.bottom(), 25.0), "{direction:?}"); } } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index e752bb51..2a111b82 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1420,7 +1420,7 @@ fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() { } ctx.memory_mut(|memory| memory.request_focus(id)); frame(vec![Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); - assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 40.0]); + assert_eq!(layout.floating[0].rect, [80.0, 70.0, 308.0, 44.0]); } #[test] diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index e8ab8527..6d449e66 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -189,7 +189,7 @@ fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { vec![PaneRequest::Place(Pane::Styles, Place::Floating), PaneRequest::FloatRect(Pane::Styles, [360.0, 112.0, 270.0, 480.0])], "floats under the pointer, clamped to the window" ); - assert_eq!(title(&h, Pane::Styles).min, pos2(360.0, 112.0)); + assert_eq!(title(&h, Pane::Styles).min, pos2(360.0, 116.0)); // Dropped within 32 pt of the right edge: docked at End. h.state_mut().log.clear(); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index a7586f9e..76423b13 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -178,6 +178,10 @@ A flyout fits its content the same way, without the title bar. - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags the whole group. A column bar drags a column. A floating window's title bar drags the window. +- A movable floating window resizes from every edge and corner. The top edge and corners are a + 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on + the title bar's buttons. A resize keeps the opposite edges in place and stays within the size + limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. - Double-click a tab or drag bar to minimize a group to its strip. From 95d5cddb5ffeecccdaeaeaeebdfffe3b18507fd9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:07:24 +0000 Subject: [PATCH 064/160] docking: a canvas that accepts splits stays protected Hiding panels and dropping the tab strip tested for exactly Permissions::PROTECTED, so a canvas set up as the docs describe (accepts_splits on) was hidden and got a tab strip. Permissions::is_protected decides from what the panel allows. --- crates/ui/src/docking/floating_tests.rs | 25 +++++++++++++++++++++++-- crates/ui/src/docking/model.rs | 6 ++++++ crates/ui/src/docking/render/frame.rs | 4 ++-- crates/ui/src/docking/render/mod.rs | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 1ecdb730..85015888 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -48,17 +48,25 @@ fn workspace() -> Layout { struct Content { native: bool, keep: Vec, + /// Canvas 0 accepts side splits, as an app that opens panels beside it does. + beside: bool, + drawn: Vec, } impl DockContent for Content { - fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.drawn.push(*panel); ui.label("Body"); } fn logical_label(&self, panel: &u32) -> String { format!("Panel {panel}") } fn permissions(&self, panel: &u32) -> Permissions { - if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + match *panel { + 0 if self.beside => Permissions { accepts_splits: true, ..Permissions::PROTECTED }, + 0 => Permissions::PROTECTED, + _ => Permissions::default(), + } } fn allow_native(&self, _: &u32) -> bool { self.native @@ -459,6 +467,19 @@ fn hide_panels_keeps_protected_panels_and_the_except_set() { assert_eq!(d.title(6).min, pos2(300.0, 84.0), "below the resize band"); } +#[test] +fn a_canvas_that_accepts_splits_is_still_protected() { + let mut d = Driver::new(workspace(), Profile::Dock); + d.content.beside = true; + d.hide = Hide::All; + d.step(vec![]); + d.content.drawn.clear(); + d.step(vec![]); + assert_eq!(d.content.drawn, vec![0], "hiding every panel keeps the canvas"); + assert!(d.tab(0).is_none()); + assert!(d.response(DockArea::::hit_id(Id::new(AREA), &Hit::DragBar(0))).is_none()); +} + #[test] fn native_window_falls_back_in_window_when_viewports_are_embedded() { let mut layout = workspace(); diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 3ca0c100..e79bf096 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -346,6 +346,12 @@ impl Permissions { /// uses `Permissions { accepts_splits: true, ..Permissions::PROTECTED }`. pub const PROTECTED: Self = Self { movable: false, floatable: false, closable: false, accepts_tabs: false, accepts_splits: false }; + /// A canvas: it can neither move, close nor take tabs. `accepts_splits` and `floatable` + /// do not matter, so a canvas that opens panels beside it still counts. + pub fn is_protected(self) -> bool { + !self.movable && !self.closable && !self.accepts_tabs + } + /// Lock Workspace: nothing moves, floats or closes; tab and split drops keep their policy. /// [`super::Workspace::apply`] applies this when the workspace is locked; an application /// with a global lock preference applies it in its own permissions callback. diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 0296daf6..8b90fe99 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -6,7 +6,7 @@ use egui::{Context, Pos2, Rect, pos2, vec2}; use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup}; use crate::docking::{ - Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Permissions, Profile, Zone, + Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Profile, Zone, dock_model::{Place, node_at}, }; @@ -29,7 +29,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( hide: HidePanels<'_, P>, content: &dyn DockContent

, ) -> (Cow<'a, Layout

>, FrameState

) { - let protected = |p: &P| content.permissions(p) == Permissions::PROTECTED; + let protected = |p: &P| content.permissions(p).is_protected(); let hidden = |p: &P| match hide { HidePanels::None => false, HidePanels::All => !protected(p), diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 869c8d0c..3d579f67 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -231,7 +231,7 @@ impl Renderer<'_, P> { /// A lone protected or bare panel draws no tab strip and no drag bar. pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

) -> bool { match panels { - [panel] => content.permissions(panel) == Permissions::PROTECTED || content.bare(panel), + [panel] => content.permissions(panel).is_protected() || content.bare(panel), _ => false, } } From 37374d06c0c072348b7d6c63dabdb86458c6be0c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:08:51 +0000 Subject: [PATCH 065/160] docking: a dock wider than its area shrinks its columns to fit A layout saved on a larger screen drew its outer columns 0 wide with no hit rect and resized them to width 0, which the model rejects. Columns shrink in proportion down to their minimum width; resizing one fixes the others at their drawn widths so the divider follows the pointer. --- crates/ui/src/docking/dock_tests.rs | 35 ++++++++++++++++ crates/ui/src/docking/render/dock.rs | 62 ++++++++++++++++++++++++---- crates/ui/src/docking/render/mod.rs | 2 + 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 8f950481..17c673d5 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -504,3 +504,38 @@ fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel( assert_eq!(out.actions, vec![]); assert_eq!(d.layout, layout); } + +#[test] +fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() { + let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0); + assert!((widths.iter().sum::() - 800.0).abs() < 0.01 && widths[2] == 48.0, "{widths:?}"); + assert_eq!(render::fit_widths(&[(600.0, Some(300.0)), (600.0, Some(300.0))], 400.0), vec![300.0, 300.0]); + + // Three 600-wide columns saved on a larger screen, in an 800-wide window. + let mut layout = docked(Side::Start, &[&[&[1]], &[&[2]], &[&[3]]]); + if let Some(Node::Split { first, .. }) = &mut layout.root + && let Node::Dock { columns, .. } = &mut **first + { + for column in columns { + column.width = 600.0; + } + } + let mut d = testing::DockDriver::new(layout, TestContent::default()); + for panel in [1, 2, 3] { + let divider = d.hit(&Hit::Divider(Divider::Column(panel))).unwrap(); + assert!(d.hit(&Hit::Tab(panel)).is_some_and(|tab| tab.width() > 0.0), "{panel}"); + assert!(divider.right() < 800.0, "{panel}: {divider:?}"); + } + let divider = d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center(); + d.press(divider); + d.move_to(divider + vec2(-8.0, 0.0)); + let out = d.move_to(divider + vec2(-30.0, 0.0)); + assert!(out.error.is_none() && !out.actions.is_empty(), "{out:?}"); + for action in &out.actions { + assert!(matches!(action, Action::ResizeColumn { width, .. } if *width > 0.0), "{action:?}"); + } + d.release(divider + vec2(-30.0, 0.0)); + let total: f32 = (0..3).map(|i| column(&d.layout, i).width).sum(); + assert!(total < 800.0, "{total}"); + assert!((d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center().x - (divider.x - 30.0)).abs() < 1.0); +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 83696661..140cc3d7 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -90,6 +90,32 @@ pub(in crate::docking) fn group_heights(groups: &[(bool, Option, f32)], str heights } +/// Column widths that fit `room`. Each entry is (width, floor); a column with a floor shrinks +/// in proportion, never below its floor, and one without keeps its width. A dock saved on a +/// larger screen still fits, as far as the floors allow. +pub(in crate::docking) fn fit_widths(columns: &[(f32, Option)], room: f32) -> Vec { + let mut widths: Vec = columns.iter().map(|(w, _)| *w).collect(); + for _ in 0..columns.len() { + let total: f32 = widths.iter().sum(); + if total <= room { + break; + } + let flex: f32 = widths.iter().zip(columns).filter(|(w, (_, floor))| floor.is_some_and(|f| **w > f)).map(|(w, _)| *w).sum(); + if flex <= 0.0 { + break; + } + let scale = ((room - (total - flex)) / flex).max(0.0); + for (width, (_, floor)) in widths.iter_mut().zip(columns) { + if let Some(floor) = *floor + && *width > floor + { + *width = (*width * scale).max(floor); + } + } + } + widths +} + impl Renderer<'_, P> { /// A horizontal split with a dock child: the dock takes its columns' width on its physical /// edge, the sibling the rest. False when neither child is a dock. @@ -138,12 +164,21 @@ impl Renderer<'_, P> { if usable(rect) { ui.painter().rect_filled(rect, 0.0, self.style.background); } - let total: f32 = columns.iter().map(|c| column_width(c, self.style)).sum(); + let bounds: Vec<(f32, f32)> = columns + .iter() + .map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }) + .collect(); + let natural: Vec<(f32, Option)> = + columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect(); + let widths = fit_widths(&natural, rect.width()); + let scaled = widths.iter().zip(&natural).any(|(w, (n, _))| w != n); + let total: f32 = widths.iter().sum(); let mut x = outer; let mut drawn = Vec::new(); for (index, column) in columns.iter().enumerate() { let Some(first) = column.groups.first().and_then(|g| g.panels.first()) else { continue }; - let width = column_width(column, self.style).min((rect.width() - (x - outer).abs()).max(0.0)); + let fitted = widths.get(index).copied().unwrap_or_default(); + let width = fitted.min((rect.width() - (x - outer).abs()).max(0.0)); let inner = x + inward * width; let column_rect = Rect::from_x_y_ranges(x.min(inner)..=x.max(inner), rect.y_range()); self.column(ui, column, first, column_rect, edge); @@ -153,13 +188,16 @@ impl Renderer<'_, P> { } let (band, line) = column_edge(inner, -inward, rect.y_range()); self.dock_targets.push(DockDrop { target: DockTarget::ColumnEdge { anchor: first.clone(), after: true }, band, line }); - drawn.push((column, first, width, inner)); + drawn.push((index, column, first, width, inner)); x = inner; } // Registered after the columns, so the narrow hit areas win at the edges. - for (column, first, width, inner) in drawn { - let others = total - column_width(column, self.style); - let (lo, hi) = if column.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(column) }; + for (index, column, first, width, inner) in drawn { + if width < 1.0 { + continue; + } + let others = total - widths.get(index).copied().unwrap_or_default(); + let (lo, hi) = bounds.get(index).copied().unwrap_or_default(); let hi = hi.min(room - others).max(lo.min(room - others).max(0.0)); let lo = lo.min(hi); let divider = Rect::from_x_y_ranges((inner - 1.0)..=(inner + 1.0), rect.y_range()); @@ -174,12 +212,22 @@ impl Renderer<'_, P> { .show(ui); self.register_interaction(ui.ctx(), response.response.id); let next = (width + inward * response.delta).clamp(lo, hi); - if response.delta != 0.0 && next != width { + if response.delta != 0.0 && next != width && next > 0.0 { self.output.actions.push(if column.iconic { Action::ResizeIconic { panel: first.clone(), width: Some(next) } } else { Action::ResizeColumn { panel: first.clone(), width: next } }); + // A dock shrunk to fit keeps the widths it was drawn at, so the resized column + // follows the pointer instead of being scaled again. + if scaled { + for (other, (column, width)) in columns.iter().zip(&widths).enumerate() { + let first = column.groups.first().and_then(|g| g.panels.first()); + if let Some(first) = first.filter(|_| other != index && !column.iconic && *width > 0.0) { + self.output.actions.push(Action::ResizeColumn { panel: first.clone(), width: *width }); + } + } + } } } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 3d579f67..1f052029 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -21,6 +21,8 @@ pub(in crate::docking) use iconic::Iconic; pub use iconic::{close_flyout, flyout, open_flyout}; pub(in crate::docking) use floating::RESIZE_HANDLES; +#[cfg(test)] +pub(in crate::docking) use dock::fit_widths; mod group; mod preview; From 4310fc72a141333d09194a71551346ae4fa7e092 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:09:34 +0000 Subject: [PATCH 066/160] docking: window.panel.dock to a side never anchors on what moves On the first panel of the last column, the command picked that panel as its own anchor and failed with InvalidPlacement. The anchor is a panel of the last column outside the moving unit; when the unit already is that column, the command does nothing. --- crates/ui/src/docking/command.rs | 12 ++++++++---- crates/ui/src/docking/command_tests.rs | 10 ++++++++++ docs/ui-docking.md | 3 ++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index e377f256..93151e6e 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -399,7 +399,11 @@ impl DockCommand

{ } if let Some(side) = side { let to = match last_column(layout, *side) { - Some(anchor) => Target::At { anchor, placement: Placement::Column { after: true } }, + Some(column) => match column.into_iter().find(|p| !members(layout, panel, unit).contains(p)) { + Some(anchor) => Target::At { anchor, placement: Placement::Column { after: true } }, + // What moves already is that dock's last column. + None => return Ok(Vec::new()), + }, None => Target::NewDock { side: *side }, }; let unit = match (unit, &to) { @@ -548,12 +552,12 @@ impl

DockCommand

{ } } -/// The first panel of the last column of the dock on `side`. -fn last_column(layout: &Layout

, side: Side) -> Option

{ +/// The panels of the last column of the dock on `side`, or `None` without such a dock. +fn last_column(layout: &Layout

, side: Side) -> Option> { let path = layout.dock_path(side)?; let node = layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path))?; match node { - super::Node::Dock { columns, .. } => columns.last()?.groups.first()?.panels.first().cloned(), + super::Node::Dock { columns, .. } => Some(columns.last()?.groups.iter().flat_map(|g| g.panels.iter().cloned()).collect()), _ => None, } } diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 1f071169..9397e62a 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -409,3 +409,13 @@ fn dock_with_a_side_zone_and_no_anchor_makes_an_outer_edge_frame() { ); rejected(json!({"command": "window.panel.dock", "panel": "color", "zone": "left", "before": null}), DockError::InvalidPlacement); } + +#[test] +fn dock_to_the_side_a_panel_already_leads_the_last_column_of() { + // Layers leads the dock's only column: it moves out to a new column after it. + let (workspace, _) = applied(json!({"command": "window.panel.dock", "panel": "layers", "side": "Start"})); + assert!(matches!(workspace.layout.place(&s("layers")), Some(dock_model::Place::Dock { column: 1, .. }))); + assert!(matches!(workspace.layout.place(&s("channels")), Some(dock_model::Place::Dock { column: 0, .. }))); + // The whole column already is the last one: nothing to do. + assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers", "column": true, "side": "Start"})), vec![]); +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 76423b13..912a37cc 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -231,7 +231,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan `Placement::for_zone`, so beside a docked panel they make a new column or group. `apply` and `to_actions` take the layout's `right_to_left` for that. - `group` moves the panel's whole group; `column` moves its dock column, or its whole window when - it floats. `side` is `Start` or `End`. + it floats. `side` is `Start` or `End`: what moves becomes a new last column of that dock, or + a new dock; when it already is that dock's last column, the command does nothing. - `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. - `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see From 41a14168ea3d611a30e098e12f581b3b710f60a3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:12:06 +0000 Subject: [PATCH 067/160] docking: reconcile and restore keep a protected canvas shown Restoring beside a sibling subtree needed the exact same set of panels, so a migrated layout without the canvas put the canvas in hidden. Restore splits the anchor alone when the subtree changed. reconcile takes the panels' permissions and never leaves a registered panel that cannot close in hidden: it goes back to its recorded or default place, else to the outer edge of the docked area. --- crates/ui/src/docking/model.rs | 33 +++++++++++++--- crates/ui/src/docking/model_tests.rs | 58 +++++++++++++++++++++++++++- crates/ui/src/docking/workspace.rs | 16 +++++++- docs/ui-docking.md | 2 +- 4 files changed, 100 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index e79bf096..e3a09040 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -675,6 +675,7 @@ impl Layout

{ if !proposed.contains(anchor) { return Err(DockError::MissingTarget); } + let mut around = false; if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = location.placement && !location.sibling.is_empty() { @@ -685,14 +686,13 @@ impl Layout

{ if members.len() != location.sibling.len() || !members.contains(anchor) { return Err(DockError::InvalidPlacement); } - let inserted = proposed + around = proposed .root .as_mut() .is_some_and(|root| restore_around_subtree(root, &panel, &members, zone, location.size.unwrap_or(SplitSize::Ratio(0.5)))); - if !inserted { - return Err(DockError::MissingTarget); - } - } else { + } + // A subtree that changed since (a migrated or reconciled layout) splits the anchor. + if !around { proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; if let Some(size) = location.size && let Some(root) = proposed.root.as_mut() @@ -719,6 +719,29 @@ impl Layout

{ Ok(()) } + /// Open an absent panel as a frame at the outer edge of the docked area, taking most of it: + /// the last resort for a panel that must stay shown, such as a protected canvas. + pub(super) fn open_at_edge(&mut self, panel: P) -> Result<(), DockError> { + let mut proposed = self.clone(); + if self.root.is_none() { + proposed.root = Some(Node::Tabs { panels: vec![panel], active: 0 }); + } else { + let anchor = self + .panels() + .into_iter() + .find(|p| matches!(self.place(p), Some(Place::Dock { .. } | Place::Node(_)))) + .cloned() + .ok_or(DockError::MissingTarget)?; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor, placement: Placement::Edge(Zone::Right) })?; + if let Some(root) = proposed.root.as_mut() { + restore_parent_size(root, &panel, SplitSize::Ratio(0.25)); + } + } + proposed.validate()?; + *self = proposed; + Ok(()) + } + /// [`Self::apply_with_permissions`] with default permissions. pub fn apply(&mut self, action: Action

) -> Result, DockError> { self.apply_with_permissions(action, |_| Permissions::default()) diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 5a6843eb..74eab84c 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -329,7 +329,7 @@ fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() { let stray = workspace.layout.location(&99).unwrap(); workspace.hidden.push((98, stray)); let registered = |p: &u32| *p < 90; - workspace.reconcile(registered, &default); + workspace.reconcile(registered, |_| Permissions::default(), &default); workspace.validate().unwrap(); // Unregistered panels wait in `hidden` and come back once registered. assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [98, 99]); @@ -341,7 +341,7 @@ fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() { let mut broken = default.clone(); broken.layout.floating[0].rect[2] = -1.0; - broken.reconcile(registered, &default); + broken.reconcile(registered, |_| Permissions::default(), &default); assert_eq!(broken.layout, default.layout); } @@ -469,6 +469,60 @@ mod serde_tests { } } } + + #[test] + fn every_app_fixture_reconciles_with_its_canvas_shown() { + let s = |p: &str| p.to_owned(); + let permissions = |p: &String| if p == "canvas" { Permissions::PROTECTED } else { Permissions::default() }; + // The canvas between a start dock and the properties, which no fixture matches exactly. + let beside = super::Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![s("layers"), s("pages")], 0)], 240.0)] }), + second: Box::new(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.75), + first: Box::new(Node::Tabs { panels: vec![s("canvas")], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![s("properties"), s("stroke")], active: 0 }), + }), + }), + floating: Vec::new(), + }; + // The canvas with no anchor to go back beside. + let alone = super::Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() }; + let fixtures = [ + ("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/docking"), + ("designcraft", include_str!("../../tests/fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"), + ("vectorcraft", include_str!("../../tests/fixtures/v1/vectorcraft.json"), "/docking"), + ("lightcraft", include_str!("../../tests/fixtures/v1/lightcraft.json"), "/docking/layout"), + ("pdfcraft", include_str!("../../tests/fixtures/v1/pdfcraft.json"), "/docking/layout"), + ("cadcraft", include_str!("../../tests/fixtures/v1/cadcraft.json"), "/workspace/docking/layout"), + ("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/docking/layout"), + ("soundcraft", include_str!("../../tests/fixtures/v1/soundcraft.json"), "/mix_docking/layout"), + ("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/docking"), + ("photocraft", include_str!("../../tests/fixtures/v1/photocraft.json"), "/panel_layout/docking"), + ]; + for (app, text, pointer) in fixtures { + let file: Value = serde_json::from_str(text).unwrap(); + let mut saved: super::Layout = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap(); + let own = saved.contains(&s("canvas")).then(|| saved.clone()); + saved.take(&s("canvas")); + for default in own.iter().chain([&beside, &alone]) { + let mut workspace = super::Workspace::new(saved.clone()); + // A file written while the canvas could still be lost. + if let Ok(location) = default.location(&s("canvas")) { + workspace.hidden.push((s("canvas"), location)); + } + for mut workspace in [super::Workspace::new(saved.clone()), workspace] { + workspace.reconcile(|_| true, permissions, &super::Workspace::new(default.clone())); + workspace.validate().unwrap_or_else(|e| panic!("{app}{pointer}: {e}")); + assert!(workspace.layout.contains(&s("canvas")), "{app}{pointer}: {:?}", workspace.layout); + assert!(workspace.hidden.iter().all(|(p, _)| p != "canvas"), "{app}{pointer}"); + } + } + } + } } #[test] diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 06cc91bc..92eb4c06 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -157,8 +157,11 @@ impl Workspace

{ /// numbered panels) move to `hidden` with their location, so they come back once registered; /// the saved arrangement drops them. Registered panels in `default` that are neither shown /// nor hidden go to their default place, or to `hidden` when that place cannot be rebuilt. + /// A registered panel that `permissions` says cannot close, such as a protected canvas, is + /// always shown: at its recorded or default place, else at the outer edge of the docked + /// area. Pass the panels' own permissions, not [`Permissions::locked`] ones. /// `hidden` keeps at most [`MAX_PANELS`] entries, dropping the oldest unregistered ones first. - pub fn reconcile(&mut self, registered: impl Fn(&P) -> bool, default: &Workspace

) { + pub fn reconcile(&mut self, registered: impl Fn(&P) -> bool, permissions: impl Fn(&P) -> Permissions, default: &Workspace

) { if self.validate().is_err() { *self = Workspace { locked: self.locked, ..default.clone() }; } @@ -201,6 +204,17 @@ impl Workspace

{ self.hidden.push((panel.clone(), location.clone())); } } + let stuck: Vec

= self.hidden.iter().map(|(p, _)| p).filter(|p| registered(p) && !permissions(p).closable).cloned().collect(); + for panel in stuck { + let mut layout = self.layout.clone(); + let shown = self.restore_into(&mut layout, &panel).is_ok() + || default.layout.location(&panel).is_ok_and(|location| layout.restore(panel.clone(), &location).is_ok()) + || layout.open_at_edge(panel.clone()).is_ok(); + if shown { + self.layout = layout; + self.hidden.retain(|(p, _)| p != &panel); + } + } let mut excess = self.hidden.len().saturating_sub(MAX_PANELS); self.hidden.retain(|(p, _)| { let drop = excess > 0 && !registered(p); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 912a37cc..d2b1cad5 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -89,7 +89,7 @@ an optional saved arrangement for Reset, and `locked`. |---|---| | `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused. | | `set_visible(panel, visible, fallback)` | Hide a panel, or show it at its recorded place, else beside `fallback`. | -| `reconcile(registered, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. | +| `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | | `validate` | Check a loaded workspace before use. | From c084db9c78ca5ab6f0bb4adb28001769484b4cd5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:21:46 +0000 Subject: [PATCH 068/160] docking: a pointer drag on a divider or resize grip leaves no focus behind Keeping focus on the handle after a pointer drag broke app shortcuts that defer to a focused widget (egui_wants_keyboard_input), and arrow keys moved both the canvas selection and the divider. A handle focused by the pointer gives the focus up on release; one reached with Tab or F6 keeps it, and arrow keys resize it. --- crates/ui/src/docking/chrome.rs | 5 ++--- crates/ui/src/docking/tests.rs | 2 +- crates/ui/src/layout/mod.rs | 2 +- crates/ui/src/layout/resize.rs | 29 ++++++++++++++--------------- crates/ui/src/layout/tests.rs | 33 +++++++++++++++++++++++---------- docs/ui-docking.md | 5 +++-- 6 files changed, 44 insertions(+), 32 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index a29f5afc..39f44aef 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -197,8 +197,8 @@ impl<'a> FloatingFrame<'a> { let response = ui.interact(handle, self.id.with(name), Sense::drag()).on_hover_cursor(cursor); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label)); let anchor_id = response.id.with("pointer-anchor"); + crate::layout::drag_focus(ui, &response); if response.drag_started() { - response.request_focus(); if let Some(origin) = ui.input(|i| i.pointer.press_origin()) && let Some(anchor) = AnchoredResize::new(rect, origin, direction) { @@ -206,7 +206,6 @@ impl<'a> FloatingFrame<'a> { started = Some(response.id); } } - let quiet = crate::layout::pointer_focus(ui, &response); let mut delta = Vec2::ZERO; if response.has_focus() { ui.memory_mut(|memory| { @@ -246,7 +245,7 @@ impl<'a> FloatingFrame<'a> { if canceled || !ui.input(|input| input.pointer.primary_down()) { ui.ctx().data_mut(|data| data.remove::(anchor_id)); } - if name == "resize" && (response.hovered() || response.dragged() || (response.has_focus() && !quiet)) { + if name == "resize" && (response.hovered() || response.dragged() || response.has_focus()) { ui.painter().line_segment([handle.left_bottom(), handle.right_top()], Stroke::new(2.0, style.accent)); } responses.push(response); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 2a111b82..1f266440 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1478,7 +1478,7 @@ fn pointer_floating_resize_stops_at_edges_and_waits_for_cursor_to_return() { step(&ctx, &mut layout, vec![Event::PointerMoved(start)]); assert_eq!(layout.floating[0].rect, original, "{name}: returning to the original handle restores its geometry"); step(&ctx, &mut layout, vec![pointer_button(start, false)]); - assert!(ctx.memory(|memory| memory.has_focus(id)), "{name}: the grip keeps keyboard focus after the drag"); + assert!(ctx.memory(|memory| memory.focused()).is_none(), "{name}: the grip gives up focus after a pointer drag"); } } diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs index 92cff198..a6ffed84 100644 --- a/crates/ui/src/layout/mod.rs +++ b/crates/ui/src/layout/mod.rs @@ -9,7 +9,7 @@ mod resize; mod toolbar; pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect}; -pub(crate) use resize::pointer_focus; +pub(crate) use resize::drag_focus; pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse}; pub use toolbar::{ToolbarFit, fit_toolbar}; diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index 422c6546..54005881 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -25,7 +25,7 @@ pub struct ResizeResponse { /// /// This widget never stores or changes a pane size. IDs and labels are separate, so translated /// labels do not change identity. Pointer drags retain egui's immediate `Sense::drag` behavior -/// and leave keyboard focus on the handle, highlighted once a key is pressed. +/// and hold focus only for the gesture (see [`drag_focus`]). /// Only arrows along the split axis are consumed, while this handle has focus. Tab remains /// available for normal focus traversal. pub struct ResizeHandle<'a> { @@ -83,12 +83,8 @@ impl<'a> ResizeHandle<'a> { let mut response = ui.interact(if valid { hit } else { inert }, self.id, if valid { Sense::drag() } else { Sense::hover() }); let enabled = valid && response.enabled(); let mut delta = 0.0; - let mut quiet = false; if enabled { - if response.drag_started() { - response.request_focus(); - } - quiet = pointer_focus(ui, &response); + drag_focus(ui, &response); if response.dragged() { let drag = response.drag_delta(); delta = match self.axis { @@ -179,7 +175,7 @@ impl<'a> ResizeHandle<'a> { if enabled { let fill = if response.dragged() { Some(self.style.dragged) - } else if response.has_focus() && !quiet { + } else if response.has_focus() { Some(self.style.focused) } else if response.hovered() { Some(self.style.hovered) @@ -200,15 +196,18 @@ impl<'a> ResizeHandle<'a> { } } -/// A pointer drag focuses its handle, and the handle keeps focus after the release so arrow -/// keys resize it right away. That focus draws no highlight until a key is pressed. Call after -/// the handle requests focus; true while its focus came from the pointer. -pub(crate) fn pointer_focus(ui: &Ui, response: &Response) -> bool { +/// A pointer drag focuses its handle for the gesture only and gives the focus up on release, +/// so the app's shortcuts and its canvas arrow keys work again (they defer to a focused +/// widget). A handle focused from the keyboard (Tab, F6) before the drag keeps its focus. +pub(crate) fn drag_focus(ui: &Ui, response: &Response) { let key = response.id.with("pointer-focus"); if response.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(key, true)); - } else if !response.has_focus() || ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) { - ui.ctx().data_mut(|d| d.remove::(key)); + if !response.has_focus() { + ui.ctx().data_mut(|d| d.insert_temp(key, ())); + } + response.request_focus(); + } + if response.drag_stopped() && ui.ctx().data_mut(|d| d.remove_temp::<()>(key)).is_some() { + response.surrender_focus(); } - ui.ctx().data(|d| d.get_temp::(key)).unwrap_or(false) } diff --git a/crates/ui/src/layout/tests.rs b/crates/ui/src/layout/tests.rs index 72544c50..0a14f1e2 100644 --- a/crates/ui/src/layout/tests.rs +++ b/crates/ui/src/layout/tests.rs @@ -171,19 +171,32 @@ fn resize_handle_preserves_pointer_drag_lifecycle_and_reports_per_frame_delta() let (response, _) = h.frame(vec![pointer(center + vec2(16.0, 0.0), false)]); assert!(response.response.drag_stopped()); assert_eq!(response.delta, 0.0); - assert!(response.response.has_focus(), "a pointer drag leaves keyboard focus on the handle"); - let (_, output) = h.frame(vec![Event::PointerMoved(pos2(300.0, 180.0))]); - let handle = h.rect; - let highlighted = |output: &FullOutput| { - output.shapes.iter().any(|shape| matches!(&shape.shape, egui::epaint::Shape::Rect(rect) if rect.rect == handle && rect.fill == Color32::BLUE)) - }; - assert!(!highlighted(&output), "focus from the pointer draws no highlight"); - let (response, output) = h.frame(vec![key(Key::ArrowRight)]); - assert_eq!(response.delta, 8.0, "arrow keys resize right after the drag"); - assert!(highlighted(&output)); + assert!(!response.response.has_focus()); + let (response, _) = h.frame(vec![key(Key::ArrowRight)]); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "after a pointer drag no widget has focus, so app shortcuts work"); + assert_eq!(response.delta, 0.0, "arrow keys are left to the app"); assert_eq!(h.value, 100.0, "the component never owns pane state"); } +#[test] +fn tab_focused_handle_keeps_focus_through_a_pointer_drag_and_arrow_resizing() { + let mut h = HandleTest::new(); + let center = h.rect.center(); + h.settle(); + h.frame(vec![key(Key::Tab)]); + let (response, _) = h.frame(vec![]); + assert!(response.response.has_focus(), "Tab reaches the handle"); + h.frame(vec![Event::PointerMoved(center), pointer(center, true)]); + h.frame(vec![Event::PointerMoved(center + vec2(12.0, 0.0))]); + h.frame(vec![pointer(center + vec2(12.0, 0.0), false)]); + let (response, _) = h.frame(vec![key(Key::ArrowRight)]); + assert!(response.response.has_focus()); + assert_eq!(response.delta, 8.0); + let (response, _) = h.frame(vec![key(Key::ArrowLeft)]); + assert!(response.response.has_focus()); + assert_eq!(response.delta, -8.0); +} + #[test] fn focused_handle_resizes_with_axis_arrows_and_draws_visible_focus() { for (axis, forward, back) in [(SplitAxis::Horizontal, Key::ArrowRight, Key::ArrowLeft), (SplitAxis::Vertical, Key::ArrowDown, Key::ArrowUp)] { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index d2b1cad5..2763ab59 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -139,8 +139,9 @@ by the app's own Close items and commands. `DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome (any control a `Hit` names) rather than on a panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a workspace reset hides, closes or moves its panel, and then gives it up. A divider or a floating -window's resize grip keeps keyboard focus after a pointer drag, so arrow keys resize it right -away; its focus highlight shows once a key is pressed. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. +window's resize grip holds focus only while the pointer drags it, so the app's shortcuts and +canvas arrow keys work right after; one reached with Tab or F6 keeps its focus, and arrow keys +resize it. Hovering focuses nothing. `begin_drag` hands a drag that started in the app's own chrome to the area. `docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview docking and `craft_ui::panes` both draw) for apps that draw their own windows. From 0781c48870bc6777c37dfa6fb5e0cc55f12bb9bf Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:21:46 +0000 Subject: [PATCH 069/160] docking: screen-reader resize values follow the column width on either edge On a dock at the right edge the column handle passed accessibility values through the physical axis, so SetValue 300 gave width 180 and Increment shrank the column. ResizeHandle::reversed applies accessibility actions in value space; the pointer and arrow keys stay physical. --- crates/ui/src/docking/dock_tests.rs | 24 ++++++++++++++++++++++++ crates/ui/src/docking/render/dock.rs | 1 + crates/ui/src/layout/resize.rs | 20 ++++++++++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 17c673d5..0e3ea1f0 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -539,3 +539,27 @@ fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() { assert!(total < 800.0, "{total}"); assert!((d.hit(&Hit::Divider(Divider::Column(1))).unwrap().center().x - (divider.x - 30.0)).abs() < 1.0); } + +#[test] +fn screen_reader_values_follow_the_column_width_on_either_edge() { + use egui::accesskit::{Action as A, ActionData, ActionRequest, TreeId}; + for (side, right_to_left) in [(Side::End, false), (Side::Start, true), (Side::Start, false)] { + let mut d = testing::DockDriver::new(docked(side, &[&[&[2]]]), TestContent::default()).with_style(move |s| s.right_to_left = right_to_left); + d.ctx.enable_accesskit(); + d.settle(); + let id = DockArea::::hit_id(d.id, &Hit::Divider(Divider::Column(2))); + let request = |action, data| Event::AccessKitActionRequest(ActionRequest { action, target_tree: TreeId::ROOT, target_node: id.accesskit_id(), data }); + let case = format!("{side:?}, right to left {right_to_left}"); + let out = d.frame(vec![request(A::SetValue, Some(ActionData::NumericValue(300.0)))]); + assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 300.0 }], "{case}"); + let out = d.frame(vec![request(A::Increment, None)]); + assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 248.0 }], "{case}"); + // Arrow keys stay physical: right moves the divider right. + d.ctx.memory_mut(|m| m.request_focus(id)); + d.frame(vec![]); + let out = d.frame(vec![key(egui::Key::ArrowRight)]); + let wider = d.hit(&Hit::Divider(Divider::Column(2))).unwrap().center().x < 400.0; + let width = if wider { 248.0 } else { 232.0 }; + assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width }], "{case}"); + } +} diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 140cc3d7..0544bc4f 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -209,6 +209,7 @@ impl Renderer<'_, P> { self.handle_style(), ) .value(width, lo..=hi) + .reversed(inward < 0.0) .show(ui); self.register_interaction(ui.ctx(), response.response.id); let next = (width + inward * response.delta).clamp(lo, hi); diff --git a/crates/ui/src/layout/resize.rs b/crates/ui/src/layout/resize.rs index 54005881..eed86e93 100644 --- a/crates/ui/src/layout/resize.rs +++ b/crates/ui/src/layout/resize.rs @@ -37,11 +37,12 @@ pub struct ResizeHandle<'a> { hit_margin: f32, step: f32, value: Option<(f32, RangeInclusive)>, + reversed: bool, } impl<'a> ResizeHandle<'a> { pub fn new(id: Id, axis: SplitAxis, rect: Rect, label: &'a str, style: ResizeHandleStyle) -> Self { - Self { id, axis, rect, label, style, hit_margin: 3.0, step: 8.0, value: None } + Self { id, axis, rect, label, style, hit_margin: 3.0, step: 8.0, value: None, reversed: false } } /// Expand the hit rectangle along the movement axis, in logical points. Default: 3. @@ -63,6 +64,14 @@ impl<'a> ResizeHandle<'a> { self } + /// The value grows against the axis, as for a column docked on the right edge that widens + /// as its divider moves left. Accessibility actions then work on the value; the pointer + /// and arrow keys stay physical, and `delta` stays along the axis. + pub fn reversed(mut self, reversed: bool) -> Self { + self.reversed = reversed; + self + } + pub fn show(self, ui: &mut Ui) -> ResizeResponse { let hit = self.rect.expand2(match self.axis { SplitAxis::Horizontal => Vec2::new(self.hit_margin, 0.0), @@ -116,27 +125,30 @@ impl<'a> ResizeHandle<'a> { } }); } + let mut change = 0.0; ui.input_mut(|input| { input.consume_accesskit_action_requests(response.id, |request| match request.action { accesskit::Action::Increment => { - delta += self.step; + change += self.step; true } accesskit::Action::Decrement => { - delta -= self.step; + change -= self.step; true } accesskit::Action::SetValue => { if let (Some((value, range)), Some(accesskit::ActionData::NumericValue(new_value))) = (&self.value, &request.data) && new_value.is_finite() { - delta = (new_value.clamp(f64::from(*range.start()), f64::from(*range.end())) - f64::from(*value)) as f32; + change = (new_value.clamp(f64::from(*range.start()), f64::from(*range.end())) - f64::from(*value)) as f32; + delta = 0.0; } true } _ => false, }); }); + delta += if self.reversed { -change } else { change }; } if !delta.is_finite() { delta = 0.0; From b19b2638a133ac593058da16ba4d1bdbbde37ed7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:21:51 +0000 Subject: [PATCH 070/160] docking: validate rejects empty frames and accordions No action leaves a Tabs or Stack node without panels, and no app fixture has one; a file that does is invalid, as an empty group is. --- crates/ui/src/docking/model.rs | 9 ++++++++- crates/ui/src/docking/tests.rs | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index e3a09040..e1fc5541 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -417,7 +417,8 @@ fn valid_active(len: usize, active: usize) -> bool { } impl Layout

{ - /// Check depth, allocation bounds, geometry, selection and globally unique panel ownership. + /// Check depth, allocation bounds, geometry, selection, globally unique panel ownership and + /// that no frame, group, column or window is empty. /// Traversal is iterative, including for an invalid, excessively deep supplied tree. pub fn validate(&self) -> Result<(), DockError> { if self.floating.len() > MAX_PANELS { @@ -468,9 +469,15 @@ impl Layout

{ } } Node::Tabs { panels, active } => { + if panels.is_empty() { + return Err(DockError::InvalidGeometry); + } check_tabs(panels, *active, &mut seen)?; } Node::Stack { entries } => { + if entries.is_empty() { + return Err(DockError::InvalidGeometry); + } if entries.len() > MAX_PANELS { return Err(DockError::LimitExceeded); } diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 1f266440..77d197e7 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -225,6 +225,12 @@ fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometr assert_eq!(deep.apply(Action::Activate { panel: 0 }), Err(DockError::LimitExceeded)); let oversized = Layout { root: Some(Node::Tabs { panels: (0..=MAX_PANELS as u32).collect(), active: 0 }), floating: Vec::new() }; assert_eq!(oversized.validate(), Err(DockError::LimitExceeded)); + for empty in [tabs(&[], 0), Node::Stack { entries: Vec::new() }] { + let root = Layout { root: Some(empty.clone()), floating: Vec::new() }; + assert_eq!(root.validate(), Err(DockError::InvalidGeometry)); + let beside = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1], 0)), second: Box::new(empty) }; + assert_eq!(Layout { root: Some(beside), floating: Vec::new() }.validate(), Err(DockError::InvalidGeometry)); + } } fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { From bc4addb8d49fbe626c1c1d46d17355a405b6bb4c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:21:51 +0000 Subject: [PATCH 071/160] docking: opening works when locked, and hiding respects closable OpenAt checked the opened panel's movable, so a locked workspace refused OpenAt while Open worked. It checks movable only for a panel already shown. Workspace::set_visible takes the app's permissions, so hiding cannot close a protected canvas. --- crates/ui/src/docking/command.rs | 4 ++-- crates/ui/src/docking/model.rs | 4 +++- crates/ui/src/docking/model_tests.rs | 26 ++++++++++++++++++++------ crates/ui/src/docking/tests.rs | 2 +- crates/ui/src/docking/workspace.rs | 7 ++++--- docs/ui-docking.md | 2 +- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 93151e6e..2dd7f67a 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -517,12 +517,12 @@ impl DockCommand

{ }; if let Some(panel) = show.filter(|p| !workspace.layout.contains(p)) { if self.edge().is_none() { - workspace.set_visible(panel.clone(), true, None)?; + workspace.set_visible(panel.clone(), true, None, &permissions)?; return Ok(Vec::new()); } // Shown, then moved to the edge, all or nothing. let mut shown = workspace.clone(); - shown.set_visible(panel.clone(), true, None)?; + shown.set_visible(panel.clone(), true, None, &permissions)?; let closed = shown.apply_all(self.to_actions(&shown, right_to_left, space)?, permissions)?; *workspace = shown; return Ok(closed); diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index e1fc5541..641e6373 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -762,7 +762,9 @@ impl Layout

{ self.validate()?; match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { - if !permissions(panel).movable { + // Opening an absent panel moves nothing, so it works in a locked workspace too. + let opens = matches!(action, Action::OpenAt { .. }) && !self.contains(panel); + if !opens && !permissions(panel).movable { return Err(DockError::NotAllowed); } if !placement_allowed(placement, permissions(anchor)) { diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 74eab84c..6e70d5ae 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -170,8 +170,8 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { assert_eq!(workspace.apply(Action::CloseGroup { panel: 3 }, allow).unwrap(), [2, 3]); assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [2, 3]); assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![4]]); - workspace.set_visible(2, true, None).unwrap(); - workspace.set_visible(3, true, None).unwrap(); + workspace.set_visible(2, true, None, |_| Permissions::default()).unwrap(); + workspace.set_visible(3, true, None, |_| Permissions::default()).unwrap(); assert_eq!(panels_of(&dock(&workspace.layout, Side::End)[0].groups), [vec![2, 3], vec![4]]); assert_eq!(workspace.layout.closed_by(&Action::CloseWindow { panel: 7 }).unwrap(), [6, 7, 8]); @@ -179,7 +179,7 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { assert!(workspace.layout.floating.is_empty()); assert_eq!(workspace.hidden.len(), 3); for panel in [6, 7, 8] { - workspace.set_visible(panel, true, None).unwrap(); + workspace.set_visible(panel, true, None, |_| Permissions::default()).unwrap(); } assert_eq!(workspace.layout.floating.len(), 1); assert_eq!(panels_of(&workspace.layout.floating[0].groups), [vec![6, 7], vec![8]]); @@ -187,7 +187,7 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { workspace.apply(Action::CloseOthers { panel: 12 }, allow).unwrap(); assert_eq!(group_state(&workspace.layout, 12).0, [12]); - workspace.set_visible(11, true, None).unwrap(); + workspace.set_visible(11, true, None, |_| Permissions::default()).unwrap(); assert_eq!(group_state(&workspace.layout, 12).0, [11, 12]); assert!(workspace.hidden.is_empty()); assert_eq!(sorted(&workspace.layout), ALL); @@ -299,7 +299,7 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi assert_eq!(workspace.apply(action.clone(), allow), Err(DockError::NotAllowed), "{action:?}"); assert_eq!(workspace, before); } - assert_eq!(workspace.set_visible(4, false, None), Err(DockError::NotAllowed)); + assert_eq!(workspace.set_visible(4, false, None, |_| Permissions::default()), Err(DockError::NotAllowed)); let allowed = [ Action::Activate { panel: 2 }, Action::Open { panel: 12, anchor: Some(11) }, @@ -317,6 +317,20 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi assert_eq!(workspace.layout.panel_state(&5), PanelState::Open); assert_eq!(workspace.layout.panel_state(&2), PanelState::Front); assert_eq!(workspace.layout.panel_state(&3), PanelState::Open); + // Opening at a chosen place works too; moving a shown panel there does not. + workspace.apply(Action::OpenAt { panel: 13, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow).unwrap(); + assert!(workspace.layout.contains(&13)); + assert_eq!(workspace.apply(Action::OpenAt { panel: 2, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow), Err(DockError::NotAllowed)); +} + +#[test] +fn hiding_a_panel_that_may_not_close_is_refused() { + let mut workspace = Workspace::new(docked()); + let canvas = |p: &u32| if *p == 1 { Permissions { accepts_splits: true, ..Permissions::PROTECTED } } else { Permissions::default() }; + assert_eq!(workspace.set_visible(1, false, None, canvas), Err(DockError::NotAllowed)); + assert!(workspace.layout.contains(&1) && workspace.hidden.is_empty()); + workspace.set_visible(2, false, None, canvas).unwrap(); + assert!(!workspace.layout.contains(&2)); } #[test] @@ -334,7 +348,7 @@ fn reconcile_drops_unknown_panels_and_places_missing_registered_ones() { // Unregistered panels wait in `hidden` and come back once registered. assert_eq!(workspace.hidden.iter().map(|(p, _)| *p).collect::>(), [98, 99]); assert_eq!(sorted(&workspace.layout), ALL); - workspace.set_visible(99, true, None).unwrap(); + workspace.set_visible(99, true, None, |_| Permissions::default()).unwrap(); assert_eq!(group_state(&workspace.layout, 2).0, [2, 3, 99]); assert_eq!(dock(&workspace.layout, Side::End).len(), 2); assert_eq!(group_state(&workspace.layout, 11).0, [11, 12]); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 77d197e7..7ed5d392 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -314,7 +314,7 @@ fn long_action_sequence_never_loses_or_duplicates_a_panel() { { let fallback = workspace.layout.panels().first().map(|p| **p); let before = workspace.clone(); - if workspace.set_visible(hidden, true, fallback).is_err() { + if workspace.set_visible(hidden, true, fallback, |_| Permissions::default()).is_err() { assert_eq!(workspace, before); } } diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 92eb4c06..da414e78 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -114,14 +114,15 @@ impl Workspace

{ /// Show or hide one panel. Showing restores its recorded location, following the chain of /// hidden anchors when its own anchor is hidden too; otherwise it opens beside `fallback`. - /// A shown panel becomes the active tab of its group. - pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

) -> Result<(), DockError> { + /// A shown panel becomes the active tab of its group. Hiding follows `permissions` and + /// [`Self::locked`], as [`Self::apply`] does, so a protected canvas stays shown. + pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { self.validate()?; if !visible { if !self.layout.contains(&panel) { return Ok(()); } - return self.apply(Action::Close { panel }, |_| Permissions::default()).map(drop); + return self.apply(Action::Close { panel }, permissions).map(drop); } if self.layout.contains(&panel) { return self.apply(Action::Activate { panel }, |_| Permissions::default()).map(drop); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 2763ab59..40c8b329 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -88,7 +88,7 @@ an optional saved arrangement for Reset, and `locked`. | Method | Does | |---|---| | `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused. | -| `set_visible(panel, visible, fallback)` | Hide a panel, or show it at its recorded place, else beside `fallback`. | +| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else beside `fallback`. | | `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | | `validate` | Check a loaded workspace before use. | From 0fe98b3be9b249f3464e0e0f64b43c16ef37d057 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:26:57 +0000 Subject: [PATCH 072/160] docking: Target, Side, ResizeEdge, Profile and DockStyle are non_exhaustive New variants and style fields can then be added without breaking apps. DockStyle gains from_visuals and Default for building a style without a Ui. --- crates/ui/src/docking/dock_model.rs | 2 ++ crates/ui/src/docking/hits.rs | 1 + crates/ui/src/docking/surface.rs | 15 ++++++++++++++- docs/ui-docking.md | 2 ++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index ea88d380..99eeec7f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -19,6 +19,7 @@ const DEFAULT_FLOAT: [f32; 4] = [48.0, 48.0, 260.0, 320.0]; /// layout); the renderer maps it to a physical edge from the layout direction. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Side { Start, End, @@ -74,6 +75,7 @@ impl

Column

{ /// Where a group, column or floating window goes. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum Target

{ /// Relative to the panel `anchor`, which must not be part of what moves. At { anchor: P, placement: Placement

}, diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index e3869fe4..96e0521d 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -43,6 +43,7 @@ pub enum Hit

{ /// The edge or corner a floating window's resize handle moves. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] pub enum ResizeEdge { Left, Right, diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c8fdd84b..c8d7dc86 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -9,6 +9,7 @@ pub(super) const TAB_PADDING: f32 = 24.0; /// Application-supplied chrome. Body renderers retain their own UI style and contents. #[derive(Clone, Debug)] +#[non_exhaustive] pub struct DockStyle { pub tab_height: f32, pub gap: f32, @@ -105,15 +106,27 @@ pub struct DockStyle { /// Which arrangement a [`DockStyle`] renders and labels. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] pub enum Profile { #[default] Dock, Frame, } +impl Default for DockStyle { + /// [`Self::from_visuals`] with egui's default visuals. + fn default() -> Self { + Self::from_visuals(&egui::Visuals::default()) + } +} + impl DockStyle { + /// Colors from the `ui`'s visuals; adjust fields afterwards. pub fn from_ui(ui: &Ui) -> Self { - let visuals = ui.visuals(); + Self::from_visuals(ui.visuals()) + } + + pub fn from_visuals(visuals: &egui::Visuals) -> Self { Self { tab_height: 26.0, gap: 4.0, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 40c8b329..b2226fa4 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -10,6 +10,8 @@ Office apps that draw their own document with a few task panes beside it use ## Profiles `DockStyle::profile` picks one of two arrangements. Both use the same model and actions. +An app builds its style with `DockStyle::from_ui`, `from_visuals` or `default` and then sets +fields; new fields can be added, so the struct has no literal form outside the crate. | Profile | Arrangement | Apps | |---|---|---| From 5f48f1e1ebf66426ad891a7f5c4b1bb35c8e990d Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:32:26 +0000 Subject: [PATCH 073/160] docking: per-frame lookups scale with the panel count The renderer looked up each panel's place with a tree walk, found recorded content heights by linear search and named every possible control of every panel to record hit rects. Places come from one traversal per frame, content heights through an index, and hit rects are named only when hit_rects asks. --- crates/ui/src/docking/dock_model.rs | 53 ++++++++++++++++++++++++- crates/ui/src/docking/hits.rs | 17 +++++--- crates/ui/src/docking/render/dock.rs | 10 ++--- crates/ui/src/docking/render/frame.rs | 8 +--- crates/ui/src/docking/render/group.rs | 2 +- crates/ui/src/docking/render/mod.rs | 22 ++++++++-- crates/ui/src/docking/render/preview.rs | 12 +++--- crates/ui/src/docking/surface.rs | 44 ++++++++++---------- 8 files changed, 115 insertions(+), 53 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 99eeec7f..435fd2dc 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -4,7 +4,7 @@ //! A group, column or floating window is named by a panel it contains. Edits run on a proposed //! copy of the layout, so a helper that fails part-way leaves nothing behind. -use std::hash::Hash; +use std::{collections::HashMap, hash::Hash}; use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_height, valid_rect, valid_width}; use crate::layout::{SplitAxis, SplitSize}; @@ -253,6 +253,57 @@ impl Layout

{ None } + /// [`Self::place`] of every panel, from one traversal: the renderer looks up many panels a + /// frame. + pub(super) fn places(&self) -> HashMap { + let mut places = HashMap::new(); + for (w, window) in self.floating.iter().enumerate() { + for (g, group) in window.groups.iter().enumerate() { + for panel in &group.panels { + places.entry(panel.clone()).or_insert(Place::Floating { window: w, group: g }); + } + } + } + let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + break; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Tabs { panels, .. } => { + for panel in panels { + places.entry(panel.clone()).or_insert_with(|| Place::Node(path.clone())); + } + } + Node::Stack { entries } => { + for entry in entries { + places.entry(entry.panel.clone()).or_insert_with(|| Place::Node(path.clone())); + } + } + Node::Dock { columns, .. } => { + for (c, column) in columns.iter().enumerate() { + for (g, group) in column.groups.iter().enumerate() { + for panel in &group.panels { + places.entry(panel.clone()).or_insert_with(|| Place::Dock { path: path.clone(), column: c, group: g }); + } + } + } + } + } + } + places + } + pub(super) fn dock_path(&self, side: Side) -> Option> { let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); let mut visited = 0usize; diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 96e0521d..0c5faf70 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -79,7 +79,9 @@ impl DockArea<'_, P> { /// return these from a `ui.docking` control method beside the layout and `focused`; tests /// drag from their centers. pub fn hit_rects(ctx: &Context, id: Id) -> Vec<(Hit

, Rect)> { - ctx.data(|d| d.get_temp::, Rect)>>(key(id))).unwrap_or_default() + let Some((layout, rects)) = ctx.data(|d| d.get_temp::>(key(id))) else { return Vec::new() }; + let mut names = named(id, &layout); + rects.into_iter().filter_map(|(id, rect)| Some((names.remove(&id)?, rect))).collect() } /// The egui widget id of the control `hit` names in the area `id`, for tests and @@ -181,10 +183,13 @@ pub(super) fn named(area: names } -/// Name the controls the renderer registered this frame and store their rects. +/// The layout of the last frame and the rects of the controls it registered, by widget id. +type Recorded

= (Layout

, Vec<(Id, Rect)>); + +/// Store the rects of the controls the renderer registered this frame. They are named only +/// when [`DockArea::hit_rects`] asks, which tests and automation do, not every frame. pub(super) fn record(ctx: &Context, area: Id, layout: &Layout

, owned: &[Id]) { - let mut names = named(area, layout); - let hits: Vec<(Hit

, Rect)> = - owned.iter().filter_map(|id| Some((names.remove(id)?, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect(); - ctx.data_mut(|d| d.insert_temp(key(area), hits)); + let rects: Vec<(Id, Rect)> = + owned.iter().filter_map(|id| Some((*id, ctx.read_response(*id)?.rect))).filter(|(_, rect)| rect.is_positive()).collect(); + ctx.data_mut(|d| d.insert_temp::>(key(area), (layout.clone(), rects))); } diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 0544bc4f..feb9b68b 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -164,10 +164,8 @@ impl Renderer<'_, P> { if usable(rect) { ui.painter().rect_filled(rect, 0.0, self.style.background); } - let bounds: Vec<(f32, f32)> = columns - .iter() - .map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }) - .collect(); + let bounds: Vec<(f32, f32)> = + columns.iter().map(|c| if c.iconic { (self.style.iconic_width, self.style.iconic_max_width) } else { self.column_bounds(c) }).collect(); let natural: Vec<(f32, Option)> = columns.iter().zip(&bounds).map(|(c, (lo, _))| (column_width(c, self.style), (!c.iconic).then_some(lo.max(1.0)))).collect(); let widths = fit_widths(&natural, rect.width()); @@ -531,9 +529,7 @@ impl Renderer<'_, P> { } } Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to @ Target::NewDock { .. } - if self.layout.place(panel).and_then(|place| self.layout.group(&place)).is_some_and(|g| g.panels.len() == 1) => - { + to @ Target::NewDock { .. } if self.place(panel).and_then(|place| self.layout.group(place)).is_some_and(|g| g.panels.len() == 1) => { Action::MoveGroup { panel: panel.clone(), to } } to @ Target::NewDock { .. } => Action::MovePanel { panel: panel.clone(), to }, diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 8b90fe99..2fc3156b 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -168,12 +168,8 @@ impl Renderer<'_, P> { if distance > band { return None; } - let anchor = self - .layout - .panels() - .into_iter() - .find(|p| !members.contains(p) && !matches!(self.layout.place(p), Some(Place::Floating { .. }) | None))? - .clone(); + let anchor = + self.layout.panels().into_iter().find(|p| !members.contains(p) && !matches!(self.place(p), Some(Place::Floating { .. }) | None))?.clone(); let size = area.size() * 0.25; let preview = match zone { Zone::Left => Rect::from_min_size(area.min, vec2(size.x, area.height())), diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 83c24ea8..25d87325 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -332,7 +332,7 @@ impl Renderer<'_, P> { if members.contains(anchor) { continue; } - let Some(place) = self.layout.place(anchor) else { continue }; + let Some(place) = self.place(anchor) else { continue }; if !places.contains(&place) { places.push(place); anchors.push(anchor.clone()); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 1f052029..b1d24a77 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -1,10 +1,10 @@ -use std::{fmt::Debug, hash::Hash}; +use std::{collections::HashMap, fmt::Debug, hash::Hash}; use egui::{Context, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}; -use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions}; +use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; mod dock; mod floating; @@ -20,9 +20,9 @@ pub(in crate::docking) use group::GroupKind; pub(in crate::docking) use iconic::Iconic; pub use iconic::{close_flyout, flyout, open_flyout}; -pub(in crate::docking) use floating::RESIZE_HANDLES; #[cfg(test)] pub(in crate::docking) use dock::fit_widths; +pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; @@ -45,9 +45,21 @@ pub(super) struct Renderer<'a, P> { pub(super) iconic: Iconic

, /// What a drag would float from each drawn panel, group and column, for commands. pub(super) float_space: crate::docking::FloatSpace

, + /// Each panel's place in `layout`, and its entry in `float_space.contents`. + pub(super) places: HashMap, + pub(super) contents: HashMap, } impl Renderer<'_, P> { + pub(super) fn place(&self, panel: &P) -> Option<&Place> { + self.places.get(panel) + } + + /// The height `panel`'s content last used: `Some(None)` when it filled its body. + pub(super) fn content_height(&self, panel: &P) -> Option> { + self.contents.get(panel).and_then(|i| self.float_space.contents.get(*i)).map(|(_, height)| *height) + } + pub(super) fn label_of(&self, panel: &P) -> String { self.content.logical_label(panel) } @@ -222,7 +234,9 @@ impl Renderer<'_, P> { ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min))); child.set_clip_rect(rect.intersect(ui.clip_rect())); self.content.body(&mut child, panel); - self.float_space.record_content(panel.clone(), child.min_rect().height(), rect.height()); + let at = self.contents.get(panel).copied(); + let at = self.float_space.record_content(at, panel.clone(), child.min_rect().height(), rect.height()); + self.contents.insert(panel.clone(), at); // Any button: a canvas pans with the middle button and opens menus with the secondary. if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 4feed8b9..72c50019 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -117,7 +117,7 @@ impl Renderer<'_, P> { self.output.actions.extend(action); return; } - let floating = matches!(self.layout.place(&drag.panel), Some(Place::Floating { .. })); + let floating = matches!(self.place(&drag.panel), Some(Place::Floating { .. })); let proposal = if float_only { None } else { self.group_proposal(drag, pointer) }; if let Some(proposal) = proposal.as_ref().filter(|p| !p.noop) { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; @@ -288,10 +288,10 @@ impl Renderer<'_, P> { return vec2(size.x, self.fitted_height(panel, chrome, size.y).unwrap_or(size.y)); } let mut size = self.iconic.float_width(panel).map_or(source, |width| vec2(width, DOCKED_FLOAT_HEIGHT)); - if matches!(self.layout.place(panel), Some(Place::Dock { .. } | Place::Node(_))) { + if matches!(self.place(panel), Some(Place::Dock { .. } | Place::Node(_))) { size.y = match self.fitted_height(panel, chrome, DOCKED_FLOAT_HEIGHT) { Some(height) => height, - None if self.float_space.filled(panel) => DOCKED_FLOAT_HEIGHT, + None if self.content_height(panel) == Some(None) => DOCKED_FLOAT_HEIGHT, None => size.y.min(DOCKED_FLOAT_HEIGHT), }; } @@ -304,7 +304,7 @@ impl Renderer<'_, P> { /// content height the panel last drew, at least [`MIN_FITTED_HEIGHT`] and at most `max`. /// `None` for a panel the area has not drawn. pub(super) fn fitted_height(&self, panel: &P, chrome: f32, max: f32) -> Option { - let content = self.float_space.content(panel)?; + let content = self.content_height(panel).flatten()?; Some((chrome + content).max(MIN_FITTED_HEIGHT).min(max)) } @@ -330,7 +330,7 @@ impl Renderer<'_, P> { /// dock column. A group fits that height to its active panel's content. A group that already /// floats keeps its size. pub(super) fn group_float_size(&self, members: &[P], source: Vec2) -> Vec2 { - let Some(place) = members.first().and_then(|panel| self.layout.place(panel)) else { return source }; + let Some(place) = members.first().and_then(|panel| self.place(panel)) else { return source }; if matches!(place, Place::Floating { .. }) { return source; } @@ -339,7 +339,7 @@ impl Renderer<'_, P> { let docked = matches!(place, Place::Dock { .. }); let chrome = self.style.title_height + self.style.tab_height; let max = host.or(docked.then_some(DOCKED_FLOAT_HEIGHT)); - let fitted = max.and_then(|max| self.group_active(members, &place).and_then(|active| self.fitted_height(active, chrome, max))); + let fitted = max.and_then(|max| self.group_active(members, place).and_then(|active| self.fitted_height(active, chrome, max))); vec2(source.x, fitted.or(host).unwrap_or(if docked { source.y.min(DOCKED_FLOAT_HEIGHT) } else { source.y })) } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index c8d7dc86..d8812080 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -287,16 +287,23 @@ impl FloatSpace

{ impl FloatSpace

{ /// Record the height `panel`'s content used in a body `available` tall. Content that /// reaches the body's bottom filled it and has no height of its own, unless the body was - /// sized to the height recorded before (a fitted window). - pub(super) fn record_content(&mut self, panel: P, used: f32, available: f32) { + /// sized to the height recorded before (a fitted window). `at` is the panel's entry in + /// `contents` if it has one; returns where the entry is. + pub(super) fn record_content(&mut self, at: Option, panel: P, used: f32, available: f32) -> usize { const TOLERANCE: f32 = 1.0; let used = if used.is_finite() { used.max(0.0) } else { 0.0 }; - let recorded = self.contents.iter_mut().find(|(p, _)| *p == panel); + let recorded = at.and_then(|i| self.contents.get_mut(i)).filter(|(p, _)| *p == panel); let fitted = recorded.as_ref().and_then(|(_, height)| *height).is_some_and(|height| height >= available - TOLERANCE); let height = (used < available - TOLERANCE || fitted).then_some(used); - match recorded { - Some((_, recorded)) => *recorded = height, - None => self.contents.push((panel, height)), + match (recorded, at) { + (Some((_, recorded)), Some(at)) => { + *recorded = height; + at + } + _ => { + self.contents.push((panel, height)); + self.contents.len() - 1 + } } } } @@ -316,15 +323,6 @@ impl FloatSpace

{ Self::find(&self.columns, panel) } - /// Whether `panel`'s content filled its body when it was last drawn. - pub(super) fn filled(&self, panel: &P) -> bool { - self.contents.iter().any(|(p, height)| p == panel && height.is_none()) - } - - pub(super) fn content(&self, panel: &P) -> Option { - self.contents.iter().find(|(p, _)| p == panel).and_then(|(_, height)| *height) - } - /// The first record wins: an iconic panel is recorded at its icon before its flyout. fn find(sizes: &[(P, Vec2)], panel: &P) -> Option { sizes.iter().find(|(p, _)| p == panel).map(|(_, size)| *size) @@ -789,6 +787,12 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { } release_moved_tab_focus(ui.ctx(), self.id, layout); let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); + let places = layout.places(); + // A panel keeps the content height it last drew while hidden or minimized. + let contents: Vec<(P, Option)> = float_space::

(ui.ctx(), self.id) + .map(|space| space.contents.into_iter().filter(|(panel, _)| places.contains_key(panel)).collect()) + .unwrap_or_default(); + let content_index = contents.iter().enumerate().map(|(i, (panel, _))| (panel.clone(), i)).collect(); let mut renderer = Renderer { id: self.id, style, @@ -803,13 +807,9 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { output: DockOutput::default(), frame, iconic: super::render::Iconic::new(self.auto_collapse), - float_space: FloatSpace { - // A panel keeps the content height it last drew while hidden or minimized. - contents: float_space::

(ui.ctx(), self.id) - .map(|space| space.contents.into_iter().filter(|(panel, _)| layout.contains(panel)).collect()) - .unwrap_or_default(), - ..FloatSpace::new(rect) - }, + float_space: FloatSpace { contents, ..FloatSpace::new(rect) }, + places, + contents: content_index, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); From 352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 15:32:26 +0000 Subject: [PATCH 074/160] docking: rustfmt and clippy --- crates/ui/src/docking/chrome.rs | 13 ++++++------- crates/ui/src/docking/dock_tests.rs | 3 ++- crates/ui/src/docking/model_tests.rs | 10 ++++++++-- crates/ui/src/docking/tests.rs | 3 ++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 39f44aef..323419b7 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -198,13 +198,12 @@ impl<'a> FloatingFrame<'a> { response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), self.resize_label)); let anchor_id = response.id.with("pointer-anchor"); crate::layout::drag_focus(ui, &response); - if response.drag_started() { - if let Some(origin) = ui.input(|i| i.pointer.press_origin()) - && let Some(anchor) = AnchoredResize::new(rect, origin, direction) - { - ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor)); - started = Some(response.id); - } + if response.drag_started() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()) + && let Some(anchor) = AnchoredResize::new(rect, origin, direction) + { + ui.ctx().data_mut(|data| data.insert_temp(anchor_id, anchor)); + started = Some(response.id); } let mut delta = Vec2::ZERO; if response.has_focus() { diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 0e3ea1f0..8ba4387b 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -548,7 +548,8 @@ fn screen_reader_values_follow_the_column_width_on_either_edge() { d.ctx.enable_accesskit(); d.settle(); let id = DockArea::::hit_id(d.id, &Hit::Divider(Divider::Column(2))); - let request = |action, data| Event::AccessKitActionRequest(ActionRequest { action, target_tree: TreeId::ROOT, target_node: id.accesskit_id(), data }); + let request = + |action, data| Event::AccessKitActionRequest(ActionRequest { action, target_tree: TreeId::ROOT, target_node: id.accesskit_id(), data }); let case = format!("{side:?}, right to left {right_to_left}"); let out = d.frame(vec![request(A::SetValue, Some(ActionData::NumericValue(300.0)))]); assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width: 300.0 }], "{case}"); diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 6e70d5ae..c7211902 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -320,7 +320,10 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi // Opening at a chosen place works too; moving a shown panel there does not. workspace.apply(Action::OpenAt { panel: 13, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow).unwrap(); assert!(workspace.layout.contains(&13)); - assert_eq!(workspace.apply(Action::OpenAt { panel: 2, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow), Err(DockError::NotAllowed)); + assert_eq!( + workspace.apply(Action::OpenAt { panel: 2, anchor: 11, placement: Placement::Split(Zone::Bottom) }, allow), + Err(DockError::NotAllowed) + ); } #[test] @@ -493,7 +496,10 @@ mod serde_tests { root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), - first: Box::new(Node::Dock { side: Side::Start, columns: vec![Column::new(vec![Group::new(vec![s("layers"), s("pages")], 0)], 240.0)] }), + first: Box::new(Node::Dock { + side: Side::Start, + columns: vec![Column::new(vec![Group::new(vec![s("layers"), s("pages")], 0)], 240.0)], + }), second: Box::new(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.75), diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 7ed5d392..fb3c847f 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -228,7 +228,8 @@ fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometr for empty in [tabs(&[], 0), Node::Stack { entries: Vec::new() }] { let root = Layout { root: Some(empty.clone()), floating: Vec::new() }; assert_eq!(root.validate(), Err(DockError::InvalidGeometry)); - let beside = Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1], 0)), second: Box::new(empty) }; + let beside = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1], 0)), second: Box::new(empty) }; assert_eq!(Layout { root: Some(beside), floating: Vec::new() }.validate(), Err(DockError::InvalidGeometry)); } } From 592cba1ca18e52819479aa0dfc95c7f1d37dd406 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:12:09 +0000 Subject: [PATCH 075/160] docking: drag areas draw no grip marks The drag strip beside the tabs, the column bar, the floating title bar and the strip above an icon group stay draggable, with the grab cursor and the hover outline, but paint no grip lines. --- crates/ui/src/docking/chrome.rs | 14 ++++---------- crates/ui/src/docking/render/dock.rs | 5 ----- crates/ui/src/docking/render/group.rs | 14 -------------- crates/ui/src/docking/render/iconic.rs | 8 ++------ crates/ui/src/docking/trial_tests.rs | 10 ++++------ docs/ui-docking.md | 2 +- 6 files changed, 11 insertions(+), 42 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 323419b7..2933ffd0 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -1,5 +1,5 @@ -//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with a -//! grip, an optional ▾ menu button and ×. [`super::DockArea`] draws its floating windows with +//! Floating window chrome: an opaque frame with anchored resize handles, and a title bar with an +//! optional title, ▾ menu button and ×. [`super::DockArea`] draws its floating windows with //! these; other hosts (task panes) use them with their own ids and actions. Nothing here changes //! a layout: the caller turns the responses into its own commands. @@ -282,7 +282,7 @@ pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> ui.painter().layout_job(job) } -/// A title bar: two grip lines (or a title), an optional ▾ menu button and an optional ×, 10 pt +/// A title bar: an optional title, an optional ▾ menu button and an optional ×, 10 pt /// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses /// clicks and drags; double-clicks are the caller's. pub struct TitleBar<'a> { @@ -309,7 +309,7 @@ impl<'a> TitleBar<'a> { Self { id, move_label, title: None, menu: None, close: None, movable: true } } - /// Text at the leading edge instead of the grip lines, cut with '…' to fit. + /// Text at the leading edge, cut with '…' to fit. pub fn title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self @@ -358,12 +358,6 @@ impl<'a> TitleBar<'a> { let galley = truncated(ui, title, (bar_rect.width() - 12.0).max(0.0), style.font.clone()); let x = if style.right_to_left { bar_rect.right() - 6.0 - galley.size().x } else { bar_rect.left() + 6.0 }; painter.galley(pos2(x, rect.center().y - galley.size().y * 0.5), galley, style.text); - } else { - let grip = Stroke::new(0.6, style.weak_text); - for k in 0..2 { - let y = rect.center().y - 1.5 + k as f32 * 3.0; - painter.line_segment([pos2(rect.center().x - 9.0, y), pos2(rect.center().x + 9.0, y)], grip); - } } if bar.has_focus() { painter.rect_stroke(bar_rect.shrink(1.0), 0.0, Stroke::new(1.0, style.accent), StrokeKind::Inside); diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index feb9b68b..73d39843 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -428,11 +428,6 @@ impl Renderer<'_, P> { if response.double_clicked() { self.output.actions.push(Action::SetIconic { panel: first.clone(), iconic: !column.iconic }); } - for k in 0..2 { - let y = grip.center().y - 1.5 + k as f32 * 3.0; - let x = grip.center().x; - ui.painter().with_clip_rect(grip).line_segment([pos2(x - 9.0, y), pos2(x + 9.0, y)], Stroke::new(1.0, self.style.inactive_text)); - } if response.has_focus() || response.hovered() { ui.painter().with_clip_rect(grip).rect_stroke( grip.shrink(1.0), diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 25d87325..28061ff5 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -179,20 +179,6 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); } let active = response.has_focus() || response.hovered(); - if movable { - // A grip mark at rest, centered in the bar's reserved width beside the header. - let grip = if self.style.right_to_left { - Rect::from_min_max(hit.min, pos2((hit.left() + self.style.drag_bar_width).min(hit.right()), hit.bottom())) - } else { - Rect::from_min_max(pos2((hit.right() - self.style.drag_bar_width).max(hit.left()), hit.top()), hit.max) - }; - let stroke = Stroke::new(1.0, if active { self.style.text } else { self.style.inactive_text }); - let half = (hit.height() * 0.2).min(4.0); - for k in -1..=1 { - let x = grip.center().x + k as f32 * 3.0; - ui.painter().with_clip_rect(hit).line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], stroke); - } - } if active { ui.painter().with_clip_rect(hit).rect_stroke( hit.shrink(1.0), diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 8949e334..12d84c76 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -15,7 +15,7 @@ use crate::docking::{ targets::{DockDrop, DockTarget, horizontal_line, usable}, }; -/// Height of the grip above each icon group. +/// Height of the drag strip above each icon group. const GRIP: f32 = 6.0; /// Room between two icon groups, with the separator line in its middle. const SEPARATOR: f32 = 7.0; @@ -185,7 +185,7 @@ impl Renderer<'_, P> { } } - /// The grip above an icon group drags the whole group (S8). + /// The strip above an icon group drags the whole group (S8). fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, width: f32) { let Some(first) = group.panels.first() else { return }; let movable = group.panels.iter().all(|p| self.permissions(p).movable); @@ -196,10 +196,6 @@ impl Renderer<'_, P> { let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); - let color = if response.hovered() || response.dragged() { self.style.text } else { self.style.inactive_text }; - let painter = ui.painter().with_clip_rect(grip.intersect(ui.clip_rect())); - painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y - 1.0, Stroke::new(1.0, color)); - painter.hline((grip.center().x - 8.0)..=(grip.center().x + 8.0), grip.center().y + 1.0, Stroke::new(1.0, color)); if movable && response.drag_started() { let origin = ui.input(|i| i.pointer.press_origin()).or_else(|| ui.ctx().pointer_interact_pos()).unwrap_or(grip.center()); let drag = GroupDrag { diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 15b4535d..5f6c17e3 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -121,17 +121,15 @@ fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellips } #[test] -fn drag_bar_shows_a_grip_at_rest_and_a_group_drag_paints_a_filled_labeled_ghost() { +fn drag_bar_draws_no_mark_at_rest_and_a_group_drag_paints_a_filled_labeled_ghost() { let mut d = Driver::new(frames()); let visuals = d.ctx.global_style().visuals.clone(); let bar = d.rect("drag-bar", 2); - let grip = flat(&d.shapes).into_iter().any(|(_, shape)| match shape { - egui::Shape::LineSegment { points, stroke } => { - bar.contains(points[0]) && bar.contains(points[1]) && stroke.color == visuals.weak_text_color() - } + let mark = flat(&d.shapes).into_iter().any(|(_, shape)| match shape { + egui::Shape::LineSegment { points, .. } => bar.contains(points[0]) && bar.contains(points[1]), _ => false, }); - assert!(grip, "no grip mark in the drag bar at rest"); + assert!(!mark, "a mark in the drag bar at rest"); d.hold(bar.center(), pos2(150.0, 300.0)); let fill = visuals.selection.stroke.color.gamma_multiply(0.15); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index b2226fa4..1686b934 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -195,7 +195,7 @@ A flyout fits its content the same way, without the title bar. `middle_click_closes_tabs` turn either on or off in any profile. - A column's »/« button collapses it to icons, one icon per panel with a separator between groups. Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the - canvas side. Drag an icon to move its panel, or the grip above an icon group to move the group. + canvas side. Drag an icon to move its panel, or the strip above an icon group to move the group. ## Commands From a0b923ac1e8d7dba473e4ac8dd9cb572d9e62633 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:40:33 +0000 Subject: [PATCH 076/160] docking: icon groups reserve no drag strip An icon group's drag band covers the separator above it and the top 4 pt of its first icon, so icons are spaced only by the separators between groups. --- crates/ui/src/docking/iconic_tests.rs | 7 ++++--- crates/ui/src/docking/render/iconic.rs | 22 +++++++++++++--------- docs/ui-docking.md | 2 +- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index d1008d3e..1d372ad5 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -76,9 +76,10 @@ fn collapsed_column_shows_one_icon_per_panel_with_group_separators() { let (one, two, three) = (hit(&d, Hit::Icon(1)), hit(&d, Hit::Icon(2)), hit(&d, Hit::Icon(3))); let (grip1, grip3) = (hit(&d, Hit::IconGrip(1)), hit(&d, Hit::IconGrip(3))); assert_eq!(one.width(), 36.0, "the column takes the style's iconic width"); - assert_eq!((grip1.bottom(), one.bottom()), (one.top(), two.top()), "a group's icons follow its grip and each other"); - assert_eq!(grip3.top() - two.bottom(), 7.0, "groups are apart by the separator"); - assert_eq!(three.top(), grip3.bottom()); + assert_eq!(one.bottom(), two.top(), "a group's icons follow each other"); + assert_eq!(three.top() - two.bottom(), 7.0, "groups are apart by the separator, with no room for a grip"); + assert_eq!((grip1.top(), grip1.bottom()), (one.top(), one.top() + 4.0), "the first group's band is its top edge"); + assert_eq!((grip3.top(), grip3.bottom()), (two.bottom(), three.top() + 4.0), "a later group's band covers the separator"); assert!(d.hit(&Hit::Tab(1)).is_none(), "an iconic column draws no tabs"); } diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 12d84c76..2abe22d7 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -15,8 +15,9 @@ use crate::docking::{ targets::{DockDrop, DockTarget, horizontal_line, usable}, }; -/// Height of the drag strip above each icon group. -const GRIP: f32 = 6.0; +/// How far an icon group's drag band reaches into its first icon. The band also covers the +/// separator room above the group; it reserves no height of its own. +const GROUP_BAND: f32 = 4.0; /// Room between two icon groups, with the separator line in its middle. const SEPARATOR: f32 = 7.0; /// Height of a flyout, or of a panel floated from an icon, when nothing else sizes it. @@ -103,7 +104,7 @@ impl Renderer<'_, P> { } fn icon_group_height(&self, group: &Group

) -> f32 { - GRIP + group.panels.len() as f32 * self.row_height() + group.panels.len() as f32 * self.row_height() } /// The body of an iconic column: icon groups top to bottom, scrolling when they do not fit. @@ -146,21 +147,23 @@ impl Renderer<'_, P> { let mut bands = Vec::new(); for (index, group) in column.groups.iter().enumerate() { let Some(first) = group.panels.first() else { continue }; + let band_top = if index > 0 { top } else { area.top() }; if index > 0 { let y = top + SEPARATOR * 0.5; ui.painter().with_clip_rect(view).hline(area.x_range().shrink(4.0), y, self.style.border); top += SEPARATOR; } let rect = Rect::from_min_size(pos2(area.left(), top), vec2(area.width(), self.icon_group_height(group))); - let grip = Rect::from_min_size(rect.min, vec2(rect.width(), GRIP)); - self.icon_grip(ui, group, grip, column.width); let showing = open.as_ref().filter(|p| group.panels.contains(p)); - let mut y = grip.bottom(); + let mut y = rect.top(); for panel in &group.panels { let cell = Rect::from_min_size(pos2(rect.left(), y), vec2(rect.width(), self.row_height())); self.icon(ui, panel, cell, labels, showing); y = cell.bottom(); } + // After the icons, so the band wins the top of the first icon. + let band = Rect::from_min_max(pos2(rect.left(), band_top), pos2(rect.right(), (rect.top() + GROUP_BAND).min(rect.bottom()))); + self.icon_grip(ui, group, band, rect.top(), column.width); // The state again: a click above may have opened or closed this group's flyout. if flyout::

(ui.ctx(), self.id).is_some_and(|p| group.panels.contains(&p)) { self.iconic.request = Some(Request { group: group.clone(), icons: rect.intersect(view), column: view, edge, width: column.width }); @@ -185,15 +188,16 @@ impl Renderer<'_, P> { } } - /// The strip above an icon group drags the whole group (S8). - fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, width: f32) { + /// The band on an icon group's top edge drags the whole group (S8). `top` is the group's + /// first icon's top, where a floated group's source starts. + fn icon_grip(&mut self, ui: &mut Ui, group: &Group

, grip: Rect, top: f32, width: f32) { let Some(first) = group.panels.first() else { return }; let movable = group.panels.iter().all(|p| self.permissions(p).movable); let response = ui.interact(grip, self.id.with(("icon-grip", Some(first))), Sense::drag()); let response = if movable { response.on_hover_cursor(egui::CursorIcon::Grab) } else { response }; self.register_interaction(ui.ctx(), response.id); let height = group.height.filter(|h| h.is_finite()).unwrap_or(FLYOUT_HEIGHT); - let source = Rect::from_min_size(grip.min, vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); + let source = Rect::from_min_size(pos2(grip.left(), top), vec2(width.max(self.style.min_pane), height.max(self.style.tab_height))); self.record_float(&group.panels, vec2(grip.width(), self.row_height()), source.size()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_group_label)); if movable && response.drag_started() { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 1686b934..e2b0607a 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -195,7 +195,7 @@ A flyout fits its content the same way, without the title bar. `middle_click_closes_tabs` turn either on or off in any profile. - A column's »/« button collapses it to icons, one icon per panel with a separator between groups. Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the - canvas side. Drag an icon to move its panel, or the strip above an icon group to move the group. + canvas side. Drag an icon to move its panel, or an icon group's top edge (the separator line above it) to move the group. ## Commands From 2fa4ad79d7dcde5e6ad671d36083dee367a1b788 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:19:25 +0000 Subject: [PATCH 077/160] docking: floating windows stay inside the app's float bounds DockArea::float_bounds sets where floating windows, flyouts and drag ghosts may go; the default is the whole viewport. An app with its own title bar under a transparent macOS title bar passes the viewport below it, so a floating window can't be dropped, dragged or restored where pressing it moves the app window. --- crates/ui/src/docking/iconic_tests.rs | 20 ++++++++++++++++++++ crates/ui/src/docking/render/floating.rs | 4 ++-- crates/ui/src/docking/render/group.rs | 4 ++-- crates/ui/src/docking/render/iconic.rs | 3 +-- crates/ui/src/docking/render/mod.rs | 2 ++ crates/ui/src/docking/render/native.rs | 2 +- crates/ui/src/docking/render/preview.rs | 22 +++++++++++----------- crates/ui/src/docking/surface.rs | 14 +++++++++++++- crates/ui/src/docking/testing.rs | 8 +++++++- docs/ui-docking.md | 1 + 10 files changed, 60 insertions(+), 20 deletions(-) diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 1d372ad5..825a2687 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -388,3 +388,23 @@ fn chrome_has_focus_covers_every_drawn_control() { assert!(DockArea::::new(d.id).chrome_has_focus(&d.ctx, &d.layout), "{hit:?}"); } } + +#[test] +fn floating_windows_stay_inside_the_float_bounds() { + // The app's title bar strip (top 30 pt) is outside the bounds: a window saved there is + // drawn below it, and a title bar drag towards it stops at its edge. Command keeps the + // drag from docking. + let mut layout = docked(&[&[1]], false); + layout.floating.push(Floating::single(vec![5], 0, [400.0, 0.0, 240.0, 200.0])); + let mut d = DockDriver::new(layout, Content); + d.float_bounds = Some(Rect::from_min_max(pos2(0.0, 30.0), pos2(800.0, 600.0))); + d.settle(); + let title = hit(&d, Hit::Title(5)); + assert!(title.top() >= 30.0, "{title:?}"); + d.modifiers = egui::Modifiers::COMMAND; + d.drag(title.center(), pos2(title.center().x, 2.0)); + d.modifiers = egui::Modifiers::NONE; + d.settle(); + let title = hit(&d, Hit::Title(5)); + assert!(title.top() >= 30.0, "{title:?}"); +} diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 25ab4101..85282ca0 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -23,7 +23,7 @@ pub(in crate::docking) struct StackHit

{ impl Renderer<'_, P> { pub(in crate::docking) fn floating(&mut self, ctx: &Context, windows: &[Floating

]) { - let screen = ctx.content_rect(); + let screen = self.float_bounds; if !crate::docking::targets::usable(screen) { return; } @@ -139,7 +139,7 @@ impl Renderer<'_, P> { && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) { // From the press origin, so fractional pointer steps do not accumulate rounding. - let moved = clamp_float(drag.source.translate(pointer - drag.origin), ui.ctx().content_rect()); + let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds); self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); } if response.bar.double_clicked() { diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 28061ff5..b84cde4a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -269,7 +269,7 @@ impl Renderer<'_, P> { } if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { let size = self.group_float_size(owner.panels, owner.rect.size()); - let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::MoveGroup { panel: panel.clone(), to: Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }, @@ -410,7 +410,7 @@ impl Renderer<'_, P> { response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); if response.clicked() { let size = self.float_size(panel, group.size()); - let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), ui.ctx().content_rect()); + let rect = clamp_float(Rect::from_min_size(group.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }); ui.close(); } diff --git a/crates/ui/src/docking/render/iconic.rs b/crates/ui/src/docking/render/iconic.rs index 2abe22d7..2bb3e235 100644 --- a/crates/ui/src/docking/render/iconic.rs +++ b/crates/ui/src/docking/render/iconic.rs @@ -296,8 +296,7 @@ impl Renderer<'_, P> { close_flyout::

(ctx, self.id); return; } - let screen = ctx.content_rect(); - let bounds = self.workspace.intersect(screen); + let bounds = self.workspace.intersect(self.float_bounds); let width = request.width.max(self.style.min_pane).min(bounds.width()); let max = self .content diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index b1d24a77..badd4678 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -35,6 +35,8 @@ pub(super) struct Renderer<'a, P> { pub(super) layout: &'a Layout

, /// The docked area, for host drop requests. pub(super) workspace: Rect, + /// Where floating windows, flyouts and drag ghosts go: [`crate::docking::DockArea::float_bounds`]. + pub(super) float_bounds: Rect, pub(super) targets: Vec>, /// [D] Seams, group bands, column edges and new-dock bands, see [`crate::docking::targets::DockDrop`]. pub(super) dock_targets: Vec>, diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 94627bd1..349dcbf2 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -134,7 +134,7 @@ impl Renderer<'_, P> { Some(window) => window.rect, None => { let size = self.float_size(panel, owner.rect.size()); - let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), ctx.content_rect()); + let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); [rect.left(), rect.top(), rect.width(), rect.height()] } }; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 72c50019..4349e14d 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -34,7 +34,7 @@ impl Renderer<'_, P> { let (escape, focused, down, released, pointer) = ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); let pointer = pointer.filter(|p| p.is_finite()); - let ghost = pointer.map(|pointer| self.group_ghost(ctx, &drag, pointer)); + let ghost = pointer.map(|pointer| self.group_ghost(&drag, pointer)); if !escape && focused && (down || released) @@ -67,7 +67,7 @@ impl Renderer<'_, P> { let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; match drag.gesture { GroupGesture::Window => self.drop_window(&drag, pointer, float_only), - GroupGesture::Group | GroupGesture::Column => self.drop_group(ctx, &drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); @@ -109,7 +109,7 @@ impl Renderer<'_, P> { /// Release of a group drag: join or split at the target, or float at the ghost with the /// group's size. Over its own docked position nothing changes; a floating group released /// over itself moves there. - fn drop_group(&mut self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + fn drop_group(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { if pointer.distance(drag.origin) < 2.0 { return; } @@ -129,7 +129,7 @@ impl Renderer<'_, P> { } // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); - let rect = self.group_ghost(ctx, drag, pointer); + let rect = self.group_ghost(drag, pointer); let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { self.output.actions.push(action); @@ -142,7 +142,7 @@ impl Renderer<'_, P> { let ctx = ui.ctx(); let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { - let ghost = self.group_ghost(ctx, drag, pointer); + let ghost = self.group_ghost(drag, pointer); drop_preview(&painter, ghost, self.style.accent, true); let strip = Rect::from_min_size(ghost.min, vec2(ghost.width(), self.style.tab_height.min(ghost.height()))); let labels = painter.with_clip_rect(strip.shrink(2.0)); @@ -207,7 +207,7 @@ impl Renderer<'_, P> { let float_only = float_only(ctx) || drag.undock; let floatable = self.permissions(&drag.panel).floatable && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); - let floating_rect = floatable.then(|| self.tab_float_rect(ctx, &drag, pointer)); + let floating_rect = floatable.then(|| self.tab_float_rect(&drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { if released { cancel_drag::

(ctx, self.id); @@ -357,16 +357,16 @@ impl Renderer<'_, P> { /// Where a group or column drag floats with the pointer at `pointer`, keeping the grab /// offset, inside the window (Z2). - fn group_ghost(&self, ctx: &Context, drag: &GroupDrag

, pointer: Pos2) -> Rect { + fn group_ghost(&self, drag: &GroupDrag

, pointer: Pos2) -> Rect { let size = if drag.gesture == GroupGesture::Window { drag.source.size() } else { self.group_float_size(&drag.members, drag.source.size()) }; let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size); - clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) } - fn tab_float_rect(&self, ctx: &Context, drag: &Drag

, pointer: Pos2) -> Rect { + fn tab_float_rect(&self, drag: &Drag

, pointer: Pos2) -> Rect { let size = self.float_size(&drag.panel, drag.source.size()); let grab = drag.grab.min(vec2(size.x, self.style.tab_height)); - clamp_float(Rect::from_min_size(pointer - grab, size), ctx.content_rect()) + clamp_float(Rect::from_min_size(pointer - grab, size), self.float_bounds) } /// Offer the gesture to [`crate::docking::DockContent::resolve_drag`]. True when the host @@ -388,7 +388,7 @@ impl Renderer<'_, P> { whole_group, released, workspace: self.workspace, - screen: ctx.content_rect(), + screen: self.float_bounds, }; let Some(feedback) = self.content.resolve_drag(ctx, self.style, &request) else { return false }; if !released { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index d8812080..03aae03f 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -672,11 +672,12 @@ pub struct DockArea<'a, P = ()> { maximized: Option

, hide: HidePanels<'a, P>, auto_collapse: bool, + float_bounds: Option, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true } + Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true, float_bounds: None } } /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press @@ -720,6 +721,16 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). + /// An app whose own title bar sits under a transparent OS title bar (macOS full-size + /// content) passes the viewport below that strip: a window drawn there would take presses + /// that the OS or the app turns into moving the whole app window. Windows outside the + /// bounds, such as ones restored from a saved layout, are drawn moved inside. + pub fn float_bounds(mut self, bounds: Rect) -> Self { + self.float_bounds = Some(bounds); + self + } + /// Host overlays that must reject docking gestures, including floating fallback. pub fn blocked_rects(mut self, rects: &[Rect]) -> Self { self.blockers = rects.to_vec(); @@ -801,6 +812,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { blockers: self.blockers, layout, workspace: rect, + float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())), targets: Vec::new(), dock_targets: Vec::new(), owned_interactions: Vec::new(), diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index e34cad2f..868a6c4c 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -42,6 +42,8 @@ pub struct DockDriver { pub maximized: Option

, /// Passed to [`DockArea::raise_panel`] on the next frame only. pub raise: Option

, + /// Passed to [`DockArea::float_bounds`]. + pub float_bounds: Option, style: Option, shapes: Vec, time: f64, @@ -63,6 +65,7 @@ impl> Do auto_collapse: true, maximized: None, raise: None, + float_bounds: None, style: None, shapes: Vec::new(), time: 0.0, @@ -97,7 +100,7 @@ impl> Do ..Default::default() }; let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); - let (style_fn, maximized, raise, auto) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse); + let (style_fn, maximized, raise, auto, bounds) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if let Some(adjust) = style_fn { @@ -108,6 +111,9 @@ impl> Do if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } + if let Some(bounds) = bounds { + dock = dock.float_bounds(bounds); + } output = dock.show_content(ui, layout, &style, content); }); }); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e2b0607a..79a0ec71 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -133,6 +133,7 @@ Builder options on `DockArea`: | `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | | `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. | | `blocked_rects(rects)` | App overlays that docking gestures must ignore. | +| `float_bounds(rect)` | Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). Pass the viewport below a title bar drawn under a transparent OS title bar, so no floating window sits where a press moves the app window. | `DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items and the floating window ×. They hide controls only: a panel that stays `closable` still closes From 40c59bb577b225664a1a13e3d13a92df6119ae81 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:40:59 +0000 Subject: [PATCH 078/160] docking: narrow frames keep usable side drop zones, and seams split A side zone is a quarter of the frame deep, at least 32 pt and at most half the frame, so a 64 pt tool strip offers its left and right edges past the outer edge band. A pointer in the gap between frames resolves to the nearest frame's edge instead of floating. --- crates/ui/src/docking/frame_tests.rs | 120 ++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/render/preview.rs | 20 +++- crates/ui/src/docking/targets.rs | 22 +++-- docs/ui-docking.md | 4 + 5 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 crates/ui/src/docking/frame_tests.rs diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs new file mode 100644 index 00000000..4d8c55f4 --- /dev/null +++ b/crates/ui/src/docking/frame_tests.rs @@ -0,0 +1,120 @@ +//! Frame profile in a Premiere-style editing layout, as FilmCraft draws it: a bottom row of +//! Project | Tools | Timeline | Audio Meters, where Tools and the meters are narrow fixed-width +//! frames without a tab strip that a grip on top moves. + +use std::collections::HashMap; + +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Id, Pos2, Rect, Sense, pos2, vec2}; + +const SOURCE: [u32; 4] = [1, 11, 12, 13]; +const PROGRAM: u32 = 2; +const PROPERTIES: [u32; 3] = [3, 31, 32]; +const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46]; +const TOOLS: u32 = 5; +const TIMELINE: u32 = 6; +const METERS: u32 = 7; + +const TOOLS_WIDTH: f32 = 64.0; +const METERS_WIDTH: f32 = 116.0; + +/// Panel bodies by their rects, with a FilmCraft-style 8 pt grip on the bare frames. +#[derive(Default)] +struct Film { + bodies: HashMap, +} + +impl DockContent for Film { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + let frame = ui.max_rect(); + self.bodies.insert(*panel, frame); + if self.bare(panel) { + let grip = Rect::from_min_size(frame.min, vec2(frame.width(), 8.0_f32.min(frame.height()))); + if ui.interact(grip, Id::new(("grip", *panel)), Sense::drag()).drag_started() { + begin_drag(ui.ctx(), Id::new("dock-driver"), *panel, frame); + } + } + ui.allocate_rect(frame, Sense::hover()); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn bare(&self, panel: &u32) -> bool { + matches!(*panel, TOOLS | METERS) + } +} + +type Driver = DockDriver; + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +fn hsplit(size: SplitSize, first: Node, second: Node) -> Node { + split(SplitAxis::Horizontal, size, first, second) +} + +/// Tools | Timeline | Audio Meters. +fn tools_row() -> Node { + hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS]))) +} + +/// FilmCraft's Editing workspace: Source | Program | Properties over Project | Tools | Timeline | Meters. +fn editing() -> Layout { + let top = hsplit(SplitSize::FixedSecond(450.0), hsplit(SplitSize::Ratio(0.5), tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES)); + let bottom = hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), tools_row()); + Layout { root: Some(split(SplitAxis::Vertical, SplitSize::Ratio(0.57), top, bottom)), floating: Vec::new() } +} + +fn driver(layout: Layout) -> Driver { + let mut d = DockDriver::new(layout, Film::default()).with_style(|style| style.profile = Profile::Frame); + d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 860.0)); + d.area = d.screen; + d.settle(); + d +} + +fn body(d: &Driver, panel: u32) -> Rect { + d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")) +} + +fn floated_project() -> Driver { + let mut layout = editing(); + layout.apply(Action::MoveGroup { panel: PROJECT[0], to: Target::Float { rect: Some([600.0, 120.0, 400.0, 300.0]) } }).unwrap(); + driver(layout) +} + +fn split_at(anchor: u32, zone: Zone) -> Target { + Target::At { anchor, placement: Placement::Split(zone) } +} + +#[test] +fn a_narrow_frame_offers_both_side_targets_and_a_seam_splits_beside_its_frames() { + let d = floated_project(); + let tools = body(&d, TOOLS); + assert_eq!((tools.left(), tools.width()), (0.0, TOOLS_WIDTH), "Tools starts the bottom row"); + // Left: past the 8 pt outer edge band. + for (at, zone) in [(pos2(20.0, tools.center().y), Zone::Left), (pos2(tools.right() - 12.0, tools.center().y), Zone::Right)] { + let mut d = floated_project(); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap(); + let out = d.drag(bar.center(), at); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: PROJECT[0], to: split_at(TOOLS, zone) }], "{zone:?}"); + } + // The gap between Tools and Timeline: a tab dropped there splits instead of floating. + let mut d = floated_project(); + let timeline = body(&d, TIMELINE); + let seam = pos2((tools.right() + timeline.left()) * 0.5, timeline.center().y); + assert!(!tools.contains(seam) && !timeline.contains(seam), "the seam is outside both frames"); + let out = d.drag(d.hit(&Hit::Tab(PROGRAM)).unwrap().center(), seam); + assert!( + matches!(&out.actions[..], [Action::Move { panel: PROGRAM, anchor: TOOLS | TIMELINE, placement: Placement::Split(Zone::Left | Zone::Right) }]), + "{:?}", + out.actions + ); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index b8462ec3..ba6651d2 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -42,6 +42,8 @@ mod dock_tests; #[cfg(test)] mod floating_tests; #[cfg(test)] +mod frame_tests; +#[cfg(test)] mod group_tests; #[cfg(test)] mod iconic_tests; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 4349e14d..7fb51f5b 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -88,6 +88,7 @@ impl Renderer<'_, P> { return valid(proposal); } let own = |t: &crate::docking::DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); + let pointer = self.seam_pointer(pointer); let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?; if own(target) { @@ -106,6 +107,22 @@ impl Renderer<'_, P> { valid(proposal) } + /// A pointer in the gap between docked frames, outside every target, moves onto the edge of + /// the nearest frame that takes splits, so a seam splits beside that frame rather than floating. + pub(super) fn seam_pointer(&self, pointer: Pos2) -> Pos2 { + let usable = |rect: Rect| crate::docking::targets::usable(rect); + if self.targets.iter().any(|t| usable(t.rect) && t.rect.contains(pointer)) { + return pointer; + } + self.targets + .iter() + .filter(|t| t.allow_splits && usable(t.rect)) + .map(|t| (t.rect.distance_to_pos(pointer), t.rect)) + .filter(|(distance, _)| *distance <= self.style.gap) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map_or(pointer, |(_, rect)| rect.clamp(pointer)) + } + /// Release of a group drag: join or split at the target, or float at the ghost with the /// group's size. Over its own docked position nothing changes; a floating group released /// over itself moves there. @@ -230,7 +247,8 @@ impl Renderer<'_, P> { let proposal = if float_only { None } else { - self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| resolve_drop(&drag.panel, pointer, &self.targets)) + self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false) + .or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets)) }; if released { cancel_drag::

(ctx, self.id); diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 3f946153..ff515ec0 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -31,8 +31,13 @@ pub(super) fn usable(rect: Rect) -> bool { rect.is_finite() && rect.width() > 0.0 && rect.height() > 0.0 && rect.width() <= 1_000_000.0 && rect.height() <= 1_000_000.0 } -/// Tab header is the center; otherwise the nearest quarter-width/height edge wins. -/// Outside, non-finite, empty and inverted rectangles have no target. +/// Least depth of a side zone, so a narrow frame such as a tool strip keeps side targets wide +/// enough to hit. +const MIN_SIDE_ZONE: f32 = 32.0; + +/// Tab header is the center; otherwise the nearest side zone wins. A side zone is a quarter of +/// the frame deep, at least 32 pt and at most half the frame, so a frame up to 64 pt wide has no +/// center. Outside, non-finite, empty and inverted rectangles have no target. pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; @@ -40,11 +45,16 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if pointer.y < rect.top() + tab_height.min(rect.height()) { return Some(Zone::Center); } - let x = (pointer.x - rect.left()) / rect.width(); - let y = (pointer.y - rect.top()) / rect.height(); - let distances = [(x, Zone::Left), (1.0 - x, Zone::Right), (y, Zone::Top), (1.0 - y, Zone::Bottom)]; + let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); + let (x, y) = (depth(rect.width()), depth(rect.height())); + let distances = [ + ((pointer.x - rect.left()) / x, Zone::Left), + ((rect.right() - pointer.x) / x, Zone::Right), + ((pointer.y - rect.top()) / y, Zone::Top), + ((rect.bottom() - pointer.y) / y, Zone::Bottom), + ]; let (distance, zone) = distances.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; - Some(if distance > 0.25 { Zone::Center } else { zone }) + Some(if distance > 1.0 { Zone::Center } else { zone }) } /// Preview of the region occupied after a split or group merge. diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 79a0ec71..e3f6255f 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -186,6 +186,10 @@ A flyout fits its content the same way, without the title bar. 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on the title bar's buttons. A resize keeps the opposite edges in place and stays within the size limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. +- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. A + side zone is a quarter of the frame deep, at least 32 pt and at most half the frame, so a narrow + tool strip keeps both side targets. A drop in the gap between two frames splits beside the + nearer one. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. - Double-click a tab or drag bar to minimize a group to its strip. From 899807ff19c9fc210a9f360b723b1411ba38960a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:44:06 +0000 Subject: [PATCH 079/160] docking: a split beside a fixed-size frame keeps that frame's size A side split replaced the anchor frame in place, so a split beside a fixed-width frame such as a tool strip took the frame's fixed slot: the two frames shared its width, and the parent's divider resized both. The frame now keeps its size: on its outer side the new split wraps the parent, on its inner side it halves the parent's other child. The drop preview shows the half the new frame takes. --- crates/ui/src/docking/dock_model.rs | 41 +++++++++++++++++---- crates/ui/src/docking/frame_tests.rs | 48 +++++++++++++++++++++++++ crates/ui/src/docking/model.rs | 21 +++++------ crates/ui/src/docking/render/mod.rs | 27 ++++++++++++++ crates/ui/src/docking/render/preview.rs | 28 +++++++++++++-- crates/ui/src/docking/surface.rs | 1 + docs/ui-docking.md | 5 +++ 7 files changed, 152 insertions(+), 19 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 435fd2dc..747f5dc3 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -207,6 +207,38 @@ pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node

, path: &[bool]) -> Op Some(node) } +/// Put `new` on the `zone` side of the frame at `path`, in a new half-size split. A frame that +/// holds the fixed size of its parent split along the same axis keeps that size: on its outer +/// side the new split wraps the parent, on its inner side it halves the parent's other child. +/// So a fixed-width tool strip neither halves nor shares its width with the new frame. +pub(super) fn split_frame

(root: &mut Node

, path: &[bool], new: Node

, zone: Zone) -> bool { + let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let before = matches!(zone, Zone::Top | Zone::Left); + let (target, new_first) = match fixed_slot(root, path, axis) { + Some((parent, anchor_first)) if before == anchor_first => (parent.to_vec(), before), + Some((parent, anchor_first)) => ([parent, &[anchor_first]].concat(), !before), + None => (path.to_vec(), before), + }; + let Some(node) = node_at_mut(root, &target) else { return false }; + let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); + let (first, second) = if new_first { (new, old) } else { (old, new) }; + *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; + true +} + +/// The parent path of the frame at `path` and whether the frame is its first child, when the +/// parent splits along `axis` and the frame holds its fixed size. +pub(super) fn fixed_slot<'a, P>(root: &Node

, path: &'a [bool], axis: SplitAxis) -> Option<(&'a [bool], bool)> { + let (second, parent) = path.split_last()?; + let Node::Split { axis: parent_axis, size, .. } = node_at(root, parent)? else { return None }; + let fixed = match size { + SplitSize::FixedFirst(_) => !second, + SplitSize::FixedSecond(_) => *second, + SplitSize::Ratio(_) => false, + }; + (*parent_axis == axis && fixed).then_some((parent, !second)) +} + /// Insert `new` at `index` and select the member at `index + selected`. fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, selected: usize) { let index = index.min(panels.len()); @@ -578,14 +610,9 @@ impl Layout

{ } Placement::Split(zone) => { let Place::Node(path) = place else { return Err(DockError::InvalidPlacement) }; - let node = self.root.as_mut().and_then(|root| node_at_mut(root, &path)).ok_or(DockError::MissingTarget)?; let (panels, active) = flatten(moving.groups); - let new = Node::Tabs { panels, active }; - let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); - let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let (first, second) = if matches!(zone, Zone::Top | Zone::Left) { (new, old) } else { (old, new) }; - *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; - Ok(()) + let root = self.root.as_mut().ok_or(DockError::MissingTarget)?; + if split_frame(root, &path, Node::Tabs { panels, active }, zone) { Ok(()) } else { Err(DockError::MissingTarget) } } Placement::Group { below, height } => { if !valid_height(height) { diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs index 4d8c55f4..6044b5c2 100644 --- a/crates/ui/src/docking/frame_tests.rs +++ b/crates/ui/src/docking/frame_tests.rs @@ -118,3 +118,51 @@ fn a_narrow_frame_offers_both_side_targets_and_a_seam_splits_beside_its_frames() out.actions ); } + + +fn bottom(layout: &Layout) -> &Node { + let Some(Node::Split { second, .. }) = &layout.root else { panic!("{:?}", layout.root) }; + second +} + +/// Drag a split divider by `dx`, hovering it first as a pointer does. +fn drag_divider(d: &mut Driver, path: &[bool], dx: f32) { + let divider = d.hit(&Hit::Divider(Divider::Split(path.to_vec()))).unwrap_or_else(|| panic!("{path:?} in {:?}", d.hits())); + d.move_to(divider.center()); + d.drag(divider.center(), divider.center() + vec2(dx, 0.0)); + d.settle(); +} + +#[test] +fn a_split_beside_a_fixed_width_frame_keeps_its_width_and_gets_its_own_divider() { + // Left of Tools: the new frame takes half the row, beside Tools at its own width. + let mut d = floated_project(); + let tools = body(&d, TOOLS); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap(); + let at = pos2(20.0, tools.center().y); + d.hold(bar.center(), at); + let row = Rect::from_x_y_ranges(0.0..=1440.0, tools.y_range()); + assert!(d.filled(Rect::from_min_max(row.min, pos2(row.center().x, row.bottom())).shrink(1.0)), "the preview shows the new frame"); + d.release(at); + assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.5), tabs(&PROJECT), tools_row())); + d.settle(); + assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH); + // Each divider resizes its own two sides; Tools keeps its width. + let project = body(&d, PROJECT[0]).width(); + drag_divider(&mut d, &[true], -200.0); + let widths = |d: &Driver| (body(d, PROJECT[0]).width().round(), body(d, TOOLS).width().round()); + assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH)); + drag_divider(&mut d, &[true, true], 30.0); + assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH + 30.0)); + + // Right of Tools: the new frame takes half of what lies right of Tools. + let mut d = driver(editing()); + let tools = body(&d, TOOLS); + let tab = d.hit(&Hit::Tab(PROGRAM)).unwrap(); + d.drag(tab.center(), pos2(tools.right() - 12.0, tools.center().y)); + let rest = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS])); + let right = hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::Ratio(0.5), tabs(&[PROGRAM]), rest)); + assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), right)); + d.settle(); + assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH); +} diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 641e6373..2fb46499 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,6 +1,6 @@ use std::{collections::HashSet, fmt, hash::Hash}; -use super::dock_model::{Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, valid_column}; +use super::dock_model::{Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, valid_column}; use crate::layout::{SplitAxis, SplitSize}; /// Limits are checked before cloning or recursively modifying an application layout. @@ -165,7 +165,9 @@ pub enum Placement

{ /// Insert before this existing destination member, or append. Also reorders a group. Tab { before: Option

}, /// Center inserts after the anchor; sides create a new half-size split. Sides apply to - /// `Tabs` and `Stack` nodes only. + /// `Tabs` and `Stack` nodes only. A frame that holds the fixed size of its parent split + /// along the same axis keeps it: on its outer side the new split wraps the parent, on its + /// inner side it halves the parent's other child. Split(Zone), /// A new group above or below the anchor's group, in a dock column or a floating stack. /// `height` is the inserted group's preferred height ([`super::Group::height`]); `None` @@ -1068,6 +1070,13 @@ impl Layout

{ let group = Group::new(vec![entry.panel], 0); return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); } + if let (Placement::Split(zone), Place::Node(path)) = (&placement, &place) + && *zone != Zone::Center + { + let root = self.root.as_mut().ok_or(DockError::MissingTarget)?; + let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; + return if split_frame(root, path, new, *zone) { Ok(()) } else { Err(DockError::MissingTarget) }; + } if let Some(root) = self.root.as_mut() && insert_node(root, &entry, anchor, &placement) { @@ -1213,14 +1222,6 @@ fn insert_node(node: &mut Node

, entry: &StackEntry

, anchor: if !here { return false; } - if let Placement::Split(zone @ (Zone::Left | Zone::Right | Zone::Top | Zone::Bottom)) = placement { - let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); - let new = Node::Tabs { panels: vec![entry.panel.clone()], active: 0 }; - let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let (first, second) = if matches!(zone, Zone::Top | Zone::Left) { (new, old) } else { (old, new) }; - *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; - return true; - } match node { Node::Tabs { panels, active } => { let index = match placement { diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index badd4678..c74a45c5 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -50,6 +50,18 @@ pub(super) struct Renderer<'a, P> { /// Each panel's place in `layout`, and its entry in `float_space.contents`. pub(super) places: HashMap, pub(super) contents: HashMap, + /// Frames drawn as the fixed-size side of their parent split, for split previews. + pub(super) fixed_slots: Vec>, +} + +/// A frame that holds the fixed size of its parent split: the parent's and the other child's +/// rects, where a side split of the frame goes (see `dock_model::split_frame`). +pub(super) struct FixedSlot

{ + pub(super) panels: Vec

, + pub(super) axis: SplitAxis, + pub(super) first: bool, + pub(super) parent: Rect, + pub(super) sibling: Rect, } impl Renderer<'_, P> { @@ -103,6 +115,21 @@ impl Renderer<'_, P> { let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); let first_size = wanted.first_size().clamp(lo, hi); let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; + let fixed = match size { + SplitSize::FixedFirst(_) => Some((first, true, split.second)), + SplitSize::FixedSecond(_) => Some((second, false, split.first)), + SplitSize::Ratio(_) => None, + }; + let panels = |node: &Node

| match node { + Node::Tabs { panels, .. } => Some(panels.clone()), + Node::Stack { entries } => Some(entries.iter().map(|e| e.panel.clone()).collect()), + Node::Split { .. } | Node::Dock { .. } => None, + }; + if let Some((node, first, sibling)) = fixed + && let Some(panels) = panels(node) + { + self.fixed_slots.push(FixedSlot { panels, axis: *axis, first, parent: rect, sibling }); + } path.push(false); self.node(ui, first, split.first, path); path.pop(); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 7fb51f5b..c1897159 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -9,7 +9,9 @@ use crate::docking::{ dock_model::{Place, node_at}, resolve_drop, surface::TAB_PADDING, + zone_rect, }; +use crate::layout::SplitAxis; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. fn float_only(ctx: &Context) -> bool { @@ -104,7 +106,29 @@ impl Renderer<'_, P> { if window && matches!(proposal.placement, Placement::Split(_)) { return None; } - valid(proposal) + valid(self.split_preview(proposal)) + } + + /// The preview of a side split beside a frame that holds its parent's fixed size: the half + /// of the parent or of the other child that the new frame takes. + fn split_preview(&self, mut proposal: DropProposal

) -> DropProposal

{ + let Placement::Split(zone) = proposal.placement else { return proposal }; + let axis = match zone { + Zone::Center => return proposal, + Zone::Left | Zone::Right => SplitAxis::Horizontal, + Zone::Top | Zone::Bottom => SplitAxis::Vertical, + }; + let Some(slot) = self.fixed_slots.iter().find(|slot| slot.axis == axis && slot.panels.contains(&proposal.anchor)) else { return proposal }; + let opposite = match zone { + Zone::Left => Zone::Right, + Zone::Right => Zone::Left, + Zone::Top => Zone::Bottom, + _ => Zone::Top, + }; + let before = matches!(zone, Zone::Left | Zone::Top); + let preview = if before == slot.first { zone_rect(slot.parent, zone) } else { zone_rect(slot.sibling, opposite) }; + proposal.preview = preview.unwrap_or(proposal.preview); + proposal } /// A pointer in the gap between docked frames, outside every target, moves onto the edge of @@ -248,7 +272,7 @@ impl Renderer<'_, P> { None } else { self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false) - .or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets)) + .or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets).map(|p| self.split_preview(p))) }; if released { cancel_drag::

(ctx, self.id); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 03aae03f..0c932815 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -822,6 +822,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { float_space: FloatSpace { contents, ..FloatSpace::new(rect) }, places, contents: content_index, + fixed_slots: Vec::new(), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e3f6255f..e3cd49e6 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -68,6 +68,11 @@ layout.apply(Action::Move { `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or center zone onto what the anchor's container takes: a split in the frame tree, a new column on that side or a new group above or below in a dock, a group above or below in a floating window. +- A side split zone puts what moves in a new half-size split of the anchor's frame. A frame + that holds the fixed size of its parent split along the same axis, such as a tool strip, keeps + that size: on its outer side the new split wraps the parent, on its inner side it halves the + parent's other child. The new split has its own divider, and the drop preview shows the half + the new frame takes. - Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move a floating window to its own OS window (`SetNative`, feature `native-windows`). From 7d786e510868ac66c6c6ccf9dbeb4dfbab317c93 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:45:20 +0000 Subject: [PATCH 080/160] docking: the Frame profile drop preview says which key floats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a drag offers a docking target, a hint under the pointer reads "Hold Ctrl to float" ("Hold ⌘ to float" on macOS), from the localizable float_hint_label (key dock.floatHint). --- crates/ui/src/docking/frame_tests.rs | 45 ++++++++++++++++++++++++- crates/ui/src/docking/labels.rs | 4 ++- crates/ui/src/docking/render/frame.rs | 18 ++++++++++ crates/ui/src/docking/render/preview.rs | 8 +++++ crates/ui/src/docking/surface.rs | 4 +++ crates/ui/src/docking/trial_tests.rs | 2 +- docs/ui-docking.md | 5 ++- 7 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs index 6044b5c2..c27ce707 100644 --- a/crates/ui/src/docking/frame_tests.rs +++ b/crates/ui/src/docking/frame_tests.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use super::testing::DockDriver; use super::*; use crate::layout::{SplitAxis, SplitSize}; -use egui::{Id, Pos2, Rect, Sense, pos2, vec2}; +use egui::{Id, Modifiers, Pos2, Rect, Sense, pos2, vec2}; const SOURCE: [u32; 4] = [1, 11, 12, 13]; const PROGRAM: u32 = 2; @@ -166,3 +166,46 @@ fn a_split_beside_a_fixed_width_frame_keeps_its_width_and_gets_its_own_divider() d.settle(); assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH); } + +fn texts(d: &Driver) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } + } + let mut out = Vec::new(); + d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out +} + +#[test] +fn ctrl_or_cmd_floats_from_a_tab_a_drag_bar_or_a_grip_and_the_preview_says_so() { + const HINT: &str = "Hold Ctrl to float"; + let floated = |actions: &[Action], panel: u32| match actions { + [Action::Float { panel: p, .. }] => *p == panel, + [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, + _ => false, + }; + let d = driver(editing()); + let over = body(&d, TIMELINE).center(); + let tab = d.hit(&Hit::Tab(PROJECT[0])).unwrap().center(); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + let grip = pos2(body(&d, TOOLS).center().x, body(&d, TOOLS).top() + 4.0); + for (from, panel) in [(tab, PROJECT[0]), (bar, PROJECT[0]), (grip, TOOLS)] { + // Without the modifier, a release over a frame docks there; the preview names the modifier. + let mut d = driver(editing()); + d.hold(from, over); + assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d)); + let out = d.release(over); + assert!(!out.actions.is_empty() && !floated(&out.actions, panel), "{from:?}: {:?}", out.actions); + // Held, it floats, and the hint is gone. + let mut d = driver(editing()); + d.modifiers = Modifiers::COMMAND; + d.hold(from, over); + assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}"); + let out = d.release(over); + assert!(floated(&out.actions, panel), "{from:?}: {:?}", out.actions); + } +} diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 4a4799c5..16934feb 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -46,6 +46,7 @@ impl DockStyle { /// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size | /// | `dock.newWindow` | `new_window_label` | Move Panel to New Window | /// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels | + /// | `dock.floatHint` | `float_hint_label` | Hold {key} to float | #[must_use] pub fn localized(mut self, translate: impl Fn(&str) -> Option) -> Self { for (key, label) in self.labels_mut() { @@ -56,7 +57,7 @@ impl DockStyle { self } - fn labels_mut(&mut self) -> [(&'static str, &mut String); 27] { + fn labels_mut(&mut self) -> [(&'static str, &mut String); 28] { [ ("dock.float", &mut self.float_label), ("dock.close", &mut self.close_label), @@ -85,6 +86,7 @@ impl DockStyle { ("dock.restoreFrame", &mut self.restore_frame_label), ("dock.newWindow", &mut self.new_window_label), ("dock.autoCollapse", &mut self.auto_collapse_label), + ("dock.floatHint", &mut self.float_hint_label), ] } } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 2fc3156b..1470b96d 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -10,6 +10,9 @@ use crate::docking::{ dock_model::{Place, node_at}, }; +/// How far below the pointer the float hint sits, clear of the cursor. +const HINT_OFFSET: f32 = 28.0; + /// What the renderer draws when it is not the layout itself, and the targets that depend on it. pub(in crate::docking) struct FrameState

{ /// View path to layout path of each split, when the view is not the layout. @@ -180,6 +183,21 @@ impl Renderer<'_, P> { Some((anchor, zone, preview)) } + /// Under the pointer while a drag offers a docking target (frame profile): which modifier + /// floats the drop instead. + pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) { + if self.style.profile != Profile::Frame || self.style.float_hint_label.is_empty() { + return; + } + let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; + let painter = self.preview_painter(ctx); + let galley = painter.layout_no_wrap(self.style.float_hint_label.replace("{key}", key), self.style.font.clone(), self.style.text); + let rect = Rect::from_center_size(pointer + vec2(0.0, HINT_OFFSET), galley.size() + vec2(12.0, 6.0)); + let rect = crate::docking::chrome::clamp(rect, self.float_bounds); + painter.rect(rect, 4.0, self.style.floating_background, egui::Stroke::new(1.0, self.style.accent), egui::StrokeKind::Inside); + painter.galley(rect.center() - galley.size() * 0.5, galley, self.style.text); + } + pub(super) fn float_label(&self) -> String { if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() } } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index c1897159..d3e043e6 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -206,6 +206,7 @@ impl Renderer<'_, P> { if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { if action.is_some() { self.paint_dock_line(ctx, line); + self.paint_float_hint(ctx, pointer); } return; } @@ -217,6 +218,9 @@ impl Renderer<'_, P> { if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } } } @@ -265,6 +269,7 @@ impl Renderer<'_, P> { } } else if action.is_some() { self.paint_dock_line(ctx, line); + self.paint_float_hint(ctx, pointer); } return; } @@ -308,6 +313,9 @@ impl Renderer<'_, P> { if let Some(marker) = proposal.insertion { painter.rect_filled(marker, 0.0, self.style.accent); } + if !proposal.noop { + self.paint_float_hint(ctx, pointer); + } } else if let Some(rect) = floating_rect { drop_preview(&painter, rect, self.style.accent, false); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 0c932815..7b60f7f8 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -90,6 +90,9 @@ pub struct DockStyle { pub restore_frame_label: String, /// Shown where native windows are available (feature `native-windows`). pub new_window_label: String, + /// Frame profile: shown under the pointer while a drag offers a docking target. `{key}` + /// stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl elsewhere. + pub float_hint_label: String, /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, /// Close Other Panels in Group) for closable panels. A host that draws its own Close items /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. @@ -180,6 +183,7 @@ impl DockStyle { maximize_label: "Maximize Frame".into(), restore_frame_label: "Restore Frame Size".into(), new_window_label: "Move Panel to New Window".into(), + float_hint_label: "Hold {key} to float".into(), close_menu_items: true, close_button: true, wheel_selects_tabs: None, diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 5f6c17e3..03830123 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -195,7 +195,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { }); full.textures_delta.clear(); let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); - assert_eq!(values.len(), 27, "{debug}"); + assert_eq!(values.len(), 28, "{debug}"); assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); let unique: std::collections::HashSet<_> = values.iter().collect(); assert_eq!(unique.len(), values.len()); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e3cd49e6..2da9736b 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -16,7 +16,7 @@ fields; new fields can be added, so the struct has no literal form outside the c | Profile | Arrangement | Apps | |---|---|---| | `Profile::Dock` (default) | Docks on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | Creative apps with a canvas and panel docks | -| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + tab drag undocks. Drops on all four outer edges split at the root. | Apps whose whole window is frames | +| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + drag undocks, and a hint under the pointer says so. Drops on all four outer edges split at the root. | Apps whose whole window is frames | Sides are logical: `Side::Start` is the left edge, or the right edge when `DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its @@ -197,6 +197,9 @@ A flyout fits its content the same way, without the title bar. nearer one. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. +- In the Frame profile, Ctrl (⌘ on macOS) held while dragging a tab, a drag bar or a grip that + calls `begin_drag` floats the drop wherever it lands. While a drag offers a docking target, a + hint under the pointer names the modifier: `float_hint_label`, where `{key}` becomes ⌘ or Ctrl. - Double-click a tab or drag bar to minimize a group to its strip. - Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused drag bar opens it too. A left click on the drag bar only focuses it. From 958c5ada58269ceb84a763021ad31524c0ee7a76 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 16:46:50 +0000 Subject: [PATCH 081/160] docking: the Frame profile's drop hint says undock, as its menus do --- crates/ui/src/docking/frame_tests.rs | 2 +- crates/ui/src/docking/labels.rs | 2 +- crates/ui/src/docking/surface.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs index c27ce707..febd3750 100644 --- a/crates/ui/src/docking/frame_tests.rs +++ b/crates/ui/src/docking/frame_tests.rs @@ -182,7 +182,7 @@ fn texts(d: &Driver) -> Vec { #[test] fn ctrl_or_cmd_floats_from_a_tab_a_drag_bar_or_a_grip_and_the_preview_says_so() { - const HINT: &str = "Hold Ctrl to float"; + const HINT: &str = "Hold Ctrl to undock"; let floated = |actions: &[Action], panel: u32| match actions { [Action::Float { panel: p, .. }] => *p == panel, [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 16934feb..76324dff 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -46,7 +46,7 @@ impl DockStyle { /// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size | /// | `dock.newWindow` | `new_window_label` | Move Panel to New Window | /// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels | - /// | `dock.floatHint` | `float_hint_label` | Hold {key} to float | + /// | `dock.floatHint` | `float_hint_label` | Hold {key} to undock | #[must_use] pub fn localized(mut self, translate: impl Fn(&str) -> Option) -> Self { for (key, label) in self.labels_mut() { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 7b60f7f8..389589dd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -183,7 +183,7 @@ impl DockStyle { maximize_label: "Maximize Frame".into(), restore_frame_label: "Restore Frame Size".into(), new_window_label: "Move Panel to New Window".into(), - float_hint_label: "Hold {key} to float".into(), + float_hint_label: "Hold {key} to undock".into(), close_menu_items: true, close_button: true, wheel_selects_tabs: None, From d30c207c8d9dae04ffa02a7bb38f268bc8e30723 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:01:31 +0000 Subject: [PATCH 082/160] docking: one model; profile differences are style data Remove `Profile`. What it switched is now a `DockStyle` setting with the dock-and-canvas behaviour as the default: `side_edge_zones`, `new_dock_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, and plain bools for `wheel_selects_tabs` and `middle_click_closes_tabs`. The undock and Close Panel Group labels go; an app sets `float_label`, `float_group_label` and `close_group_label` instead. Remove the size machinery that made frames stick: `DockContent::bare`, `FixedSlot` and the split that took a parent's or sibling's half beside a fixed-size frame, and the gap snapping to the nearest frame. A tab group takes the largest maximum of its panels, so a panel without one lifts the cap of the group it joins. A dragged divider stores a ratio whatever mode it was saved with. Sizes follow the reference rules: a divider resizes only the groups that touch it and stops at their minimums; a four-way handle where a divider ends on another moves both (`Divider::Intersection`); a split or outer-edge drop that would leave a group without `min_pane` is not offered, and a group too small to halve takes the drop as a tab. Tests: one per rule, the stuck cases from the FilmCraft map, and a property test over random drops checking minimums and divider travel. --- crates/ui/src/docking/dock_model.rs | 28 +- crates/ui/src/docking/dock_tests.rs | 2 +- crates/ui/src/docking/floating_tests.rs | 83 +++-- crates/ui/src/docking/frame_tests.rs | 211 ----------- crates/ui/src/docking/group_tests.rs | 10 +- crates/ui/src/docking/hits.rs | 9 + crates/ui/src/docking/labels.rs | 8 +- crates/ui/src/docking/mod.rs | 8 +- crates/ui/src/docking/render/dock.rs | 45 ++- crates/ui/src/docking/render/floating.rs | 8 + crates/ui/src/docking/render/frame.rs | 66 ++-- crates/ui/src/docking/render/group.rs | 10 +- crates/ui/src/docking/render/mod.rs | 122 ++++--- crates/ui/src/docking/render/preview.rs | 100 ++--- crates/ui/src/docking/render/sizes.rs | 172 +++++++++ crates/ui/src/docking/split_tests.rs | 445 +++++++++++++++++++++++ crates/ui/src/docking/surface.rs | 83 ++--- crates/ui/src/docking/tests.rs | 6 +- crates/ui/src/docking/trial_tests.rs | 2 +- docs/ui-docking.md | 67 ++-- 20 files changed, 974 insertions(+), 511 deletions(-) delete mode 100644 crates/ui/src/docking/frame_tests.rs create mode 100644 crates/ui/src/docking/render/sizes.rs create mode 100644 crates/ui/src/docking/split_tests.rs diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 747f5dc3..a42bb56f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -207,38 +207,18 @@ pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node

, path: &[bool]) -> Op Some(node) } -/// Put `new` on the `zone` side of the frame at `path`, in a new half-size split. A frame that -/// holds the fixed size of its parent split along the same axis keeps that size: on its outer -/// side the new split wraps the parent, on its inner side it halves the parent's other child. -/// So a fixed-width tool strip neither halves nor shares its width with the new frame. +/// Put `new` on the `zone` side of the frame at `path`: the frame splits into two halves and +/// every other frame keeps its size. pub(super) fn split_frame

(root: &mut Node

, path: &[bool], new: Node

, zone: Zone) -> bool { let axis = if matches!(zone, Zone::Top | Zone::Bottom) { SplitAxis::Vertical } else { SplitAxis::Horizontal }; - let before = matches!(zone, Zone::Top | Zone::Left); - let (target, new_first) = match fixed_slot(root, path, axis) { - Some((parent, anchor_first)) if before == anchor_first => (parent.to_vec(), before), - Some((parent, anchor_first)) => ([parent, &[anchor_first]].concat(), !before), - None => (path.to_vec(), before), - }; - let Some(node) = node_at_mut(root, &target) else { return false }; + let new_first = matches!(zone, Zone::Top | Zone::Left); + let Some(node) = node_at_mut(root, path) else { return false }; let old = std::mem::replace(node, Node::Tabs { panels: Vec::new(), active: 0 }); let (first, second) = if new_first { (new, old) } else { (old, new) }; *node = Node::Split { axis, size: SplitSize::Ratio(0.5), first: Box::new(first), second: Box::new(second) }; true } -/// The parent path of the frame at `path` and whether the frame is its first child, when the -/// parent splits along `axis` and the frame holds its fixed size. -pub(super) fn fixed_slot<'a, P>(root: &Node

, path: &'a [bool], axis: SplitAxis) -> Option<(&'a [bool], bool)> { - let (second, parent) = path.split_last()?; - let Node::Split { axis: parent_axis, size, .. } = node_at(root, parent)? else { return None }; - let fixed = match size { - SplitSize::FixedFirst(_) => !second, - SplitSize::FixedSecond(_) => *second, - SplitSize::Ratio(_) => false, - }; - (*parent_axis == axis && fixed).then_some((parent, !second)) -} - /// Insert `new` at `index` and select the member at `index + selected`. fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, selected: usize) { let index = index.min(panels.len()); diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 8ba4387b..87e1c4a0 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -1,4 +1,4 @@ -//! [D] Dock profile: columns, stacked groups, seams, column bars and their drops. +//! [D] Docks: columns, stacked groups, seams, column bars and their drops. use super::*; use crate::layout::{SplitAxis, SplitSize}; diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 85015888..c05271c5 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -1,4 +1,4 @@ -//! Floating windows (title bar, stacks, close), the frame profile (outer edge, Maximize, +//! Floating windows (title bar, stacks, close), the split-tree settings (outer edge, Maximize, //! Ctrl/Cmd undock, frame menu items), hidden panels, native windows and the public chrome. use super::*; @@ -80,11 +80,26 @@ enum Hide { Keep, } +/// The style of an editor whose layout is one split tree of tab groups, as FilmCraft sets it. +pub(super) fn frame_style(style: &mut DockStyle) { + style.side_edge_zones = true; + style.new_dock_zones = false; + style.close_others_item = true; + style.maximize_item = true; + style.command_press_floats = true; + style.float_hint = true; + style.wheel_selects_tabs = true; + style.middle_click_closes_tabs = true; + style.float_label = "Undock Panel".into(); + style.float_group_label = "Undock Panel Group".into(); + style.close_group_label = "Close Panel Group".into(); +} + struct Driver { ctx: Context, layout: Layout, content: Content, - profile: Profile, + split_tree: bool, maximized: Option, hide: Hide, modifiers: Modifiers, @@ -92,12 +107,12 @@ struct Driver { } impl Driver { - fn new(layout: Layout, profile: Profile) -> Self { + fn new(layout: Layout, split_tree: bool) -> Self { let mut driver = Self { ctx: Context::default(), layout, content: Content::default(), - profile, + split_tree, maximized: None, hide: Hide::None, modifiers: Modifiers::NONE, @@ -119,12 +134,14 @@ impl Driver { time: Some(self.time), ..Default::default() }; - let (layout, content, profile, maximized, hide) = (&self.layout, &mut self.content, self.profile, self.maximized, self.hide); + let (layout, content, split_tree, maximized, hide) = (&self.layout, &mut self.content, self.split_tree, self.maximized, self.hide); let keep: Vec = content.keep.clone(); let keep = move |p: &u32| keep.contains(p); let full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); - style.profile = profile; + if split_tree { + frame_style(&mut style); + } let hide = match hide { Hide::None => HidePanels::None, Hide::All => HidePanels::All, @@ -200,7 +217,7 @@ fn groups_of(window: &Floating) -> Vec> { #[test] fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_groups() { // The window follows the pointer with every group; Escape puts it back. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(5).center(); d.hold(title, title + vec2(40.0, 30.0)); assert_eq!(d.layout.floating[0].rect, [340.0, 110.0, 240.0, 300.0]); @@ -221,7 +238,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]); // Released over a docked group's strip: every panel joins it as tabs. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(5).center(); let strip = d.tab(4).unwrap().center(); let out = d.drag(title, strip); @@ -230,7 +247,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro assert_eq!(d.layout.place(&4), d.layout.place(&6)); // Released over the canvas: the window stays where it was moved and hosts get the release. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(5).center(); let out = d.drag(title, pos2(500.0, 300.0)); assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveWindow { .. })), "{:?}", out.actions); @@ -241,7 +258,7 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro #[test] fn title_bar_drag_onto_dock_targets_docks_the_window() { // Released on the seam between two docked groups: the window's group stacks between them. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(8).center(); let seam = pos2(120.0, d.tab(4).unwrap().top()); let out = d.drag(title, seam); @@ -258,7 +275,7 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() { assert_eq!(stacked, vec![vec![2, 3], vec![8], vec![4]]); // Released on the new-dock band along the right edge: the window becomes an end dock. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(5).center(); let out = d.drag(title, pos2(796.0, 300.0)); assert!(out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::NewDock { side: Side::End } }), "{:?}", out.actions); @@ -268,7 +285,7 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() { #[test] fn floating_stack_seams_and_bands_insert_groups() { // A group dragged by its bar to another window's top band stacks above that group. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center(); let out = d.drag(bar, pos2(660.0, 360.0)); assert!( @@ -281,7 +298,7 @@ fn floating_stack_seams_and_bands_insert_groups() { assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]); // A tab dropped on the seam between two stacked groups becomes a group between them. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let tab = d.tab(8).unwrap().center(); let seam = pos2(420.0, 237.0); let out = d.drag(tab, seam); @@ -293,7 +310,7 @@ fn floating_stack_seams_and_bands_insert_groups() { assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); // The divider between two open groups resizes both. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::Group(5u32)))).unwrap().center(); d.drag(divider, divider + vec2(0.0, 40.0)); let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); @@ -302,7 +319,7 @@ fn floating_stack_seams_and_bands_insert_groups() { #[test] fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() { - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(5).center(); d.click(title); d.click(title); @@ -334,7 +351,7 @@ fn closed_floating_window_reopens_each_panel_at_its_last_rect() { #[test] fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() { - let mut d = Driver::new(frames(), Profile::Frame); + let mut d = Driver::new(frames(), true); let tab = d.tab(3).unwrap().center(); let other = d.tab(1).unwrap().center(); d.modifiers = Modifiers::COMMAND; @@ -345,23 +362,23 @@ fn ctrl_drag_from_a_frame_strip_undocks_to_a_floating_window() { assert!(window_of(&d.layout, 3).is_some()); // Without the modifier the same drag joins the other frame. - let mut d = Driver::new(frames(), Profile::Frame); + let mut d = Driver::new(frames(), true); let out = d.drag(tab, other); assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 3, anchor: 1, .. }]), "{:?}", out.actions); } #[test] fn outer_edge_band_splits_at_the_root() { - // Frame profile: all four edges. - let mut d = Driver::new(frames(), Profile::Frame); + // With side edge zones: all four edges. + let mut d = Driver::new(frames(), true); let tab = d.tab(3).unwrap().center(); let out = d.drag(tab, pos2(3.0, 300.0)); assert!(out.actions.contains(&Action::Move { panel: 3, anchor: 1, placement: Placement::Edge(Zone::Left) }), "{:?}", out.actions); let Some(Node::Split { axis: SplitAxis::Horizontal, first, .. }) = &d.layout.root else { panic!("{:?}", d.layout) }; assert_eq!(**first, tabs(&[3], 0)); - // Dock profile: the bottom edge of the part between the docks, for a Timeline. - let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + // The defaults: the bottom edge of the part between the docks, for a Timeline. + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false); let tab = d.tab(2).unwrap().center(); let out = d.drag(tab, pos2(500.0, 597.0)); assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 2, placement: Placement::Edge(Zone::Bottom), .. })), "{:?}", out.actions); @@ -370,7 +387,7 @@ fn outer_edge_band_splits_at_the_root() { assert!(matches!(&**second, Node::Split { axis: SplitAxis::Vertical, second, .. } if **second == tabs(&[2], 0))); // Its left and right edges are the docks' (new columns and docks), not root splits. - let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, Profile::Dock); + let mut d = Driver::new(Layout { floating: Vec::new(), ..workspace() }, false); let tab = d.tab(4).unwrap().center(); let out = d.drag(tab, pos2(246.0, 300.0)); assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { placement: Placement::Edge(_), .. })), "{:?}", out.actions); @@ -380,7 +397,7 @@ fn outer_edge_band_splits_at_the_root() { fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() { let mut layout = frames(); layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0])); - let mut d = Driver::new(layout.clone(), Profile::Frame); + let mut d = Driver::new(layout.clone(), true); let window_title = d.title(5).center(); d.maximized = Some(2); d.step(vec![]); @@ -392,7 +409,7 @@ fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() assert!(out.actions.iter().all(|a| !matches!(a, Action::MoveFloating { .. } | Action::MoveWindow { .. })), "{:?}", out.actions); // A floating panel does not maximize. - let mut d = Driver::new(layout, Profile::Frame); + let mut d = Driver::new(layout, true); d.maximized = Some(5); d.step(vec![]); d.step(vec![]); @@ -406,7 +423,7 @@ fn close_other_panels_keeps_only_the_chosen_tab() { let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( |ui, (content, actions, events): &mut State| { let mut style = DockStyle::from_ui(ui); - style.profile = Profile::Frame; + frame_style(&mut style); let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content); actions.extend(out.actions); events.extend(out.events); @@ -444,7 +461,7 @@ fn close_other_panels_keeps_only_the_chosen_tab() { #[test] fn hide_panels_keeps_protected_panels_and_the_except_set() { - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); d.hide = Hide::All; d.step(vec![]); d.step(vec![]); @@ -469,7 +486,7 @@ fn hide_panels_keeps_protected_panels_and_the_except_set() { #[test] fn a_canvas_that_accepts_splits_is_still_protected() { - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); d.content.beside = true; d.hide = Hide::All; d.step(vec![]); @@ -484,7 +501,7 @@ fn a_canvas_that_accepts_splits_is_still_protected() { fn native_window_falls_back_in_window_when_viewports_are_embedded() { let mut layout = workspace(); layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); - let mut d = Driver::new(layout.clone(), Profile::Dock); + let mut d = Driver::new(layout.clone(), false); d.content.native = true; assert!(d.ctx.embed_viewports()); let out = d.step(vec![]); @@ -519,7 +536,7 @@ fn native_window_close_returns_the_group_to_its_in_window_rect() { }); let mut layout = workspace(); layout.floating[0].native = Some([1200.0, 900.0, 240.0, 300.0]); - let mut d = Driver::new(Layout::default(), Profile::Dock); + let mut d = Driver::new(Layout::default(), false); d.ctx.set_embed_viewports(false); d.content.native = true; d.layout = layout; @@ -601,7 +618,7 @@ fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() { }), floating: Vec::new(), }; - let mut d = Driver::new(layout, Profile::Frame); + let mut d = Driver::new(layout, true); d.hide = Hide::Keep; d.content.keep = vec![1, 2]; d.step(vec![]); @@ -622,7 +639,7 @@ fn a_hidden_floating_window_does_not_cover_dock_targets() { // Window 5 covers the column's seam at x 150..240 until it is hidden. let mut layout = workspace(); layout.floating = vec![Floating::single(vec![5], 0, [150.0, 250.0, 200.0, 150.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0])]; - let mut d = Driver::new(layout, Profile::Dock); + let mut d = Driver::new(layout, false); d.hide = Hide::Keep; d.content.keep = vec![2, 3, 4, 8]; d.step(vec![]); @@ -643,14 +660,14 @@ fn top_edges_resize_from_the_band_above_the_title_bar() { (ResizeEdge::TopLeft, vec2(-30.0, 20.0), [530.0, 320.0, 230.0, 180.0]), (ResizeEdge::TopRight, vec2(20.0, -10.0), [560.0, 290.0, 220.0, 210.0]), ] { - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let handle = d.response(DockArea::hit_id(area, &Hit::Resize(8u32, edge))).unwrap(); assert_eq!(handle.bottom(), d.title(8).top(), "{edge:?} lies above the title bar"); d.drag(handle.center(), handle.center() + by); assert_eq!(window_of(&d.layout, 8).unwrap().rect, expected, "{edge:?}"); } // The title bar still moves the window, and arrow keys move the focused top edge. - let mut d = Driver::new(workspace(), Profile::Dock); + let mut d = Driver::new(workspace(), false); let title = d.title(8); d.drag(pos2(title.center().x, title.top() + 1.0), pos2(title.center().x - 50.0, title.top() + 1.0)); assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 300.0, 200.0, 200.0]); diff --git a/crates/ui/src/docking/frame_tests.rs b/crates/ui/src/docking/frame_tests.rs deleted file mode 100644 index febd3750..00000000 --- a/crates/ui/src/docking/frame_tests.rs +++ /dev/null @@ -1,211 +0,0 @@ -//! Frame profile in a Premiere-style editing layout, as FilmCraft draws it: a bottom row of -//! Project | Tools | Timeline | Audio Meters, where Tools and the meters are narrow fixed-width -//! frames without a tab strip that a grip on top moves. - -use std::collections::HashMap; - -use super::testing::DockDriver; -use super::*; -use crate::layout::{SplitAxis, SplitSize}; -use egui::{Id, Modifiers, Pos2, Rect, Sense, pos2, vec2}; - -const SOURCE: [u32; 4] = [1, 11, 12, 13]; -const PROGRAM: u32 = 2; -const PROPERTIES: [u32; 3] = [3, 31, 32]; -const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46]; -const TOOLS: u32 = 5; -const TIMELINE: u32 = 6; -const METERS: u32 = 7; - -const TOOLS_WIDTH: f32 = 64.0; -const METERS_WIDTH: f32 = 116.0; - -/// Panel bodies by their rects, with a FilmCraft-style 8 pt grip on the bare frames. -#[derive(Default)] -struct Film { - bodies: HashMap, -} - -impl DockContent for Film { - fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { - let frame = ui.max_rect(); - self.bodies.insert(*panel, frame); - if self.bare(panel) { - let grip = Rect::from_min_size(frame.min, vec2(frame.width(), 8.0_f32.min(frame.height()))); - if ui.interact(grip, Id::new(("grip", *panel)), Sense::drag()).drag_started() { - begin_drag(ui.ctx(), Id::new("dock-driver"), *panel, frame); - } - } - ui.allocate_rect(frame, Sense::hover()); - } - fn logical_label(&self, panel: &u32) -> String { - format!("Panel {panel}") - } - fn bare(&self, panel: &u32) -> bool { - matches!(*panel, TOOLS | METERS) - } -} - -type Driver = DockDriver; - -fn tabs(panels: &[u32]) -> Node { - Node::Tabs { panels: panels.to_vec(), active: 0 } -} - -fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { - Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } -} - -fn hsplit(size: SplitSize, first: Node, second: Node) -> Node { - split(SplitAxis::Horizontal, size, first, second) -} - -/// Tools | Timeline | Audio Meters. -fn tools_row() -> Node { - hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS]))) -} - -/// FilmCraft's Editing workspace: Source | Program | Properties over Project | Tools | Timeline | Meters. -fn editing() -> Layout { - let top = hsplit(SplitSize::FixedSecond(450.0), hsplit(SplitSize::Ratio(0.5), tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES)); - let bottom = hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), tools_row()); - Layout { root: Some(split(SplitAxis::Vertical, SplitSize::Ratio(0.57), top, bottom)), floating: Vec::new() } -} - -fn driver(layout: Layout) -> Driver { - let mut d = DockDriver::new(layout, Film::default()).with_style(|style| style.profile = Profile::Frame); - d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 860.0)); - d.area = d.screen; - d.settle(); - d -} - -fn body(d: &Driver, panel: u32) -> Rect { - d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")) -} - -fn floated_project() -> Driver { - let mut layout = editing(); - layout.apply(Action::MoveGroup { panel: PROJECT[0], to: Target::Float { rect: Some([600.0, 120.0, 400.0, 300.0]) } }).unwrap(); - driver(layout) -} - -fn split_at(anchor: u32, zone: Zone) -> Target { - Target::At { anchor, placement: Placement::Split(zone) } -} - -#[test] -fn a_narrow_frame_offers_both_side_targets_and_a_seam_splits_beside_its_frames() { - let d = floated_project(); - let tools = body(&d, TOOLS); - assert_eq!((tools.left(), tools.width()), (0.0, TOOLS_WIDTH), "Tools starts the bottom row"); - // Left: past the 8 pt outer edge band. - for (at, zone) in [(pos2(20.0, tools.center().y), Zone::Left), (pos2(tools.right() - 12.0, tools.center().y), Zone::Right)] { - let mut d = floated_project(); - let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap(); - let out = d.drag(bar.center(), at); - assert_eq!(out.actions, vec![Action::MoveGroup { panel: PROJECT[0], to: split_at(TOOLS, zone) }], "{zone:?}"); - } - // The gap between Tools and Timeline: a tab dropped there splits instead of floating. - let mut d = floated_project(); - let timeline = body(&d, TIMELINE); - let seam = pos2((tools.right() + timeline.left()) * 0.5, timeline.center().y); - assert!(!tools.contains(seam) && !timeline.contains(seam), "the seam is outside both frames"); - let out = d.drag(d.hit(&Hit::Tab(PROGRAM)).unwrap().center(), seam); - assert!( - matches!(&out.actions[..], [Action::Move { panel: PROGRAM, anchor: TOOLS | TIMELINE, placement: Placement::Split(Zone::Left | Zone::Right) }]), - "{:?}", - out.actions - ); -} - - -fn bottom(layout: &Layout) -> &Node { - let Some(Node::Split { second, .. }) = &layout.root else { panic!("{:?}", layout.root) }; - second -} - -/// Drag a split divider by `dx`, hovering it first as a pointer does. -fn drag_divider(d: &mut Driver, path: &[bool], dx: f32) { - let divider = d.hit(&Hit::Divider(Divider::Split(path.to_vec()))).unwrap_or_else(|| panic!("{path:?} in {:?}", d.hits())); - d.move_to(divider.center()); - d.drag(divider.center(), divider.center() + vec2(dx, 0.0)); - d.settle(); -} - -#[test] -fn a_split_beside_a_fixed_width_frame_keeps_its_width_and_gets_its_own_divider() { - // Left of Tools: the new frame takes half the row, beside Tools at its own width. - let mut d = floated_project(); - let tools = body(&d, TOOLS); - let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap(); - let at = pos2(20.0, tools.center().y); - d.hold(bar.center(), at); - let row = Rect::from_x_y_ranges(0.0..=1440.0, tools.y_range()); - assert!(d.filled(Rect::from_min_max(row.min, pos2(row.center().x, row.bottom())).shrink(1.0)), "the preview shows the new frame"); - d.release(at); - assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.5), tabs(&PROJECT), tools_row())); - d.settle(); - assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH); - // Each divider resizes its own two sides; Tools keeps its width. - let project = body(&d, PROJECT[0]).width(); - drag_divider(&mut d, &[true], -200.0); - let widths = |d: &Driver| (body(d, PROJECT[0]).width().round(), body(d, TOOLS).width().round()); - assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH)); - drag_divider(&mut d, &[true, true], 30.0); - assert_eq!(widths(&d), ((project - 200.0).round(), TOOLS_WIDTH + 30.0)); - - // Right of Tools: the new frame takes half of what lies right of Tools. - let mut d = driver(editing()); - let tools = body(&d, TOOLS); - let tab = d.hit(&Hit::Tab(PROGRAM)).unwrap(); - d.drag(tab.center(), pos2(tools.right() - 12.0, tools.center().y)); - let rest = hsplit(SplitSize::FixedSecond(METERS_WIDTH), tabs(&[TIMELINE]), tabs(&[METERS])); - let right = hsplit(SplitSize::FixedFirst(TOOLS_WIDTH), tabs(&[TOOLS]), hsplit(SplitSize::Ratio(0.5), tabs(&[PROGRAM]), rest)); - assert_eq!(bottom(&d.layout), &hsplit(SplitSize::Ratio(0.29), tabs(&PROJECT), right)); - d.settle(); - assert_eq!(body(&d, TOOLS).width(), TOOLS_WIDTH); -} - -fn texts(d: &Driver) -> Vec { - fn walk(shape: &egui::Shape, out: &mut Vec) { - match shape { - egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), - egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), - _ => {} - } - } - let mut out = Vec::new(); - d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); - out -} - -#[test] -fn ctrl_or_cmd_floats_from_a_tab_a_drag_bar_or_a_grip_and_the_preview_says_so() { - const HINT: &str = "Hold Ctrl to undock"; - let floated = |actions: &[Action], panel: u32| match actions { - [Action::Float { panel: p, .. }] => *p == panel, - [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, - _ => false, - }; - let d = driver(editing()); - let over = body(&d, TIMELINE).center(); - let tab = d.hit(&Hit::Tab(PROJECT[0])).unwrap().center(); - let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); - let grip = pos2(body(&d, TOOLS).center().x, body(&d, TOOLS).top() + 4.0); - for (from, panel) in [(tab, PROJECT[0]), (bar, PROJECT[0]), (grip, TOOLS)] { - // Without the modifier, a release over a frame docks there; the preview names the modifier. - let mut d = driver(editing()); - d.hold(from, over); - assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d)); - let out = d.release(over); - assert!(!out.actions.is_empty() && !floated(&out.actions, panel), "{from:?}: {:?}", out.actions); - // Held, it floats, and the hint is gone. - let mut d = driver(editing()); - d.modifiers = Modifiers::COMMAND; - d.hold(from, over); - assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}"); - let out = d.release(over); - assert!(floated(&out.actions, panel), "{from:?}: {:?}", out.actions); - } -} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index bface8f2..9d81bb9a 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -493,12 +493,12 @@ fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() { } #[test] -fn lone_bare_or_protected_panels_draw_no_strip_and_no_bar() { +fn a_lone_protected_panel_draws_no_strip_and_no_bar() { struct Bare; impl DockContent for Bare { fn body(&mut self, _: &mut egui::Ui, _: &u32) {} - fn bare(&self, panel: &u32) -> bool { - *panel == 1 + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 1 { Permissions::PROTECTED } else { Permissions::default() } } } let ctx = Context::default(); @@ -669,7 +669,7 @@ fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() { let wheel = |unit, y| Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }; let middle = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Middle, pressed, modifiers: Modifiers::NONE }; - // Off by default in the Dock profile. + // Off by default. let mut d = Driver::new(frames()); let over = d.tab(2).center(); d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); @@ -678,7 +678,7 @@ fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() { assert_eq!(second(&d), tabs(&[2, 3], 1)); let mut d = Driver::new(frames()); - d.tweak = |style| style.profile = Profile::Frame; + d.tweak = super::floating_tests::frame_style; d.step(vec![]); let over = d.tab(2).center(); d.step(vec![Event::PointerMoved(over), wheel(egui::MouseWheelUnit::Line, 1.0)]); diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 0c5faf70..17b37149 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -68,6 +68,9 @@ pub enum Divider

{ Column(P), /// Below the dock or floating group holding this panel first. Group(P), + /// Where the divider of a split inside another split ends on the outer split's divider, + /// by the outer and the inner split's paths. Dragging it moves both dividers. + Intersection(Vec, Vec), } fn key(area: Id) -> Id { @@ -101,6 +104,7 @@ impl DockArea<'_, P> { Hit::Divider(Divider::Column(p)) => id.with(("column-resize", Some(p))), Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))), Hit::Divider(Divider::Split(path)) => id.with(("split", path)), + Hit::Divider(Divider::Intersection(outer, inner)) => id.with(("intersection", outer, inner)), Hit::Title(p) => id.with(("move-group", Some(p))), Hit::Close(p) => id.with(("move-group", Some(p))).with("close"), Hit::Resize(p, edge) => { @@ -169,9 +173,14 @@ pub(super) fn named(area: } } let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); + let mut splits: Vec> = Vec::new(); while let Some((node, path)) = nodes.pop() { if let Node::Split { first, second, .. } = node { name(Hit::Divider(Divider::Split(path.clone()))); + for outer in splits.iter().filter(|outer| path.starts_with(outer)) { + name(Hit::Divider(Divider::Intersection(outer.clone(), path.clone()))); + } + splits.push(path.clone()); let mut second_path = path.clone(); second_path.push(true); let mut first_path = path; diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index 76324dff..e900e6fc 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -38,9 +38,6 @@ impl DockStyle { /// | `dock.expandColumn` | `expand_column_label` | Expand column | /// | `dock.moveWindow` | `move_window_label` | Move panel window | /// | `dock.closeWindow` | `close_window_label` | Close panel window | - /// | `dock.undock` | `undock_label` | Undock Panel | - /// | `dock.undockGroup` | `undock_group_label` | Undock Panel Group | - /// | `dock.closePanelGroup` | `close_panel_group_label` | Close Panel Group | /// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group | /// | `dock.maximize` | `maximize_label` | Maximize Frame | /// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size | @@ -57,7 +54,7 @@ impl DockStyle { self } - fn labels_mut(&mut self) -> [(&'static str, &mut String); 28] { + fn labels_mut(&mut self) -> [(&'static str, &mut String); 25] { [ ("dock.float", &mut self.float_label), ("dock.close", &mut self.close_label), @@ -78,9 +75,6 @@ impl DockStyle { ("dock.expandColumn", &mut self.expand_column_label), ("dock.moveWindow", &mut self.move_window_label), ("dock.closeWindow", &mut self.close_window_label), - ("dock.undock", &mut self.undock_label), - ("dock.undockGroup", &mut self.undock_group_label), - ("dock.closePanelGroup", &mut self.close_panel_group_label), ("dock.closeOthers", &mut self.close_others_label), ("dock.maximize", &mut self.maximize_label), ("dock.restoreFrame", &mut self.restore_frame_label), diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index ba6651d2..2587dca0 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -27,8 +27,8 @@ pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ - DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, Profile, begin_drag, - cancel_drag, float_space, is_dragging, + DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, + float_space, is_dragging, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; pub use workspace::Workspace; @@ -42,12 +42,12 @@ mod dock_tests; #[cfg(test)] mod floating_tests; #[cfg(test)] -mod frame_tests; -#[cfg(test)] mod group_tests; #[cfg(test)] mod iconic_tests; #[cfg(test)] mod model_tests; #[cfg(test)] +mod split_tests; +#[cfg(test)] mod tests; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 73d39843..16932bf8 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -1,4 +1,4 @@ -//! [D] The dock profile: side docks of columns, each a column bar above stacked groups. +//! [D] Docks: side docks of columns, each a column bar above stacked groups. //! //! Columns run from the window edge inwards. Every column's inner edge resizes it; the last //! column's inner edge is the dock's inner edge. Behavior follows VectorCraft `floating.rs` @@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer}; use crate::docking::{ - Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Profile, Side, Target, + Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, dock_model::Unit, targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, }; @@ -41,6 +41,31 @@ fn side_at(edge: Edge, right_to_left: bool) -> Side { } } +/// The rects of a dock beside `other` in `rect`, and the widest the dock may get. +pub(in crate::docking) fn dock_rects

( + side: Side, + columns: &[Column

], + other: &Node

, + rect: Rect, + style: &DockStyle, + content: &dyn crate::docking::DockContent

, +) -> (Rect, Rect, f32) { + let gap = style.gap; + let room = (rect.width() - gap - super::node_limits(other, style, content).min.x).clamp(0.0, rect.width()); + let width = columns.iter().map(|c| column_width(c, style)).sum::().clamp(0.0, room); + let (dock, rest) = match edge_of(side, style.right_to_left) { + Edge::Left => ( + Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), + Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max), + ), + Edge::Right => ( + Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max), + Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())), + ), + }; + (dock, rest, room) +} + pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) -> f32 { if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width } } @@ -125,19 +150,7 @@ impl Renderer<'_, P> { (other, Node::Dock { side, columns }) => (false, *side, columns, other), _ => return false, }; - let gap = self.style.gap; - let room = (rect.width() - gap - self.node_limits(other).min.x).clamp(0.0, rect.width()); - let width = columns.iter().map(|c| column_width(c, self.style)).sum::().clamp(0.0, room); - let (dock, rest) = match edge_of(side, self.style.right_to_left) { - Edge::Left => ( - Rect::from_min_max(rect.min, pos2(rect.left() + width, rect.bottom())), - Rect::from_min_max(pos2((rect.left() + width + gap).min(rect.right()), rect.top()), rect.max), - ), - Edge::Right => ( - Rect::from_min_max(pos2(rect.right() - width, rect.top()), rect.max), - Rect::from_min_max(rect.min, pos2((rect.right() - width - gap).max(rect.left()), rect.bottom())), - ), - }; + let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content); self.dock(ui, side, columns, dock, room); path.push(dock_first); self.node(ui, other, rest, path); @@ -470,7 +483,7 @@ impl Renderer<'_, P> { /// have no dock yet. fn dock_candidates(&self) -> Vec> { let mut candidates = self.dock_targets.clone(); - if self.style.profile != Profile::Dock || !usable(self.workspace) { + if !self.style.new_dock_zones || !usable(self.workspace) { return candidates; } let rtl = self.style.right_to_left; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 85282ca0..f8bc435a 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -240,6 +240,14 @@ impl Renderer<'_, P> { return self.stack_proposal(window, members, pointer); } let (anchor, zone, preview) = self.edge_proposal(members, pointer)?; + let lead = members.first()?.clone(); + let action = match members { + [_] => Action::Move { panel: lead, anchor: anchor.clone(), placement: Placement::Edge(zone) }, + _ => Action::MoveGroup { panel: lead, to: Target::At { anchor: anchor.clone(), placement: Placement::Edge(zone) } }, + }; + if !self.leaves_room(action) { + return None; + } Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false }) } diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 1470b96d..f7fa7953 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -1,4 +1,5 @@ -//! Frame profile: Maximize Frame, the outer edge band, hidden panels and the frame menu items. +//! What the split tree adds around the groups: Maximize Frame, the outer edge band, hidden +//! panels, the float hint and the group menu tail. use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash}; @@ -6,7 +7,7 @@ use egui::{Context, Pos2, Rect, pos2, vec2}; use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup}; use crate::docking::{ - Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Profile, Zone, + Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone, dock_model::{Place, node_at}, }; @@ -157,14 +158,14 @@ impl Renderer<'_, P> { self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) } - /// The outer edge band under the pointer (P8): top and bottom in both profiles, left and - /// right in the frame profile. Returns a docked anchor that is not moving, the zone and - /// the preview of the new frame. + /// The outer edge band under the pointer (P8): top and bottom, and left and right with + /// [`crate::docking::DockStyle::side_edge_zones`]. Returns a docked anchor that is not + /// moving, the zone and the preview of the new frame. pub(super) fn edge_proposal(&self, members: &[P], pointer: Pos2) -> Option<(P, Zone, Rect)> { let area = self.frame.edge.filter(|area| area.contains(pointer))?; let band = self.style.edge_band.min(area.width() / 4.0).min(area.height() / 4.0); let mut edges = vec![(pointer.y - area.top(), Zone::Top), (area.bottom() - pointer.y, Zone::Bottom)]; - if self.style.profile == Profile::Frame { + if self.style.side_edge_zones { edges.extend([(pointer.x - area.left(), Zone::Left), (area.right() - pointer.x, Zone::Right)]); } let (distance, zone) = edges.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; @@ -183,10 +184,10 @@ impl Renderer<'_, P> { Some((anchor, zone, preview)) } - /// Under the pointer while a drag offers a docking target (frame profile): which modifier - /// floats the drop instead. + /// Under the pointer while a drag offers a docking target: which modifier floats the drop + /// instead. pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) { - if self.style.profile != Profile::Frame || self.style.float_hint_label.is_empty() { + if !self.style.float_hint || self.style.float_hint_label.is_empty() { return; } let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; @@ -198,22 +199,10 @@ impl Renderer<'_, P> { painter.galley(rect.center() - galley.size() * 0.5, galley, self.style.text); } - pub(super) fn float_label(&self) -> String { - if self.style.profile == Profile::Frame { self.style.undock_label.clone() } else { self.style.float_label.clone() } - } - - pub(super) fn float_group_label(&self) -> String { - if self.style.profile == Profile::Frame { self.style.undock_group_label.clone() } else { self.style.float_group_label.clone() } - } - - pub(super) fn close_group_label(&self) -> String { - if self.style.profile == Profile::Frame { self.style.close_panel_group_label.clone() } else { self.style.close_group_label.clone() } - } - - /// Ctrl/Cmd held when a frame-profile tab drag starts: the drop floats (S9). + /// Ctrl/Cmd held when a split-tree tab drag starts: the drop floats (S9). pub(super) fn latch_undock(&self, ctx: &Context, kind: Option) { let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd); - if self.style.profile == Profile::Frame && kind == Some(GroupKind::Frame) && command { + if self.style.command_press_floats && kind == Some(GroupKind::Frame) && command { ctx.data_mut(|d| { if let Some(drag) = d.get_temp::>(self.id.with("drag")) { d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag }); @@ -222,21 +211,24 @@ impl Renderer<'_, P> { } } - /// Frame items of the menu tail: Close Other Panels in Group and Maximize Frame (frame - /// profile), and Move Panel to New Window where native windows are available. + /// The menu tail: Close Other Panels in Group and Maximize Frame when the style shows + /// them, and Move Panel to New Window where native windows are available. pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { - if self.style.profile == Profile::Frame { - let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); - if owner.panels.len() > 1 && others && self.style.close_menu_items && self.menu_action(ui, &self.style.close_others_label.clone()) { - self.output.actions.push(Action::CloseOthers { panel: panel.clone() }); - } - if owner.kind == GroupKind::Frame { - let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m)); - let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() }; - if self.menu_action(ui, &label) { - let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() }; - self.output.events.push(DockEvent::ToggleMaximize { panel }); - } + let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); + if self.style.close_others_item + && owner.panels.len() > 1 + && others + && self.style.close_menu_items + && self.menu_action(ui, &self.style.close_others_label.clone()) + { + self.output.actions.push(Action::CloseOthers { panel: panel.clone() }); + } + if self.style.maximize_item && owner.kind == GroupKind::Frame { + let restore = self.frame.maximized.as_ref().is_some_and(|m| owner.panels.contains(m)); + let label = if restore { self.style.restore_frame_label.clone() } else { self.style.maximize_label.clone() }; + if self.menu_action(ui, &label) { + let panel = if restore { self.frame.maximized.clone().unwrap_or_else(|| panel.clone()) } else { panel.clone() }; + self.output.events.push(DockEvent::ToggleMaximize { panel }); } } #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index b84cde4a..8ce1ecb9 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -134,7 +134,7 @@ impl Renderer<'_, P> { // Registered last, so the band wins over the tabs below it. let border = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, (strip.top() + BORDER_BAND).min(strip.bottom())))); self.group_handle(ui, "drag-border", border, &owner, movable); - if self.style.wheel_selects_tabs() { + if self.style.wheel_selects_tabs { let tabs_area = mirror(Rect::from_min_max(strip.min, pos2(strip.right() - header_width, strip.bottom()))); self.wheel_tabs(ui, tabs_area, panels, active); } @@ -255,7 +255,7 @@ impl Renderer<'_, P> { let closable = self.style.close_menu_items && all(&|p| p.closable); let movable = all(&|p| p.movable); let floatable = movable && all(&|p| p.floatable); - if closable && self.menu_action(ui, &self.close_group_label()) { + if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } if owner.kind != GroupKind::Frame { @@ -267,7 +267,7 @@ impl Renderer<'_, P> { if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.float_group_label()) { + if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { let size = self.group_float_size(owner.panels, owner.rect.size()); let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::MoveGroup { @@ -395,7 +395,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !minimized }); } } - if self.style.middle_click_closes_tabs() && policy.closable && response.clicked_by(egui::PointerButton::Middle) { + if self.style.middle_click_closes_tabs && policy.closable && response.clicked_by(egui::PointerButton::Middle) { self.output.actions.push(Action::Close { panel: panel.clone() }); } if policy.movable && response.drag_started() && begin_drag(ui.ctx(), self.id, panel.clone(), group) { @@ -404,7 +404,7 @@ impl Renderer<'_, P> { response.context_menu(|ui| { self.content.panel_menu(ui, panel); if policy.movable && policy.floatable { - let logical = self.float_label(); + let logical = self.style.float_label.clone(); let text = self.content.action_label(ui, &logical, &self.style.font, self.style.text); let response = ui.button(text); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index c74a45c5..742839a7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -1,8 +1,8 @@ use std::{collections::HashMap, fmt::Debug, hash::Hash}; -use egui::{Context, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; +use egui::{Context, CursorIcon, Id, Rect, Sense, Ui, UiBuilder, Vec2, pos2, vec2}; -use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect}; +use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; @@ -25,6 +25,9 @@ pub(in crate::docking) use dock::fit_widths; pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; +mod sizes; + +pub(in crate::docking) use sizes::{DROP_MARGIN, Drawn, Sizer}; pub(super) struct Renderer<'a, P> { pub(super) id: Id, @@ -50,18 +53,16 @@ pub(super) struct Renderer<'a, P> { /// Each panel's place in `layout`, and its entry in `float_space.contents`. pub(super) places: HashMap, pub(super) contents: HashMap, - /// Frames drawn as the fixed-size side of their parent split, for split previews. - pub(super) fixed_slots: Vec>, + /// Split dividers drawn so far, by view path, for the intersections of their ancestors. + pub(super) dividers: Vec, } -/// A frame that holds the fixed size of its parent split: the parent's and the other child's -/// rects, where a side split of the frame goes (see `dock_model::split_frame`). -pub(super) struct FixedSlot

{ - pub(super) panels: Vec

, - pub(super) axis: SplitAxis, - pub(super) first: bool, - pub(super) parent: Rect, - pub(super) sibling: Rect, +pub(super) struct DrawnDivider { + path: Vec, + axis: SplitAxis, + /// The whole split's rect. + rect: Rect, + divider: Rect, } impl Renderer<'_, P> { @@ -111,25 +112,10 @@ impl Renderer<'_, P> { match node { Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {} Node::Split { axis, size, first, second } => { - let Some(wanted) = split_rect(rect, *axis, *size, self.style.gap, 0.0) else { return }; - let (lo, hi) = split_bounds(wanted.available(), *axis, self.node_limits(first), self.node_limits(second)); - let first_size = wanted.first_size().clamp(lo, hi); - let Some(split) = split_rect(rect, *axis, SplitSize::FixedFirst(first_size), self.style.gap, 0.0) else { return }; - let fixed = match size { - SplitSize::FixedFirst(_) => Some((first, true, split.second)), - SplitSize::FixedSecond(_) => Some((second, false, split.first)), - SplitSize::Ratio(_) => None, - }; - let panels = |node: &Node

| match node { - Node::Tabs { panels, .. } => Some(panels.clone()), - Node::Stack { entries } => Some(entries.iter().map(|e| e.panel.clone()).collect()), - Node::Split { .. } | Node::Dock { .. } => None, - }; - if let Some((node, first, sibling)) = fixed - && let Some(panels) = panels(node) - { - self.fixed_slots.push(FixedSlot { panels, axis: *axis, first, parent: rect, sibling }); - } + let sizer = self.sizer(); + let Some(split) = sizer.split(*axis, *size, first, second, rect) else { return }; + let (lo, hi) = sizer.range(node, rect); + let mark = self.dividers.len(); path.push(false); self.node(ui, first, split.first, path); path.pop(); @@ -147,13 +133,16 @@ impl Renderer<'_, P> { &self.style.resize_label, ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, ) - .value(split.first_size(), lo..=hi) + .value(split.first_size(), (split.first_size() + lo)..=(split.first_size() + hi)) .show(ui); self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 - && let Some(size) = split.resized(*size, (split.first_size() + response.delta).clamp(lo, hi) - split.first_size(), 0.0) - { - self.output.actions.push(Action::ResizeSplit { path: layout_path, size }); + if response.delta != 0.0 { + let actions = self.divider_actions(node, rect, path, response.delta); + self.output.actions.extend(actions); + } + self.intersections(ui, node, rect, path, split.divider, mark); + if self.dividers.len() < crate::docking::model::MAX_NODES { + self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, rect, divider: split.divider }); } } Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), @@ -250,8 +239,50 @@ impl Renderer<'_, P> { } } - pub(super) fn node_limits(&self, node: &Node

) -> PanelLimits { - node_limits(node, self.style, &*self.content) + pub(super) fn sizer(&self) -> Sizer<'_, P> { + Sizer { style: self.style, content: &*self.content } + } + + /// `ResizeSplit` actions that move the divider of the split `node`, drawn at view path + /// `path` in `rect`, by `delta` points. + fn divider_actions(&self, node: &Node

, rect: Rect, path: &[bool], delta: f32) -> Vec> { + let sizes = self.sizer().drag(node, rect, delta, &mut path.to_vec()); + sizes.into_iter().map(|(path, size)| Action::ResizeSplit { path: self.original_path(&path), size }).collect() + } + + /// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on + /// the split's own divider: dragging one moves both dividers. + fn intersections(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &[bool], divider: Rect, mark: usize) { + let Node::Split { axis, .. } = node else { return }; + let side = (self.style.gap * 3.0).max(12.0); + let mut found = Vec::new(); + for inner in self.dividers.get(mark..).unwrap_or_default() { + if inner.axis == *axis || !inner.divider.expand(1.0).intersects(divider) { + continue; + } + let center = match axis { + SplitAxis::Horizontal => pos2(divider.center().x, inner.divider.center().y), + SplitAxis::Vertical => pos2(inner.divider.center().x, divider.center().y), + }; + found.push((inner.path.clone(), inner.rect, Rect::from_center_size(center, Vec2::splat(side)).intersect(rect))); + } + for (inner_path, inner_rect, hit) in found { + let Some(inner) = inner_path.get(path.len()..).and_then(|rest| crate::docking::dock_model::node_at(node, rest)) else { continue }; + let id = self.id.with(("intersection", self.original_path(path), self.original_path(&inner_path))); + let response = ui.interact(hit, id, Sense::drag()); + self.register_interaction(ui.ctx(), id); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(CursorIcon::Move); + } + let delta = response.drag_delta(); + if delta != Vec2::ZERO && delta.is_finite() { + let along = |axis: SplitAxis| if axis == SplitAxis::Horizontal { delta.x } else { delta.y }; + let Node::Split { axis: inner_axis, .. } = inner else { continue }; + let mut actions = self.divider_actions(node, rect, path, along(*axis)); + actions.extend(self.divider_actions(inner, inner_rect, &inner_path, along(*inner_axis))); + self.output.actions.extend(actions); + } + } } pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) { @@ -273,10 +304,10 @@ impl Renderer<'_, P> { } } -/// A lone protected or bare panel draws no tab strip and no drag bar. +/// A lone protected panel draws no tab strip and no drag bar. pub(in crate::docking) fn stripless

(panels: &[P], content: &dyn DockContent

) -> bool { match panels { - [panel] => content.permissions(panel).is_protected() || content.bare(panel), + [panel] => content.permissions(panel).is_protected(), _ => false, } } @@ -288,12 +319,14 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont let width: f32 = columns.iter().map(|c| dock::column_width(c, style)).sum(); PanelLimits { min: vec2(width.clamp(style.min_pane, 1_000_000.0), style.min_pane), ..PanelLimits::default() } } + // A group is as large as its largest panel allows: a panel without a maximum lifts the + // maximum of the panels it joins. Node::Tabs { panels, .. } => { - let mut limits = PanelLimits::default(); + let mut limits = PanelLimits { min: Vec2::ZERO, max: if panels.is_empty() { PanelLimits::default().max } else { Vec2::ZERO } }; for panel in panels { let policy = content.limits(panel); limits.min = limits.min.max(policy.min).max(Vec2::splat(style.min_pane)); - limits.max = limits.max.min(policy.max); + limits.max = limits.max.max(policy.max); } limits.max = limits.max.max(limits.min); if !stripless(panels, content) { @@ -304,11 +337,14 @@ pub(in crate::docking) fn node_limits

(node: &Node

, style: &DockStyle, cont } Node::Stack { entries } => { let mut limits = PanelLimits::default(); + if !entries.is_empty() { + limits.max.x = 0.0; + } for entry in entries { let policy = content.limits(&entry.panel); limits.min.x = limits.min.x.max(policy.min.x).max(style.min_pane); limits.min.y += if entry.open { policy.min.y.max(style.min_pane) } else { 0.0 } + style.tab_height; - limits.max.x = limits.max.x.min(policy.max.x); + limits.max.x = limits.max.x.max(policy.max.x); } limits.min = limits.min.min(Vec2::splat(1_000_000.0)); limits.max = limits.max.max(limits.min); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index d3e043e6..9de57d97 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -1,17 +1,15 @@ -use std::{fmt::Debug, hash::Hash}; +use std::{collections::HashMap, fmt::Debug, hash::Hash}; use egui::{Context, Key, Pos2, Rect, Ui, Vec2, pos2, vec2}; -use super::{Renderer, clamp_float, dock::group_action}; +use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ - Action, Drag, DropProposal, DropRequest, GroupDrag, GroupGesture, Node, Placement, Target, Zone, cancel_drag, + Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, dock_model::{Place, node_at}, resolve_drop, surface::TAB_PADDING, - zone_rect, }; -use crate::layout::SplitAxis; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. fn float_only(ctx: &Context) -> bool { @@ -90,7 +88,6 @@ impl Renderer<'_, P> { return valid(proposal); } let own = |t: &crate::docking::DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); - let pointer = self.seam_pointer(pointer); let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?; if own(target) { @@ -102,49 +99,48 @@ impl Renderer<'_, P> { noop: true, }); } - let proposal = resolve_drop(&drag.panel, pointer, std::slice::from_ref(target))?; + let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?; if window && matches!(proposal.placement, Placement::Split(_)) { return None; } - valid(self.split_preview(proposal)) + valid(proposal) } - /// The preview of a side split beside a frame that holds its parent's fixed size: the half - /// of the parent or of the other child that the new frame takes. - fn split_preview(&self, mut proposal: DropProposal

) -> DropProposal

{ - let Placement::Split(zone) = proposal.placement else { return proposal }; - let axis = match zone { - Zone::Center => return proposal, - Zone::Left | Zone::Right => SplitAxis::Horizontal, - Zone::Top | Zone::Bottom => SplitAxis::Vertical, - }; - let Some(slot) = self.fixed_slots.iter().find(|slot| slot.axis == axis && slot.panels.contains(&proposal.anchor)) else { return proposal }; - let opposite = match zone { - Zone::Left => Zone::Right, - Zone::Right => Zone::Left, - Zone::Top => Zone::Bottom, - _ => Zone::Top, - }; - let before = matches!(zone, Zone::Left | Zone::Top); - let preview = if before == slot.first { zone_rect(slot.parent, zone) } else { zone_rect(slot.sibling, opposite) }; - proposal.preview = preview.unwrap_or(proposal.preview); - proposal - } - - /// A pointer in the gap between docked frames, outside every target, moves onto the edge of - /// the nearest frame that takes splits, so a seam splits beside that frame rather than floating. - pub(super) fn seam_pointer(&self, pointer: Pos2) -> Pos2 { - let usable = |rect: Rect| crate::docking::targets::usable(rect); - if self.targets.iter().any(|t| usable(t.rect) && t.rect.contains(pointer)) { - return pointer; + /// [`resolve_drop`], where a split that would leave a group without room becomes a join + /// of the target group. + fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { + let proposal = resolve_drop(panel, pointer, targets)?; + if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { + return Some(proposal); } - self.targets - .iter() - .filter(|t| t.allow_splits && usable(t.rect)) - .map(|t| (t.rect.distance_to_pos(pointer), t.rect)) - .filter(|(distance, _)| *distance <= self.style.gap) - .min_by(|a, b| a.0.total_cmp(&b.0)) - .map_or(pointer, |(_, rect)| rect.clamp(pointer)) + if self.leaves_room(action(Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() })) { + return Some(proposal); + } + let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; + resolve_drop(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() })) + } + + /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group + /// it makes smaller stays [`DROP_MARGIN`] above its minimum, so each divider can still + /// travel both ways. An action the model rejects is left to the model's check. + pub(super) fn leaves_room(&self, action: Action

) -> bool { + let mut candidate = self.layout.clone(); + if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { + return true; + } + let sizer = self.sizer(); + let drawn = |layout: &Layout

| { + let mut out: Vec> = Vec::new(); + if let Some(root) = &layout.root { + sizer.groups(root, self.workspace, &mut out); + } + out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect.size(), d.limits.min))).collect::>() + }; + let before: HashMap = drawn(self.layout).into_iter().map(|(panel, size, _)| (panel, size)).collect(); + drawn(&candidate).into_iter().all(|(panel, size, min)| { + let room = size - min; + (room.x >= DROP_MARGIN && room.y >= DROP_MARGIN) || before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + }) } /// Release of a group drag: join or split at the target, or float at the ghost with the @@ -276,8 +272,13 @@ impl Renderer<'_, P> { let proposal = if float_only { None } else { - self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false) - .or_else(|| resolve_drop(&drag.panel, self.seam_pointer(pointer), &self.targets).map(|p| self.split_preview(p))) + self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| { + let panel = &drag.panel; + self.resolve_roomy(panel, pointer, &self.targets, |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }) + }) }; if released { cancel_drag::

(ctx, self.id); @@ -453,3 +454,12 @@ impl Renderer<'_, P> { true } } + +/// The panel that names a drawn group: its first panel; none for a dock or a split. +fn lead

(node: &Node

) -> Option<&P> { + match node { + Node::Tabs { panels, .. } => panels.first(), + Node::Stack { entries } => entries.first().map(|e| &e.panel), + Node::Split { .. } | Node::Dock { .. } => None, + } +} diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs new file mode 100644 index 00000000..bf7fd3dc --- /dev/null +++ b/crates/ui/src/docking/render/sizes.rs @@ -0,0 +1,172 @@ +//! Split-tree geometry: where each split draws its children, how far a divider can travel, +//! and the sizes that move a divider while every group away from it keeps its size. + +use egui::Rect; + +use super::{dock::dock_rects, node_limits, split_bounds}; +use crate::docking::{DockContent, DockStyle, Node, PanelLimits}; +use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect}; + +/// Room a group keeps above its minimum after a drop, so each of its dividers can still +/// travel both ways. +pub(in crate::docking) const DROP_MARGIN: f32 = 1.0; + +pub(in crate::docking) struct Sizer<'a, P> { + pub(in crate::docking) style: &'a DockStyle, + pub(in crate::docking) content: &'a dyn DockContent

, +} + +/// One group as drawn: its node, rect and limits. +pub(in crate::docking) struct Drawn<'a, P> { + pub(in crate::docking) node: &'a Node

, + pub(in crate::docking) rect: Rect, + pub(in crate::docking) limits: PanelLimits, +} + +fn extent(rect: Rect, axis: SplitAxis) -> f32 { + match axis { + SplitAxis::Horizontal => rect.width(), + SplitAxis::Vertical => rect.height(), + } +} + +fn along(v: egui::Vec2, axis: SplitAxis) -> f32 { + match axis { + SplitAxis::Horizontal => v.x, + SplitAxis::Vertical => v.y, + } +} + +impl

Sizer<'_, P> { + fn limits(&self, node: &Node

) -> PanelLimits { + node_limits(node, self.style, self.content) + } + + /// The split as drawn: its stored size, clamped so both children fit their limits. + pub(in crate::docking) fn split(&self, axis: SplitAxis, size: SplitSize, first: &Node

, second: &Node

, rect: Rect) -> Option { + let wanted = split_rect(rect, axis, size, self.style.gap, 0.0)?; + let (lo, hi) = split_bounds(wanted.available(), axis, self.limits(first), self.limits(second)); + split_rect(rect, axis, SplitSize::FixedFirst(wanted.first_size().clamp(lo, hi)), self.style.gap, 0.0) + } + + /// The children of a split drawn in `rect`, with their rects; a dock beside its neighbour + /// is drawn by the dock renderer. + pub(in crate::docking) fn children<'n>(&self, node: &'n Node

, rect: Rect) -> Option<[(&'n Node

, Rect); 2]> { + let Node::Split { axis, size, first, second } = node else { return None }; + match (&**first, &**second) { + (Node::Dock { side, columns }, other) if *axis == SplitAxis::Horizontal => { + let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content); + Some([(first, dock), (second, rest)]) + } + (other, Node::Dock { side, columns }) if *axis == SplitAxis::Horizontal => { + let (dock, rest, _) = dock_rects(*side, columns, other, rect, self.style, self.content); + Some([(first, rest), (second, dock)]) + } + _ => { + let split = self.split(*axis, *size, first, second, rect)?; + Some([(first, split.first), (second, split.second)]) + } + } + } + + /// Every group of `node` drawn in `rect`; a dock counts as one. + pub(in crate::docking) fn groups<'n>(&self, node: &'n Node

, rect: Rect, out: &mut Vec>) { + match self.children(node, rect) { + Some(children) => children.into_iter().for_each(|(child, rect)| self.groups(child, rect, out)), + None => out.push(Drawn { node, rect, limits: self.limits(node) }), + } + } + + /// How far the end edge (`end`) or start edge of `node` drawn in `rect` can move along + /// `axis`: inward while the groups touching it stay at their minimums, and outward up to + /// their maximums. + pub(in crate::docking) fn travel(&self, node: &Node

, rect: Rect, axis: SplitAxis, end: bool) -> (f32, f32) { + let docked = + matches!(node, Node::Split { first, second, .. } if matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. })); + match (node, self.children(node, rect)) { + (Node::Split { first, .. }, Some([a, b])) if docked => { + let (other, rect) = if matches!(**first, Node::Dock { .. }) { b } else { a }; + self.travel(other, rect, axis, end) + } + (Node::Split { axis: split_axis, .. }, Some([a, b])) if *split_axis == axis => { + let (child, rect) = if end { b } else { a }; + self.travel(child, rect, axis, end) + } + (_, Some([a, b])) => { + let (a, b) = (self.travel(a.0, a.1, axis, end), self.travel(b.0, b.1, axis, end)); + (a.0.min(b.0), a.1.min(b.1)) + } + (_, None) => { + let limits = self.limits(node); + let size = extent(rect, axis); + ((size - along(limits.min, axis)).max(0.0), (along(limits.max, axis) - size).max(0.0)) + } + } + } + + /// The sizes that move the end (`end`) or start edge of `node` drawn in `rect` outward by + /// `grow` points: only the groups touching that edge change. Paths are relative to `path`. + pub(in crate::docking) fn follow( + &self, + (node, rect): (&Node

, Rect), + axis: SplitAxis, + end: bool, + grow: f32, + path: &mut Vec, + out: &mut Vec<(Vec, SplitSize)>, + ) { + let Some([a, b]) = self.children(node, rect) else { return }; + let Node::Split { axis: split_axis, first, second, .. } = node else { return }; + let docked = matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }); + let visit = |child: (&Node

, Rect), second_child: bool, path: &mut Vec, out: &mut Vec<(Vec, SplitSize)>| { + path.push(second_child); + self.follow(child, axis, end, grow, path, out); + path.pop(); + }; + if docked { + let dock_first = matches!(**first, Node::Dock { .. }); + visit(if dock_first { b } else { a }, dock_first, path, out); + } else if *split_axis == axis { + let available = extent(a.1, axis) + extent(b.1, axis) + grow; + let first_size = extent(a.1, axis) + if end { 0.0 } else { grow }; + if available > 0.0 { + out.push((path.clone(), SplitSize::Ratio((first_size / available).clamp(0.0, 1.0)))); + } + visit(if end { b } else { a }, end, path, out); + } else { + visit(a, false, path, out); + visit(b, true, path, out); + } + } + + /// The divider of the split `node` drawn in `rect` moved by `delta` points, clamped to its + /// travel: the split's own size and those of the splits that keep the groups away from the + /// divider at their sizes. Paths are relative to `path`, the split's own. + pub(in crate::docking) fn drag(&self, node: &Node

, rect: Rect, delta: f32, path: &mut Vec) -> Vec<(Vec, SplitSize)> { + let Node::Split { axis, size, first, second } = node else { return Vec::new() }; + let Some(split) = self.split(*axis, *size, first, second, rect) else { return Vec::new() }; + let (lo, hi) = self.range(node, rect); + let delta = delta.clamp(lo, hi); + if delta == 0.0 || !delta.is_finite() || split.available() <= 0.0 { + return Vec::new(); + } + let mut out = vec![(path.clone(), SplitSize::Ratio(((split.first_size() + delta) / split.available()).clamp(0.0, 1.0)))]; + path.push(false); + self.follow((first, split.first), *axis, true, delta, path, &mut out); + path.pop(); + path.push(true); + self.follow((second, split.second), *axis, false, -delta, path, &mut out); + path.pop(); + out + } + + /// How far the divider of the split `node` drawn in `rect` can move: `(lo, hi)`, with + /// `lo <= 0 <= hi`. + pub(in crate::docking) fn range(&self, node: &Node

, rect: Rect) -> (f32, f32) { + let Node::Split { axis, size, first, second } = node else { return (0.0, 0.0) }; + let Some(split) = self.split(*axis, *size, first, second, rect) else { return (0.0, 0.0) }; + let (first_in, first_out) = self.travel(first, split.first, *axis, true); + let (second_in, second_out) = self.travel(second, split.second, *axis, false); + (-first_in.min(second_out), first_out.min(second_in)) + } +} diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs new file mode 100644 index 00000000..89a94dc9 --- /dev/null +++ b/crates/ui/src/docking/split_tests.rs @@ -0,0 +1,445 @@ +//! A split tree of tab groups in an editing layout like FilmCraft's: Source | Program | +//! Properties over Project | Tools | Timeline | Audio Meters, every group an ordinary +//! resizable one. One test per docking rule, the stuck cases of the FilmCraft map, and a +//! property test over random drops. + +use std::collections::HashMap; + +use super::floating_tests::frame_style; +use super::render::{Drawn, Sizer}; +use super::testing::DockDriver; +use super::*; +use crate::layout::{SplitAxis, SplitSize}; +use egui::{Modifiers, Pos2, Rect, Vec2, pos2, vec2}; + +const SOURCE: [u32; 4] = [1, 11, 12, 13]; +const PROGRAM: u32 = 2; +const PROPERTIES: [u32; 3] = [3, 31, 32]; +const PROJECT: [u32; 7] = [4, 41, 42, 43, 44, 45, 46]; +const TOOLS: u32 = 5; +const TIMELINE: u32 = 6; +const METERS: u32 = 7; +const INFO: u32 = 9; + +const SCREEN: Vec2 = Vec2::new(1440.0, 860.0); + +/// Panel bodies by their rects; `caps` are maximum widths a host asks for. +#[derive(Default)] +struct Film { + bodies: HashMap, + caps: HashMap, +} + +impl DockContent for Film { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + self.bodies.insert(*panel, ui.max_rect()); + } + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + fn limits(&self, panel: &u32) -> PanelLimits { + match self.caps.get(panel) { + Some(width) => PanelLimits { min: Vec2::ZERO, max: vec2(*width, 1_000_000.0) }, + None => PanelLimits::default(), + } + } +} + +type Driver = DockDriver; + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +fn split(axis: SplitAxis, ratio: f32, first: Node, second: Node) -> Node { + Node::Split { axis, size: SplitSize::Ratio(ratio), first: Box::new(first), second: Box::new(second) } +} + +fn hsplit(ratio: f32, first: Node, second: Node) -> Node { + split(SplitAxis::Horizontal, ratio, first, second) +} + +/// The Editing workspace, with Info floating over Properties. +fn editing() -> Layout { + let top = hsplit(0.69, hsplit(0.5, tabs(&SOURCE), tabs(&[PROGRAM])), tabs(&PROPERTIES)); + let row = hsplit(0.06, tabs(&[TOOLS]), hsplit(0.88, tabs(&[TIMELINE]), tabs(&[METERS]))); + let bottom = hsplit(0.29, tabs(&PROJECT), row); + Layout { + root: Some(split(SplitAxis::Vertical, 0.57, top, bottom)), + floating: vec![Floating::single(vec![INFO], 0, [1120.0, 60.0, 280.0, 200.0])], + } +} + +fn driver(layout: Layout, content: Film) -> Driver { + let mut d = DockDriver::new(layout, content).with_style(frame_style); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + d +} + +fn film() -> Driver { + driver(editing(), Film::default()) +} + +/// A group's rect: its active body and its tab strip. +fn group(d: &Driver, panel: u32) -> Rect { + let body = d.content.bodies.get(&panel).copied().unwrap_or_else(|| panic!("panel {panel} not drawn")); + Rect::from_min_max(pos2(body.left(), body.top() - 26.0), body.max) +} + +fn tab(d: &Driver, panel: u32) -> Pos2 { + d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("tab {panel}")).center() +} + +/// Drag a divider by `delta`, hovering it first as a pointer does. +fn drag_hit(d: &mut Driver, hit: Hit, delta: Vec2) { + let at = d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits())).center(); + d.move_to(at); + d.drag(at, at + delta); + d.settle(); +} + +fn divider(path: &[bool]) -> Hit { + Hit::Divider(Divider::Split(path.to_vec())) +} + +fn sizes(d: &Driver, panels: &[u32]) -> Vec { + panels.iter().map(|p| group(d, *p).size()).collect() +} + +fn close(a: Rect, b: Rect) -> bool { + (a.min - b.min).length() < 1.0 && (a.max - b.max).length() < 1.0 +} + +#[test] +fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_joins_it() { + let base = film(); + let timeline = group(&base, TIMELINE); + let others = [PROJECT[0], TOOLS, METERS, SOURCE[0], PROGRAM, PROPERTIES[0]]; + let inset = |zone| { + let (w, h) = (timeline.width() * 0.08, (timeline.height() - 26.0) * 0.08); + match zone { + Zone::Left => pos2(timeline.left() + w, timeline.center().y + 13.0), + Zone::Right => pos2(timeline.right() - w, timeline.center().y + 13.0), + Zone::Top => pos2(timeline.center().x, timeline.top() + 26.0 + h), + Zone::Bottom => pos2(timeline.center().x, timeline.bottom() - h), + Zone::Center => pos2(timeline.center().x, timeline.center().y + 13.0), + } + }; + for zone in [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom] { + let mut d = film(); + let before = sizes(&d, &others); + let at = inset(zone); + d.hold(tab(&d, INFO), at); + let half = zone_rect(timeline, zone).unwrap(); + assert!(d.filled(half.shrink(1.0)), "{zone:?}: the preview is that half of the group"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}"); + d.settle(); + let (info, rest) = (group(&d, INFO), group(&d, TIMELINE)); + assert!(close(info.union(rest), timeline), "{zone:?}: the two halves fill the group: {info:?} {rest:?} in {timeline:?}"); + assert!((info.area() - rest.area()).abs() < info.area() * 0.05, "{zone:?}: about halves"); + assert_eq!(sizes(&d, &others), before, "{zone:?}: no other group changes"); + } + // The middle and the tab row join the group. + let mut d = film(); + let out = d.drag(tab(&d, INFO), inset(Zone::Center)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(Zone::Center) }]); + let mut d = film(); + let out = d.drag(tab(&d, INFO), pos2(timeline.center().x, timeline.top() + 10.0)); + assert!(matches!(&out.actions[..], [Action::Move { panel: INFO, placement: Placement::Tab { .. }, .. }]), "{:?}", out.actions); + // The gap between two groups belongs to neither: the tab floats. + let mut d = film(); + let tools = group(&d, TOOLS); + let gap = pos2(tools.right() + 2.0, tools.center().y); + let out = d.drag(tab(&d, INFO), gap); + assert!(matches!(&out.actions[..], [Action::Float { panel: INFO, .. }]), "{:?}", out.actions); +} + +#[test] +fn window_edges_add_a_full_column_or_row_without_taking_the_groups_edges() { + let mut d = film(); + let project = group(&d, PROJECT[0]); + let out = d.drag(tab(&d, INFO), pos2(3.0, project.center().y)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: SOURCE[0], placement: Placement::Edge(Zone::Left) }]); + d.settle(); + let info = group(&d, INFO); + assert_eq!((info.top(), info.bottom(), info.left()), (0.0, SCREEN.y, 0.0), "a full-height column at the left"); + assert!((info.width() - SCREEN.x * 0.25).abs() < 4.0, "{info:?}"); + + for (at, zone) in [(pos2(SCREEN.x - 3.0, 300.0), Zone::Right), (pos2(700.0, 3.0), Zone::Top), (pos2(700.0, SCREEN.y - 3.0), Zone::Bottom)] { + let mut d = film(); + let out = d.drag(tab(&d, INFO), at); + assert!(matches!(&out.actions[..], [Action::Move { placement: Placement::Edge(z), .. }] if *z == zone), "{zone:?}: {:?}", out.actions); + } + // Past the window band, the group's own edge band splits that group. + let mut d = film(); + let out = d.drag(tab(&d, INFO), pos2(project.left() + 20.0, project.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: PROJECT[0], placement: Placement::Split(Zone::Left) }]); +} + +#[test] +fn groups_have_one_small_minimum_and_no_maximum() { + let mut d = film(); + let tools = group(&d, TOOLS).width(); + drag_hit(&mut d, divider(&[true, true]), vec2(400.0, 0.0)); + assert!((group(&d, TOOLS).width() - (tools + 400.0)).abs() < 1.0, "Tools grows as wide as it is dragged"); + drag_hit(&mut d, divider(&[true, true]), vec2(-2000.0, 0.0)); + assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further"); + + // A group too narrow for two halves takes a drop on its edge as a tab. + let tools = group(&d, TOOLS); + let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0)); + assert!( + matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]), + "{:?}", + out.actions + ); +} + +#[test] +fn a_divider_resizes_only_the_groups_that_share_it() { + // Timeline | Meters: Project and Tools keep their widths. + let mut d = film(); + let (project, tools, timeline, meters) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, TIMELINE), group(&d, METERS)); + drag_hit(&mut d, divider(&[true, true, true]), vec2(-100.0, 0.0)); + assert_eq!((group(&d, PROJECT[0]), group(&d, TOOLS)), (project, tools)); + assert!((group(&d, TIMELINE).width() - (timeline.width() - 100.0)).abs() < 1.0); + assert!((group(&d, METERS).width() - (meters.width() + 100.0)).abs() < 1.0); + + // Project | the rest: only Tools, on the other side, gives way; Timeline and Meters stay. + let mut d = film(); + drag_hit(&mut d, divider(&[true]), vec2(15.0, 0.0)); + assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0); + assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0); + assert!(close(group(&d, TIMELINE), timeline) && close(group(&d, METERS), meters), "{:?}", d.layout); + + // Top row: Source | Program leaves Properties alone. + let mut d = film(); + let properties = group(&d, PROPERTIES[0]); + drag_hit(&mut d, divider(&[false, false]), vec2(80.0, 0.0)); + assert_eq!(group(&d, PROPERTIES[0]), properties); +} + +#[test] +fn an_intersection_moves_both_dividers() { + let mut d = film(); + let (project, tools, source) = (group(&d, PROJECT[0]), group(&d, TOOLS), group(&d, SOURCE[0])); + let hit = Hit::Divider(Divider::Intersection(vec![], vec![true])); + let at = d.hit(&hit).unwrap_or_else(|| panic!("{:?}", d.hits())); + assert!(at.contains(pos2(project.right() + 2.0, project.top() - 2.0)), "{at:?} at Project's top right corner"); + drag_hit(&mut d, hit, vec2(15.0, -60.0)); + assert!((group(&d, PROJECT[0]).width() - (project.width() + 15.0)).abs() < 1.0); + assert!((group(&d, PROJECT[0]).height() - (project.height() + 60.0)).abs() < 1.0); + assert!((group(&d, TOOLS).width() - (tools.width() - 15.0)).abs() < 1.0); + assert!((group(&d, SOURCE[0]).height() - (source.height() - 60.0)).abs() < 1.0); + assert_eq!(group(&d, SOURCE[0]).width(), source.width()); +} + +#[test] +fn closing_a_group_gives_its_space_to_its_neighbours() { + let mut d = film(); + let (tools, meters) = (group(&d, TOOLS), group(&d, METERS)); + d.layout.apply(Action::CloseGroup { panel: TIMELINE }).unwrap(); + d.settle(); + let meters_now = group(&d, METERS); + assert_eq!(group(&d, TOOLS), tools); + assert_eq!((meters_now.right(), meters_now.top(), meters_now.bottom()), (meters.right(), meters.top(), meters.bottom())); + assert!((meters_now.left() - (tools.right() + 4.0)).abs() < 1.0, "the row has no hole: {meters_now:?}"); +} + +// The stuck cases of the FilmCraft map (map.md section 3). + +#[test] +fn info_dropped_onto_a_capped_group_lifts_the_cap() { + let content = Film { caps: HashMap::from([(METERS, 116.0)]), ..Film::default() }; + let mut d = driver(editing(), content); + let meters = group(&d, METERS); + d.drag(tab(&d, INFO), pos2(meters.center().x, meters.center().y + 13.0)); + d.settle(); + assert!(d.layout.location(&INFO).is_ok_and(|l| l.anchor == Some(METERS)), "{:?}", d.layout); + // Info is the group's shown panel. + let width = group(&d, INFO).width(); + drag_hit(&mut d, divider(&[true, true, true]), vec2(-200.0, 0.0)); + assert!((group(&d, INFO).width() - (width + 200.0)).abs() < 1.0, "the group grows past the cap of one of its panels"); + drag_hit(&mut d, divider(&[true, true, true]), vec2(150.0, 0.0)); + assert!((group(&d, INFO).width() - (width + 50.0)).abs() < 1.0, "and shrinks again"); +} + +#[test] +fn a_drop_beside_a_narrow_group_splits_that_group_only() { + let mut d = film(); + drag_hit(&mut d, divider(&[true, true]), vec2(80.0, 0.0)); + let (tools, project, timeline, meters) = (group(&d, TOOLS), group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)); + assert!(tools.width() > 120.0, "{tools:?}"); + let at = pos2(tools.right() - 8.0, tools.center().y + 13.0); + d.hold(tab(&d, INFO), at); + assert!(d.filled(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview is Tools' right half"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]); + d.settle(); + assert!(close(group(&d, TOOLS).union(group(&d, INFO)), tools)); + assert_eq!((group(&d, PROJECT[0]), group(&d, TIMELINE), group(&d, METERS)), (project, timeline, meters)); +} + +#[test] +fn every_divider_travels_after_drops() { + let mut d = film(); + let tools = group(&d, TOOLS); + d.drag(tab(&d, INFO), pos2(tools.right() - 8.0, tools.center().y + 13.0)); + d.settle(); + let meters = group(&d, METERS); + d.drag(tab(&d, PROGRAM), pos2(meters.center().x, meters.center().y + 13.0)); + d.settle(); + let timeline = group(&d, TIMELINE); + d.drag(tab(&d, PROPERTIES[0]), pos2(timeline.center().x, timeline.bottom() - 10.0)); + d.settle(); + assert_dividers_travel(&mut d); +} + +/// Each split divider moves both ways when dragged, from the layout as it is. +fn assert_dividers_travel(d: &mut Driver) { + let start = d.layout.clone(); + let paths: Vec> = d + .hits() + .into_iter() + .filter_map(|(hit, _)| match hit { + Hit::Divider(Divider::Split(path)) => Some(path), + _ => None, + }) + .collect(); + assert!(!paths.is_empty()); + for path in paths { + for step in [12.0, -24.0] { + let Some(rect) = d.hit(÷r(&path)) else { continue }; + let delta = if rect.width() < rect.height() { vec2(step, 0.0) } else { vec2(0.0, step) }; + let before = d.layout.clone(); + drag_hit(d, divider(&path), delta); + assert_ne!(d.layout, before, "divider {path:?} does not move by {step}"); + } + d.layout = start.clone(); + d.settle(); + } +} + +/// Every group at or above its minimum, and every divider with room both ways. +fn assert_sizes(d: &Driver, context: &str) { + let mut style = DockStyle::default(); + frame_style(&mut style); + let Some(root) = &d.layout.root else { return }; + let sizer = Sizer { style: &style, content: &d.content }; + let mut groups: Vec> = Vec::new(); + sizer.groups(root, d.area, &mut groups); + for g in &groups { + let room = g.rect.size() - g.limits.min; + assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min); + } + let mut nodes = vec![(root, d.area)]; + while let Some((node, rect)) = nodes.pop() { + let Some(children) = sizer.children(node, rect) else { continue }; + let (lo, hi) = sizer.range(node, rect); + assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout); + nodes.extend(children); + } +} + +/// A small deterministic generator, so a failing sequence repeats. +struct Lcg(u64); + +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + fn unit(&mut self) -> f32 { + (self.next() % 1000) as f32 / 1000.0 + } +} + +#[test] +fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() { + for seed in 1..=6u64 { + let mut rng = Lcg(seed); + let mut d = film(); + for step in 0..14 { + // Only the bodies drawn now. + d.content.bodies.clear(); + d.settle(); + let panels: Vec = d.layout.panels().into_iter().copied().collect(); + let panel = panels[rng.below(panels.len())]; + let Some(from) = d.hit(&Hit::Tab(panel)).map(|r| r.center()) else { continue }; + let target = panels[rng.below(panels.len())]; + let Some(rect) = d.content.bodies.get(&target).copied() else { continue }; + let to = match rng.below(8) { + // A window edge. + 0 => { + let edges = [ + pos2(3.0, rect.center().y), + pos2(SCREEN.x - 3.0, rect.center().y), + pos2(rect.center().x, 3.0), + pos2(rect.center().x, SCREEN.y - 3.0), + ]; + edges[rng.below(4)] + } + // The tab row. + 1 => pos2(rect.center().x, rect.top() - 13.0), + // Anywhere in the body: an edge band or the middle. + _ => rect.lerp_inside(vec2(0.02 + rng.unit() * 0.96, 0.02 + rng.unit() * 0.96)), + }; + d.drag(from, to); + d.settle(); + if d.layout.floating.iter().any(|w| w.contains(&panel)) && panel != INFO { + // Back into the tree: this test is about the docked layout. + d.layout.apply(Action::Move { panel, anchor: target, placement: Placement::Split(Zone::Center) }).ok(); + d.settle(); + } + assert_sizes(&d, &format!("seed {seed}, step {step}")); + } + assert_dividers_travel(&mut d); + } +} + +#[test] +fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { + const HINT: &str = "Hold Ctrl to undock"; + let floated = |actions: &[Action], panel: u32| match actions { + [Action::Float { panel: p, .. }] => *p == panel, + [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, + _ => false, + }; + fn texts(d: &Driver) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } + } + let mut out = Vec::new(); + d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out + } + let d = film(); + let over = group(&d, TIMELINE).center() + vec2(0.0, 13.0); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + // Without the modifier, a release over a group docks there; the preview names the modifier. + let mut d = film(); + d.hold(from, over); + assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d)); + let out = d.release(over); + assert!(!out.actions.is_empty() && !floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); + // Held, it floats, and the hint is gone. + let mut d = film(); + d.modifiers = Modifiers::COMMAND; + d.hold(from, over); + assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}"); + let out = d.release(over); + assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); + } +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 389589dd..e4a82b34 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -44,13 +44,16 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colors are not. pub floating_background: Color32, pub floating_stroke: Stroke, - /// Dock: docks, columns and stacked groups. Frame: a split tree of tab frames, with - /// Maximize, Ctrl/Cmd-drag undock, splits on all four outer edges and frame menu labels. - pub profile: Profile, /// Height of a floating window's title bar. pub title_height: f32, /// Width of the band along the layout's outer edge that splits at the root. pub edge_band: f32, + /// The outer edge band also runs along the left and right edges, where a drop adds a + /// full-height column. Off: only the top and bottom edges add a full-width row. + pub side_edge_zones: bool, + /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new + /// dock (see [`Self::new_dock_band`]). + pub new_dock_zones: bool, /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. pub column_bar_height: f32, /// [D] Height of the band around the seam between two stacked groups, in docks and @@ -69,7 +72,7 @@ pub struct DockStyle { /// [D] Column menu item that toggles Auto-Collapse Iconic Panels. pub auto_collapse_label: String, /// [D] Lay docks out right to left: `Side::Start` is the right edge. egui has no app-wide - /// layout direction, so the host sets this from its locale. Frame splits keep their sides. + /// layout direction, so the host sets this from its locale. Splits keep their sides. pub right_to_left: bool, /// [D] Accessibility label of a column bar. pub move_column_label: String, @@ -81,18 +84,23 @@ pub struct DockStyle { pub expand_column_label: String, pub move_window_label: String, pub close_window_label: String, - /// Frame profile menu labels. - pub undock_label: String, - pub undock_group_label: String, - pub close_panel_group_label: String, + /// Group menu items Close Other Panels in Group, and Maximize Frame / Restore Frame Size + /// for groups of the split tree, each shown when its flag is on. pub close_others_label: String, + pub close_others_item: bool, pub maximize_label: String, pub restore_frame_label: String, + pub maximize_item: bool, /// Shown where native windows are available (feature `native-windows`). pub new_window_label: String, - /// Frame profile: shown under the pointer while a drag offers a docking target. `{key}` - /// stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl elsewhere. + /// Shown under the pointer while a drag offers a docking target, when [`Self::float_hint`] + /// is on. `{key}` stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl + /// elsewhere. pub float_hint_label: String, + pub float_hint: bool, + /// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even + /// when the key is let go during the drag. Off: only the key held at the drop counts. + pub command_press_floats: bool, /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, /// Close Other Panels in Group) for closable panels. A host that draws its own Close items /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. @@ -100,20 +108,9 @@ pub struct DockStyle { /// Show × on the title bar of a floating window whose panels are all closable. pub close_button: bool, /// The mouse wheel over a tab strip selects the next or previous tab, one per notch. - /// `None`: on in [`Profile::Frame`], off in [`Profile::Dock`]. - pub wheel_selects_tabs: Option, - /// A middle click on a tab closes its panel when the panel is closable. `None`: on in - /// [`Profile::Frame`], off in [`Profile::Dock`]. - pub middle_click_closes_tabs: Option, -} - -/// Which arrangement a [`DockStyle`] renders and labels. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub enum Profile { - #[default] - Dock, - Frame, + pub wheel_selects_tabs: bool, + /// A middle click on a tab closes its panel when the panel is closable. + pub middle_click_closes_tabs: bool, } impl Default for DockStyle { @@ -157,9 +154,10 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, - profile: Profile::Dock, title_height: super::chrome::TITLE_HEIGHT, edge_band: 8.0, + side_edge_zones: false, + new_dock_zones: true, column_bar_height: 14.0, seam_band: 8.0, new_dock_band: 12.0, @@ -176,29 +174,22 @@ impl DockStyle { expand_column_label: "Expand column".into(), move_window_label: "Move panel window".into(), close_window_label: "Close panel window".into(), - undock_label: "Undock Panel".into(), - undock_group_label: "Undock Panel Group".into(), - close_panel_group_label: "Close Panel Group".into(), close_others_label: "Close Other Panels in Group".into(), + close_others_item: false, maximize_label: "Maximize Frame".into(), restore_frame_label: "Restore Frame Size".into(), + maximize_item: false, new_window_label: "Move Panel to New Window".into(), float_hint_label: "Hold {key} to undock".into(), + float_hint: false, + command_press_floats: false, close_menu_items: true, close_button: true, - wheel_selects_tabs: None, - middle_click_closes_tabs: None, + wheel_selects_tabs: false, + middle_click_closes_tabs: false, } } - pub(super) fn wheel_selects_tabs(&self) -> bool { - self.wheel_selects_tabs.unwrap_or(self.profile == Profile::Frame) - } - - pub(super) fn middle_click_closes_tabs(&self) -> bool { - self.middle_click_closes_tabs.unwrap_or(self.profile == Profile::Frame) - } - pub(super) fn valid(&self) -> bool { [ self.tab_height, @@ -415,7 +406,9 @@ impl

Clone for HidePanels<'_, P> { impl

Copy for HidePanels<'_, P> {} /// Preferred body bounds. Tiny viewports relax minima proportionally rather than producing -/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. +/// inverted rectangles. Maximums constrain a pane when its sibling can occupy the remainder. A +/// tab group takes the largest minimum and the largest maximum of its panels, so a panel without +/// a maximum lifts the maximum of the panels it joins. #[derive(Clone, Copy, Debug, PartialEq)] pub struct PanelLimits { pub min: Vec2, @@ -447,7 +440,7 @@ pub(super) struct Drag

{ pub(super) origin: Pos2, pub(super) source: Rect, pub(super) grab: Vec2, - /// Ctrl/Cmd held at the press of a frame-profile tab drag: the drop always floats. + /// Ctrl/Cmd held at the press, with [`DockStyle::command_press_floats`]: the drop floats. pub(super) undock: bool, } @@ -557,11 +550,6 @@ pub trait DockContent

{ fn allow_native(&self, _panel: &P) -> bool { false } - /// A lone bare panel draws no tab strip and no drag bar, like a lone protected panel. It - /// still moves by command and menu. - fn bare(&self, _panel: &P) -> bool { - false - } /// Host-owned drop resolution for targets the renderer cannot know about. Returning /// feedback consumes the gesture, including no-op targets; the renderer then emits no move. fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option { @@ -644,9 +632,6 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn allow_native(&self, panel: &P) -> bool { self.inner.allow_native(panel) } - fn bare(&self, panel: &P) -> bool { - self.inner.bare(panel) - } fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option { self.inner.resolve_drag(ctx, style, request) } @@ -826,7 +811,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { float_space: FloatSpace { contents, ..FloatSpace::new(rect) }, places, contents: content_index, - fixed_slots: Vec::new(), + dividers: Vec::new(), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index fb3c847f..87b2cb0a 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -651,8 +651,10 @@ fn split_inner_edge_remains_draggable_beside_a_scrollbar() { frame(&mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); frame(&mut layout, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); frame(&mut layout, vec![pointer_button(start + vec2(40.0, 0.0), false)]); - let Node::Split { size: SplitSize::FixedFirst(width), .. } = layout.root.unwrap() else { panic!() }; - assert!((width - 260.0).abs() < 0.001, "sidebar width after inner-edge drag: {width}"); + // A dragged divider keeps a ratio of the space, whatever mode it was saved with. + let Node::Split { size: SplitSize::Ratio(ratio), .. } = layout.root.unwrap() else { panic!() }; + // 800 points less the 4 point divider. + assert!((ratio * 796.0 - 260.0).abs() < 0.01, "sidebar width after inner-edge drag: {}", ratio * 796.0); } #[test] diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 03830123..b7f2e432 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -195,7 +195,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { }); full.textures_delta.clear(); let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); - assert_eq!(values.len(), 28, "{debug}"); + assert_eq!(values.len(), 25, "{debug}"); assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); let unique: std::collections::HashSet<_> = values.iter().collect(); assert_eq!(unique.len(), values.len()); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 2da9736b..51cfd297 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -7,16 +7,17 @@ gestures. The app owns panel contents, commands, document state and where layout Office apps that draw their own document with a few task panes beside it use [`craft_ui::panes`](ui-panes.md) instead. -## Profiles +## Arrangements -`DockStyle::profile` picks one of two arrangements. Both use the same model and actions. -An app builds its style with `DockStyle::from_ui`, `from_visuals` or `default` and then sets -fields; new fields can be added, so the struct has no literal form outside the crate. +One model serves every app. What differs between apps is the default layout and a few +`DockStyle` settings. An app builds its style with `DockStyle::from_ui`, `from_visuals` or +`default` and then sets fields; new fields can be added, so the struct has no literal form +outside the crate. -| Profile | Arrangement | Apps | -|---|---|---| -| `Profile::Dock` (default) | Docks on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | Creative apps with a canvas and panel docks | -| `Profile::Frame` | A split tree of tab frames. Frame menus offer Maximize Frame, Undock and Close Other Panels. Ctrl/Cmd + drag undocks, and a hint under the pointer says so. Drops on all four outer edges split at the root. | Apps whose whole window is frames | +| Arrangement | Layout | Style settings | Apps | +|---|---|---|---| +| Docks beside a canvas (the defaults) | `Node::Dock`s on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | `new_dock_zones` on | Creative apps with a canvas and panel docks | +| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | Sides are logical: `Side::Start` is the left edge, or the right edge when `DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its @@ -32,7 +33,7 @@ their sides. | Node | Holds | |---|---| | `Tabs` | A tab group: panels and the active index. | -| `Split` | Two children, horizontal or vertical, with a ratio or a fixed first or second size. | +| `Split` | Two children, horizontal or vertical, with a ratio. A fixed first or second size from a saved layout is drawn at that size until the divider moves; a moved divider stores a ratio. | | `Stack` | Accordion entries, each open or closed, with an optional height. | | `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | @@ -68,11 +69,9 @@ layout.apply(Action::Move { `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or center zone onto what the anchor's container takes: a split in the frame tree, a new column on that side or a new group above or below in a dock, a group above or below in a floating window. -- A side split zone puts what moves in a new half-size split of the anchor's frame. A frame - that holds the fixed size of its parent split along the same axis, such as a tool strip, keeps - that size: on its outer side the new split wraps the parent, on its inner side it halves the - parent's other child. The new split has its own divider, and the drop preview shows the half - the new frame takes. +- A side split zone puts what moves in a new half-size split of the anchor's frame, and no + other frame changes size. The new split has its own divider, and the drop preview shows the + half the new frame takes. - Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move a floating window to its own OS window (`SetNative`, feature `native-windows`). @@ -112,19 +111,19 @@ its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, permissions, size limits, preferred floating size, panel menu and group header. The group header sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that -draws its own tabs can size them to the room left. `bare` hides the tab strip of a lone panel. An icon is an image (`icon`), or drawn in code by `paint_icon`, which +draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory survives. `DockOutput` also has: - `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any - pointer button in a panel body (bare and protected panels included) or in the group header, + pointer button in a panel body (protected panels included) or in the group header, which focuses the group's shown panel. Commands, `raise_panel` and workspace resets never set it; a host that tracks the last focused panel clears it when a reset or command hides, closes or moves that panel. -- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and - `DockEvent::ToggleAutoCollapse` (Dock), and `DockEvent::FlyoutChanged { panel }` (Dock): the +- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (`maximize_item`) and + `DockEvent::ToggleAutoCollapse` and `DockEvent::FlyoutChanged { panel }` (docks): the flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an icon, Escape, a press outside, a raised panel, `open_flyout` or `close_flyout`. - `error`: a rejected gesture. @@ -191,21 +190,33 @@ A flyout fits its content the same way, without the title bar. 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on the title bar's buttons. A resize keeps the opposite edges in place and stays within the size limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. -- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. A - side zone is a quarter of the frame deep, at least 32 pt and at most half the frame, so a narrow - tool strip keeps both side targets. A drop in the gap between two frames splits beside the - nearer one. +- Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The + side zones are trapezoids along the four edges, like a picture frame: a quarter of the frame + deep, at least 32 pt and at most half the frame. A split that would leave either half without + `DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target. +- The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole + window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of + the window and is offered only when every frame keeps its minimum. Past the band, a frame's own + side zones apply. +- A divider resizes only the frames that touch it; every other frame keeps its size. It stops + when a frame touching it reaches its minimum. Where a divider ends on another divider, a + four-way handle (`Divider::Intersection`) moves both. +- Every frame has the same minimum, `DockStyle::min_pane` (plus its tab strip), unless its panels + ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks + for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it + joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. -- In the Frame profile, Ctrl (⌘ on macOS) held while dragging a tab, a drag bar or a grip that - calls `begin_drag` floats the drop wherever it lands. While a drag offers a docking target, a - hint under the pointer names the modifier: `float_hint_label`, where `{key}` becomes ⌘ or Ctrl. +- Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`, + the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the + pointer names the modifier while a drag offers a docking target: `float_hint_label`, where + `{key}` becomes ⌘ or Ctrl. - Double-click a tab or drag bar to minimize a group to its strip. - Right-click a tab for its menu, or a drag bar for the group menu; Enter or Space on a focused drag bar opens it too. A left click on the drag bar only focuses it. -- In the Frame profile, the mouse wheel over a tab strip selects the next or previous tab, and a - middle click on a closable panel's tab closes it. `DockStyle::wheel_selects_tabs` and - `middle_click_closes_tabs` turn either on or off in any profile. +- With `DockStyle::wheel_selects_tabs`, the mouse wheel over a tab strip selects the next or + previous tab. With `middle_click_closes_tabs`, a middle click on a closable panel's tab closes + it. - A column's »/« button collapses it to icons, one icon per panel with a separator between groups. Widen an iconic column to show panel names. Click an icon to open its group as a flyout on the canvas side. Drag an icon to move its panel, or an icon group's top edge (the separator line above it) to move the group. From 5abc37e45446d5cad2175d6464fad94e6526e91c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:08:18 +0000 Subject: [PATCH 083/160] docking: a release over no target cancels; the title bar only moves a window Two DockStyle settings, on by default so the dock-and-canvas arrangement keeps its behaviour. Without float_on_empty_drop, a tab or group released over no target inside the app window (the gap between groups, a menu bar, a status bar) cancels the drag; Ctrl/Cmd or a release outside the window floats it. Without title_bar_docks, dragging a floating window's title bar only moves the window. --- crates/ui/src/docking/floating_tests.rs | 2 + crates/ui/src/docking/render/floating.rs | 1 + crates/ui/src/docking/render/preview.rs | 27 ++++++---- crates/ui/src/docking/split_tests.rs | 67 +++++++++++++++++++++++- crates/ui/src/docking/surface.rs | 9 ++++ docs/ui-docking.md | 10 +++- 6 files changed, 103 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index c05271c5..021b8570 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -87,6 +87,8 @@ pub(super) fn frame_style(style: &mut DockStyle) { style.close_others_item = true; style.maximize_item = true; style.command_press_floats = true; + style.float_on_empty_drop = false; + style.title_bar_docks = false; style.float_hint = true; style.wheel_selects_tabs = true; style.middle_click_closes_tabs = true; diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index f8bc435a..a321f3aa 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -323,6 +323,7 @@ impl Renderer<'_, P> { /// group target docks or merges every group in it, else hosts get the release in /// `group_drop`. pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + let float_only = float_only || !self.style.title_bar_docks; if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { self.output.actions.extend(action); return; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 9de57d97..a767750a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -60,14 +60,18 @@ impl Renderer<'_, P> { } return; } - // Leaving the window ends a group drag where it is, with no drop. - if !released || !(has_pointer || touch_ended) { + // Leaving the window ends a group drag where it is, with no drop, unless only a + // release outside the window floats. + let gone = !has_pointer && !touch_ended; + let floats_outside = !self.style.float_on_empty_drop && drag.gesture != GroupGesture::Window; + if !(released && !gone) && !(gone && floats_outside) { return; } let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) else { return }; + let outside = gone || !ctx.content_rect().contains(pointer); match drag.gesture { GroupGesture::Window => self.drop_window(&drag, pointer, float_only), - GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only), + GroupGesture::Group | GroupGesture::Column => self.drop_group(&drag, pointer, float_only || outside), } } else if let Some(pointer) = pointer.filter(|p| !self.blockers.iter().any(|r| r.contains(*p))) { self.output.group_drag = Some(drag.panel.clone()); @@ -164,6 +168,9 @@ impl Renderer<'_, P> { if proposal.is_some() && !floating { return; } + if !float_only && !self.style.float_on_empty_drop { + return; + } // No target: hosts that still apply their own policy for a whole-group release get it. self.output.group_drop = Some((drag.panel.clone(), pointer)); let rect = self.group_ghost(drag, pointer); @@ -196,7 +203,7 @@ impl Renderer<'_, P> { x += advance; } } - if float_only { + if float_only || (drag.gesture == GroupGesture::Window && !self.style.title_bar_docks) { return; } if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { @@ -246,8 +253,10 @@ impl Renderer<'_, P> { return; } let float_only = float_only(ctx) || drag.undock; - let floatable = self.permissions(&drag.panel).floatable - && (float_only || gone || !drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let outside = gone || !ctx.content_rect().contains(pointer); + let empty_drop = self.style.float_on_empty_drop + && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let floatable = self.permissions(&drag.panel).floatable && (float_only || outside || empty_drop); let floating_rect = floatable.then(|| self.tab_float_rect(&drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { if released { @@ -255,7 +264,7 @@ impl Renderer<'_, P> { } return; } - let dock = if float_only || gone { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; + let dock = if float_only || outside { None } else { self.dock_tab_proposal(&drag.panel, pointer) }; if let Some((action, line)) = dock { if released { cancel_drag::

(ctx, self.id); @@ -282,10 +291,10 @@ impl Renderer<'_, P> { }; if released { cancel_drag::

(ctx, self.id); - if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !gone) { + if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !outside) { return; } - let action = if let Some(proposal) = proposal.filter(|_| !gone) { + let action = if let Some(proposal) = proposal.filter(|_| !outside) { Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) } else if let Some(rect) = floating_rect { Some(Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }) diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 89a94dc9..3354d9f0 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -149,12 +149,12 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let mut d = film(); let out = d.drag(tab(&d, INFO), pos2(timeline.center().x, timeline.top() + 10.0)); assert!(matches!(&out.actions[..], [Action::Move { panel: INFO, placement: Placement::Tab { .. }, .. }]), "{:?}", out.actions); - // The gap between two groups belongs to neither: the tab floats. + // The gap between two groups belongs to neither: the drag cancels. let mut d = film(); let tools = group(&d, TOOLS); let gap = pos2(tools.right() + 2.0, tools.center().y); let out = d.drag(tab(&d, INFO), gap); - assert!(matches!(&out.actions[..], [Action::Float { panel: INFO, .. }]), "{:?}", out.actions); + assert!(out.actions.is_empty(), "{:?}", out.actions); } #[test] @@ -443,3 +443,66 @@ fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); } } + +/// The Editing workspace under a 24 pt menu bar and above a 20 pt status bar. +fn film_in_window() -> Driver { + let mut d = DockDriver::new(editing(), Film::default()).with_style(frame_style); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = Rect::from_min_max(pos2(0.0, 24.0), pos2(SCREEN.x, SCREEN.y - 20.0)); + d.settle(); + d +} + +#[test] +fn a_release_over_no_target_inside_the_window_cancels_the_drag() { + let d = film_in_window(); + let (source, program) = (group(&d, SOURCE[0]), group(&d, PROGRAM)); + let gap = pos2((source.right() + program.left()) * 0.5, source.center().y); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + for to in [pos2(600.0, 10.0), pos2(600.0, SCREEN.y - 8.0), gap] { + let mut d = film_in_window(); + let before = d.layout.clone(); + let out = d.drag(from, to); + d.settle(); + assert!(out.actions.is_empty(), "{from:?} to {to:?}: {:?}", out.actions); + assert_eq!(d.layout, before, "{from:?} to {to:?}"); + } + // Ctrl/Cmd held floats it there. + let mut d = film_in_window(); + d.modifiers = Modifiers::COMMAND; + d.drag(from, pos2(600.0, 10.0)); + d.settle(); + assert!(d.layout.floating.iter().any(|w| w.contains(&PROJECT[0])), "{from:?}: {:?}", d.layout.floating); + } +} + +#[test] +fn a_release_outside_the_window_floats() { + let d = film_in_window(); + let from_tab = tab(&d, PROJECT[0]); + let from_bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + for from in [from_tab, from_bar] { + let mut d = film_in_window(); + d.hold(from, pos2(600.0, 10.0)); + d.leave(); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&PROJECT[0])); + assert!(window.is_some(), "{from:?}: {:?}", d.layout); + // A tab takes one panel; a drag bar takes the group. + assert_eq!(window.unwrap().contains(&PROJECT[1]), from == from_bar, "{from:?}"); + } +} + +#[test] +fn the_title_bar_moves_a_floating_window_without_docking() { + let mut d = film(); + let title = d.hit(&Hit::Title(INFO)).unwrap().center(); + let timeline = group(&d, TIMELINE); + d.drag(title, timeline.center()); + d.settle(); + let window = d.layout.floating.iter().find(|w| w.contains(&INFO)).expect("still floating"); + assert!(window.rect[0] < 1000.0 && window.rect[1] > 400.0, "moved towards the drop: {:?}", window.rect); + assert_eq!(d.layout.root, editing().root); +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index e4a82b34..1595ef3e 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -101,6 +101,13 @@ pub struct DockStyle { /// Ctrl/Cmd held when a tab drag of a split-tree group starts makes its drop float, even /// when the key is let go during the drag. Off: only the key held at the drop counts. pub command_press_floats: bool, + /// A drag released over no drop target inside the window floats what it carries, such as + /// a panel dropped on a protected canvas. Off: that release cancels the drag; only + /// Ctrl/Cmd or a release outside the window floats. + pub float_on_empty_drop: bool, + /// Dragging a floating window's title bar onto a drop target docks the whole window there. + /// Off: the title bar only moves the window; tabs and drag bars dock. + pub title_bar_docks: bool, /// Show the built-in Close items in tab and group menus (Close Panel, Close Tab Group, /// Close Other Panels in Group) for closable panels. A host that draws its own Close items /// turns this off and keeps [`Permissions::closable`], so its close actions still apply. @@ -183,6 +190,8 @@ impl DockStyle { float_hint_label: "Hold {key} to undock".into(), float_hint: false, command_press_floats: false, + float_on_empty_drop: true, + title_bar_docks: true, close_menu_items: true, close_button: true, wheel_selects_tabs: false, diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 51cfd297..f92d1aa9 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -17,7 +17,7 @@ outside the crate. | Arrangement | Layout | Style settings | Apps | |---|---|---|---| | Docks beside a canvas (the defaults) | `Node::Dock`s on the window's start and end edges. A dock has columns; a column has stacked groups and can collapse to icons. Frames (tab groups and splits) fill the space between the docks. | `new_dock_zones` on | Creative apps with a canvas and panel docks | -| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | +| A split tree of tab groups | `Split`s and `Tabs` only. | `side_edge_zones`, `maximize_item`, `close_others_item`, `command_press_floats`, `float_hint`, `wheel_selects_tabs`, `middle_click_closes_tabs` on, `new_dock_zones`, `float_on_empty_drop` and `title_bar_docks` off; menu labels such as "Undock Panel" set on `float_label`, `float_group_label` and `close_group_label` | Apps whose whole window is frames | Sides are logical: `Side::Start` is the left edge, or the right edge when `DockStyle::right_to_left` is set. egui has no app-wide direction, so the app sets it from its @@ -185,7 +185,9 @@ A flyout fits its content the same way, without the title bar. ## Gestures - A tab drags one panel. A group's drag bar (the space beside its tabs) or its top border drags - the whole group. A column bar drags a column. A floating window's title bar drags the window. + the whole group. A column bar drags a column. A floating window's title bar drags the window; + with `title_bar_docks` a release over a target docks the whole window there, else it only + moves. - A movable floating window resizes from every edge and corner. The top edge and corners are a 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on the title bar's buttons. A resize keeps the opposite edges in place and stays within the size @@ -207,6 +209,10 @@ A flyout fits its content the same way, without the title bar. joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. Escape or losing window focus cancels a drag. +- With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such + as a panel dropped on a protected canvas. Without it, that release cancels the drag and + nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release + outside the app window floats in both cases. - Ctrl (⌘ on macOS) held at the drop floats it wherever it lands. With `command_press_floats`, the key held when a tab drag of a frame starts is enough. With `float_hint`, a hint under the pointer names the modifier while a drag offers a docking target: `float_hint_label`, where From 809bf909365fb213a06706531b4548418a452ad1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:19:09 +0000 Subject: [PATCH 084/160] docking: a floating window holds a tree like the docked layout Floating::root replaces the stack of groups. A floating window takes splits in any direction, tab groups and accordions; its groups split, join and resize like frames, with the Sizer, four-way handles (Divider::WindowSplit, Divider::WindowIntersection, Action::ResizeWindowSplit) and the room check. Tabs and drag bars inside it show the targets of every window and dock; Undock Panel Group floats one group of a window that has several. Floating::minimized replaces per-group minimizing in a window. Saved layouts keep loading: a stored stack of groups becomes a vertical split sized by the groups' heights. Placement::Group on a floating anchor splits its group above or below. A window moved onto a dock keeps its groups as stacked groups. --- crates/ui/src/docking/command.rs | 7 +- crates/ui/src/docking/command_tests.rs | 5 +- crates/ui/src/docking/dock_model.rs | 352 ++++++++++++++++------- crates/ui/src/docking/dock_tests.rs | 2 +- crates/ui/src/docking/floating_tests.rs | 86 ++++-- crates/ui/src/docking/group_tests.rs | 18 +- crates/ui/src/docking/hits.rs | 34 ++- crates/ui/src/docking/iconic_tests.rs | 3 +- crates/ui/src/docking/model.rs | 313 ++++++++++++-------- crates/ui/src/docking/model_tests.rs | 58 ++-- crates/ui/src/docking/render/dock.rs | 4 +- crates/ui/src/docking/render/floating.rs | 247 +++++----------- crates/ui/src/docking/render/frame.rs | 40 +-- crates/ui/src/docking/render/group.rs | 13 +- crates/ui/src/docking/render/mod.rs | 34 ++- crates/ui/src/docking/render/native.rs | 8 +- crates/ui/src/docking/render/preview.rs | 11 +- crates/ui/src/docking/split_tests.rs | 104 +++++++ crates/ui/src/docking/surface.rs | 1 + crates/ui/src/docking/tests.rs | 28 +- crates/ui/src/docking/trial_tests.rs | 8 +- docs/ui-docking.md | 26 +- 22 files changed, 884 insertions(+), 518 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 2dd7f67a..132d8539 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -369,7 +369,7 @@ impl DockCommand

{ let any = given.iter().any(Option::is_some); let unit = Unit::of(*group, *column); let lone = layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1); - let whole = layout.floating.iter().any(|w| w.contains(panel) && w.groups.len() == 1); + let whole = layout.floating.iter().any(|w| w.contains(panel) && matches!(w.root, super::Node::Tabs { .. } | super::Node::Stack { .. })); // The rect a drag would float the unit at, inside the area. let placed = || { let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width); @@ -450,6 +450,7 @@ impl DockCommand

{ Some(super::Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && !e.open), _ => false, }, + Place::Floating { window, .. } => layout.floating.get(*window).is_some_and(|w| w.minimized), place => layout.group(place).is_some_and(|g| g.minimized), }; one(Action::SetMinimized { panel: panel.clone(), minimized: minimized.unwrap_or(!current) }) @@ -563,14 +564,14 @@ fn last_column(layout: &Layout

, side: Side) -> Option(layout: &Layout

, panel: &P) -> Option { - layout.place(panel).and_then(|place| layout.group(&place)).map(|g| g.panels.len()) + layout.members(panel, super::dock_model::Unit::Group).ok().map(|members| members.len()) } /// The panels that move with `panel`. fn members(layout: &Layout

, panel: &P, unit: Unit) -> Vec

{ match unit { Unit::Panel => vec![panel.clone()], - Unit::Group => layout.place(panel).and_then(|place| layout.group(&place)).map(|g| g.panels.clone()).unwrap_or_default(), + Unit::Group => layout.members(panel, super::dock_model::Unit::Group).unwrap_or_default(), Unit::Column => match layout.place(panel) { Some(Place::Dock { path, column, .. }) => match layout.root.as_ref().and_then(|root| super::dock_model::node_at(root, &path)) { Some(super::Node::Dock { columns, .. }) => { diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 9397e62a..913cb35d 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -306,8 +306,9 @@ fn side_zones_map_to_the_placement_the_anchor_container_takes_in_either_directio assert_eq!(placed("right", "layers", true), Ok(Placement::Column { after: false })); assert_eq!(placed("left", "layers", true), Ok(Placement::Column { after: true })); assert_eq!(placed("top", "layers", false), Ok(Placement::Group { below: false, height: None })); - assert_eq!(placed("bottom", "color", false), Ok(Placement::Group { below: true, height: None })); - assert_eq!(placed("left", "color", false), Err(DockError::InvalidPlacement)); + // A floating window is a tree like the docked layout's. + assert_eq!(placed("bottom", "color", false), Ok(Placement::Split(Zone::Bottom))); + assert_eq!(placed("left", "color", false), Ok(Placement::Split(Zone::Left))); assert_eq!(placed("left", "canvas", true), Ok(Placement::Split(Zone::Left))); let (workspace, _) = applied(json!({"command": "window.panel.move", "panel": "history", "anchor": "layers", "zone": "right"})); diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index a42bb56f..9226bb4f 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -1,4 +1,4 @@ -//! Docks, columns, groups and floating stacks: their types and the structural edits behind +//! Docks, columns, groups and floating windows: their types and the structural edits behind //! group, column and window moves. //! //! A group, column or floating window is named by a panel it contains. Edits run on a proposed @@ -6,7 +6,7 @@ use std::{collections::HashMap, hash::Hash}; -use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, Node, Placement, Zone, valid_height, valid_rect, valid_width}; +use super::model::{DockError, Floating, Layout, MAX_DEPTH, MAX_NODES, MAX_PANELS, Node, Placement, Zone, valid_height, valid_rect, valid_width}; use crate::layout::{SplitAxis, SplitSize}; /// Width of a new column when nothing better is known. @@ -25,7 +25,7 @@ pub enum Side { End, } -/// Tabs that move, minimize and size together, in a dock column or a floating stack. +/// Tabs that move, minimize and size together in a dock column. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(bound(deserialize = "P: serde::Deserialize<'de>")))] @@ -92,9 +92,8 @@ impl Placement

{ /// /// | Anchor in | `Center` | `Top`, `Bottom` | `Left`, `Right` | /// |---|---|---|---| - /// | the split tree | [`Placement::Split`] | `Split` | `Split` | + /// | the split tree or a floating window | [`Placement::Split`] | `Split` | `Split` | /// | a dock column | `Split(Center)` (join as tabs) | [`Placement::Group`] above or below | [`Placement::Column`] on that side | - /// | a floating window | `Split(Center)` | `Group` above or below | [`DockError::InvalidPlacement`] | /// /// A dock's `Column { after }` counts from its window edge, which depends on the dock's /// side and `right_to_left` (see [`Side`]); this picks `after` so the new column lands on @@ -102,9 +101,8 @@ impl Placement

{ pub fn for_zone(layout: &Layout

, anchor: &P, zone: Zone, right_to_left: bool) -> Result { let place = layout.place(anchor).ok_or(DockError::MissingTarget)?; Ok(match (place, zone) { - (Place::Node(_), zone) | (_, zone @ Zone::Center) => Placement::Split(zone), - (Place::Dock { .. } | Place::Floating { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None }, - (Place::Floating { .. }, _) => return Err(DockError::InvalidPlacement), + (Place::Node(_) | Place::Floating { .. }, zone) | (_, zone @ Zone::Center) => Placement::Split(zone), + (Place::Dock { .. }, Zone::Top | Zone::Bottom) => Placement::Group { below: zone == Zone::Bottom, height: None }, (Place::Dock { path, .. }, _) => { let side = match layout.root.as_ref().and_then(|root| node_at(root, &path)) { Some(Node::Dock { side, .. }) => *side, @@ -128,7 +126,7 @@ pub enum PanelState { Front, } -/// The group a closed panel was in, when that group was in a dock or a floating stack. +/// The group a closed panel was in, when that group was in a dock. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct GroupLocation { @@ -157,12 +155,25 @@ pub(super) enum Place { column: usize, group: usize, }, + /// A `Tabs` or `Stack` node of a floating window's tree. Floating { window: usize, - group: usize, + path: Vec, }, } +impl Place { + /// The tree (`None` for the docked layout, else a floating window) and the path of a + /// `Tabs` or `Stack` node. + pub(super) fn tree_path(&self) -> Option<(Option, &[bool])> { + match self { + Place::Node(path) => Some((None, path)), + Place::Floating { window, path } => Some((Some(*window), path)), + Place::Dock { .. } => None, + } + } +} + /// Which container a whole-container action names. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Unit { @@ -174,6 +185,10 @@ pub(super) enum Unit { /// Groups in transit, with the size and state of the container they came from. pub(super) struct Moving

{ pub groups: Vec>, + /// A floating window's whole tree, whose leaves are `groups`. A split or a new window + /// takes the tree; docks take the groups. + pub node: Option>, + pub minimized: bool, pub width: Option, pub iconic: bool, pub iconic_width: Option, @@ -183,7 +198,7 @@ pub(super) struct Moving

{ impl

Moving

{ pub fn group(group: Group

) -> Self { - Self { groups: vec![group], width: None, iconic: false, iconic_width: None, rect: None, native: None } + Self { groups: vec![group], node: None, minimized: false, width: None, iconic: false, iconic_width: None, rect: None, native: None } } fn into_column(self, width: f32) -> Column

{ @@ -207,6 +222,93 @@ pub(super) fn node_at_mut<'a, P>(mut node: &'a mut Node

, path: &[bool]) -> Op Some(node) } +/// Every `Tabs`, `Stack` and `Dock` node of a tree with its path, first to last. Stops at +/// [`MAX_NODES`] for an invalid tree. +pub(super) fn leaves

(root: &Node

) -> Vec<(&Node

, Vec)> { + let mut out = Vec::new(); + let mut visit = vec![(root, Vec::new())]; + let mut visited = 0usize; + while let Some((node, path)) = visit.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + break; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + leaf => out.push((leaf, path)), + } + } + out +} + +/// The panels of a tree in order, without recursion; at most [`MAX_PANELS`]. +pub(super) fn tree_panels

(root: &Node

) -> Vec<&P> { + let mut panels = Vec::new(); + for (leaf, _) in leaves(root) { + let room = MAX_PANELS.saturating_sub(panels.len()); + match leaf { + Node::Tabs { panels: group, .. } => panels.extend(group.iter().take(room)), + Node::Stack { entries } => panels.extend(entries.iter().map(|e| &e.panel).take(room)), + Node::Dock { columns, .. } => panels.extend(columns.iter().flat_map(|c| &c.groups).flat_map(|g| &g.panels).take(room)), + Node::Split { .. } => {} + } + } + panels +} + +/// The shown panel of a tree's first group. +pub(super) fn first_active

(root: &Node

) -> Option<&P> { + match leaves(root).into_iter().next()?.0 { + Node::Tabs { panels, active } => panels.get(*active), + Node::Stack { entries } => entries.first().map(|e| &e.panel), + Node::Split { .. } | Node::Dock { .. } => None, + } +} + +/// The groups of a tree, first to last: what a dock column takes from a floating window. +pub(super) fn tree_groups(root: &Node

) -> Vec> { + let mut groups = Vec::new(); + for (leaf, _) in leaves(root) { + match leaf { + Node::Tabs { panels, active } => groups.push(Group::new(panels.clone(), *active)), + Node::Stack { entries } => groups.push(Group::new(entries.iter().map(|e| e.panel.clone()).collect(), 0)), + Node::Dock { columns, .. } => groups.extend(columns.iter().flat_map(|c| c.groups.iter().cloned())), + Node::Split { .. } => {} + } + } + groups +} + +/// Groups stacked top to bottom as a tree: each group over a vertical split of the ones below +/// it, sized by their heights, with an equal share where a group has none. +pub(super) fn stack_tree

(groups: Vec>) -> Option> { + let height = |g: &Group

| g.height.filter(|h| h.is_finite() && *h > 0.0); + let known: Vec = groups.iter().filter_map(height).collect(); + let share = if known.is_empty() { 1.0 } else { known.iter().sum::() / known.len() as f32 }; + let weights: Vec = groups.iter().map(|g| height(g).unwrap_or(share)).collect(); + let mut node = None; + let mut below = 0.0; + for (group, weight) in groups.into_iter().zip(weights).rev() { + let tabs = Node::Tabs { panels: group.panels, active: group.active }; + node = Some(match node { + None => tabs, + Some(rest) => { + let ratio = (weight / (weight + below)).clamp(0.0, 1.0); + Node::Split { axis: SplitAxis::Vertical, size: SplitSize::Ratio(ratio), first: Box::new(tabs), second: Box::new(rest) } + } + }); + below += weight; + } + node +} + /// Put `new` on the `zone` side of the frame at `path`: the frame splits into two halves and /// every other frame keeps its size. pub(super) fn split_frame

(root: &mut Node

, path: &[bool], new: Node

, zone: Zone) -> bool { @@ -230,28 +332,14 @@ fn splice

(panels: &mut Vec

, active: &mut usize, index: usize, new: Vec

, impl Layout

{ pub(super) fn place(&self, panel: &P) -> Option { for (w, window) in self.floating.iter().enumerate() { - if let Some(g) = window.groups.iter().position(|group| group.panels.contains(panel)) { - return Some(Place::Floating { window: w, group: g }); + for (leaf, path) in leaves(&window.root) { + if leaf_holds(leaf, panel) { + return Some(Place::Floating { window: w, path }); + } } } - let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); - let mut visited = 0usize; - while let Some((node, path)) = visit.pop() { - visited = visited.saturating_add(1); - if visited > MAX_NODES { - return None; - } - match node { - Node::Split { first, second, .. } => { - let mut second_path = path.clone(); - second_path.push(true); - let mut first_path = path; - first_path.push(false); - visit.push((second, second_path)); - visit.push((first, first_path)); - } - Node::Tabs { panels, .. } if panels.contains(panel) => return Some(Place::Node(path)), - Node::Stack { entries } if entries.iter().any(|e| &e.panel == panel) => return Some(Place::Node(path)), + for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() { + match leaf { Node::Dock { columns, .. } => { for (c, column) in columns.iter().enumerate() { if let Some(g) = column.groups.iter().position(|group| group.panels.contains(panel)) { @@ -259,7 +347,8 @@ impl Layout

{ } } } - Node::Tabs { .. } | Node::Stack { .. } => {} + leaf if leaf_holds(leaf, panel) => return Some(Place::Node(path)), + _ => {} } } None @@ -268,54 +357,74 @@ impl Layout

{ /// [`Self::place`] of every panel, from one traversal: the renderer looks up many panels a /// frame. pub(super) fn places(&self) -> HashMap { - let mut places = HashMap::new(); - for (w, window) in self.floating.iter().enumerate() { - for (g, group) in window.groups.iter().enumerate() { - for panel in &group.panels { - places.entry(panel.clone()).or_insert(Place::Floating { window: w, group: g }); - } - } - } - let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); - let mut visited = 0usize; - while let Some((node, path)) = visit.pop() { - visited = visited.saturating_add(1); - if visited > MAX_NODES { - break; - } - match node { - Node::Split { first, second, .. } => { - let mut second_path = path.clone(); - second_path.push(true); - let mut first_path = path; - first_path.push(false); - visit.push((second, second_path)); - visit.push((first, first_path)); - } + fn note(places: &mut HashMap, leaf: &Node

, place: &dyn Fn() -> Place) { + match leaf { Node::Tabs { panels, .. } => { for panel in panels { - places.entry(panel.clone()).or_insert_with(|| Place::Node(path.clone())); + places.entry(panel.clone()).or_insert_with(place); } } Node::Stack { entries } => { for entry in entries { - places.entry(entry.panel.clone()).or_insert_with(|| Place::Node(path.clone())); + places.entry(entry.panel.clone()).or_insert_with(place); } } - Node::Dock { columns, .. } => { - for (c, column) in columns.iter().enumerate() { - for (g, group) in column.groups.iter().enumerate() { - for panel in &group.panels { - places.entry(panel.clone()).or_insert_with(|| Place::Dock { path: path.clone(), column: c, group: g }); - } + _ => {} + } + } + let mut places = HashMap::new(); + for (w, window) in self.floating.iter().enumerate() { + for (leaf, path) in leaves(&window.root) { + note(&mut places, leaf, &|| Place::Floating { window: w, path: path.clone() }); + } + } + for (leaf, path) in self.root.as_ref().map(leaves).unwrap_or_default() { + if let Node::Dock { columns, .. } = leaf { + for (c, column) in columns.iter().enumerate() { + for (g, group) in column.groups.iter().enumerate() { + for panel in &group.panels { + places.entry(panel.clone()).or_insert_with(|| Place::Dock { path: path.clone(), column: c, group: g }); } } } + } else { + note(&mut places, leaf, &|| Place::Node(path.clone())); } } places } + /// The docked layout (`None`) or a floating window's tree. + pub(super) fn tree(&self, window: Option) -> Option<&Node

> { + match window { + None => self.root.as_ref(), + Some(window) => self.floating.get(window).map(|w| &w.root), + } + } + + pub(super) fn tree_mut(&mut self, window: Option) -> Option<&mut Node

> { + match window { + None => self.root.as_mut(), + Some(window) => self.floating.get_mut(window).map(|w| &mut w.root), + } + } + + /// The docked layout's tree, then each floating window's. + pub(super) fn trees_mut(&mut self) -> impl Iterator> { + self.root.iter_mut().chain(self.floating.iter_mut().map(|w| &mut w.root)) + } + + /// The `Tabs` or `Stack` node at a tree place. + pub(super) fn node_of(&self, place: &Place) -> Option<&Node

> { + let (window, path) = place.tree_path()?; + node_at(self.tree(window)?, path) + } + + fn node_of_mut(&mut self, place: &Place) -> Option<&mut Node

> { + let (window, path) = place.tree_path()?; + node_at_mut(self.tree_mut(window)?, path) + } + pub(super) fn dock_path(&self, side: Side) -> Option> { let mut visit: Vec<(&Node

, Vec)> = self.root.iter().map(|node| (node, Vec::new())).collect(); let mut visited = 0usize; @@ -379,27 +488,37 @@ impl Layout

{ self.columns_mut(path)?.get_mut(*column) } - /// The dock or floating group at `place`; `None` for a `Tabs` or `Stack` node. + /// The dock group at `place`; `None` for a `Tabs` or `Stack` node. pub(super) fn group(&self, place: &Place) -> Option<&Group

> { match place { - Place::Node(_) => None, Place::Dock { group, .. } => self.column(place)?.groups.get(*group), - Place::Floating { window, group } => self.floating.get(*window)?.groups.get(*group), + Place::Node(_) | Place::Floating { .. } => None, } } pub(super) fn group_mut(&mut self, place: &Place) -> Option<&mut Group

> { match place { - Place::Node(_) => None, Place::Dock { group, .. } => self.column_mut(place)?.groups.get_mut(*group), - Place::Floating { window, group } => self.floating.get_mut(*window)?.groups.get_mut(*group), + Place::Node(_) | Place::Floating { .. } => None, } } - /// Remove the node at `path`; its parent split collapses into the sibling. + /// Remove the node at `path` of the docked layout; its parent split collapses into the + /// sibling. pub(super) fn take_node(&mut self, path: &[bool]) -> Option> { - let Some((second_child, parent_path)) = path.split_last() else { return self.root.take() }; - let parent = self.root.as_mut().and_then(|root| node_at_mut(root, parent_path))?; + self.take_node_in(None, path) + } + + /// [`Self::take_node`] in the docked layout (`None`) or a floating window, which closes + /// when its root goes. + fn take_node_in(&mut self, window: Option, path: &[bool]) -> Option> { + let Some((second_child, parent_path)) = path.split_last() else { + return match window { + None => self.root.take(), + Some(window) => (window < self.floating.len()).then(|| self.floating.remove(window).root), + }; + }; + let parent = self.tree_mut(window).and_then(|root| node_at_mut(root, parent_path))?; if !matches!(parent, Node::Split { .. }) { return None; } @@ -421,7 +540,7 @@ impl Layout

{ pub(super) fn members(&self, panel: &P, unit: Unit) -> Result, DockError> { let place = self.place(panel).ok_or(DockError::MissingPanel)?; match (unit, &place) { - (Unit::Group, Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, path)) { + (Unit::Group, Place::Node(_) | Place::Floating { .. }) => match self.node_of(&place) { Some(Node::Tabs { panels, .. }) => Ok(panels.clone()), Some(Node::Stack { .. }) => Ok(vec![panel.clone()]), _ => Err(DockError::MissingPanel), @@ -450,20 +569,32 @@ impl Layout

{ fn take_group(&mut self, panel: &P) -> Result, DockError> { let place = self.place(panel).ok_or(DockError::MissingPanel)?; match place { - Place::Node(path) => { - if let Some(Node::Stack { entries }) = self.root.as_mut().and_then(|root| node_at_mut(root, &path)) + Place::Node(_) | Place::Floating { .. } => { + let (window, path) = place.tree_path().map(|(w, p)| (w, p.to_vec())).ok_or(DockError::MissingPanel)?; + // A group leaving a floating window keeps its width; the window's only group + // takes the window's rect along. + let source = window.and_then(|w| self.floating.get(w)).map(|w| (w.rect, w.native, w.minimized)); + let whole = |moving: Moving

| match source { + Some((rect, native, minimized)) if path.is_empty() => { + Moving { width: rect.get(2).copied(), rect: Some(rect), native, minimized, ..moving } + } + Some((rect, ..)) => Moving { width: rect.get(2).copied(), ..moving }, + None => moving, + }; + if let Some(Node::Stack { entries }) = self.node_of_mut(&place) && entries.len() > 1 && let Some(index) = entries.iter().position(|e| &e.panel == panel) { let entry = entries.remove(index); let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }; - return Ok(Moving::group(group)); + let moving = whole(Moving::group(group)); + return Ok(Moving { rect: None, native: None, ..moving }); } - match self.take_node(&path) { - Some(Node::Tabs { panels, active }) => Ok(Moving::group(Group::new(panels, active))), + match self.take_node_in(window, &path) { + Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))), Some(Node::Stack { mut entries }) if entries.len() == 1 => { let entry = entries.remove(0); - Ok(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height })) + Ok(whole(Moving::group(Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }))) } _ => Err(DockError::MissingPanel), } @@ -484,21 +615,6 @@ impl Layout

{ } Ok(Moving { width: Some(width), ..Moving::group(taken) }) } - Place::Floating { window, group } => { - let source = self.floating.get_mut(window).ok_or(DockError::MissingPanel)?; - if group >= source.groups.len() { - return Err(DockError::MissingPanel); - } - let taken = source.groups.remove(group); - let rect = source.rect; - let native = source.native; - let whole = source.groups.is_empty(); - if whole { - self.floating.remove(window); - } - let width = rect.get(2).copied(); - Ok(Moving { width, rect: whole.then_some(rect), native: native.filter(|_| whole), ..Moving::group(taken) }) - } } } @@ -514,6 +630,8 @@ impl Layout

{ } Ok(Moving { groups: taken.groups, + node: None, + minimized: false, width: Some(taken.width), iconic: taken.iconic, iconic_width: taken.iconic_width, @@ -529,7 +647,9 @@ impl Layout

{ } let taken = self.floating.remove(window); Ok(Moving { - groups: taken.groups, + groups: tree_groups(&taken.root), + node: Some(taken.root), + minimized: taken.minimized, width: taken.rect.get(2).copied(), iconic: false, iconic_width: None, @@ -553,7 +673,11 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } - self.floating.push(Floating { groups: moving.groups, rect, native }); + let root = match moving.node { + Some(node) => node, + None => stack_tree(moving.groups).ok_or(DockError::MissingPanel)?, + }; + self.floating.push(Floating { root, rect, native, minimized: moving.minimized }); Ok(()) } Target::NewDock { side } => { @@ -589,10 +713,21 @@ impl Layout

{ self.merge(&place, index, moving) } Placement::Split(zone) => { - let Place::Node(path) = place else { return Err(DockError::InvalidPlacement) }; - let (panels, active) = flatten(moving.groups); - let root = self.root.as_mut().ok_or(DockError::MissingTarget)?; - if split_frame(root, &path, Node::Tabs { panels, active }, zone) { Ok(()) } else { Err(DockError::MissingTarget) } + let (window, path) = place.tree_path().ok_or(DockError::InvalidPlacement)?; + let new = match moving.node { + Some(node) => node, + None => { + let (panels, active) = flatten(moving.groups); + Node::Tabs { panels, active } + } + }; + let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; + if split_frame(root, path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) } + } + // A floating window is a tree: a group above or below splits the anchor's group. + Placement::Group { below, .. } if matches!(place, Place::Floating { .. }) => { + let zone = if below { Zone::Bottom } else { Zone::Top }; + self.insert_moving(moving, Target::At { anchor, placement: Placement::Split(zone) }) } Placement::Group { below, height } => { if !valid_height(height) { @@ -604,10 +739,7 @@ impl Layout

{ } let (groups, index) = match &place { Place::Dock { group, .. } => (&mut self.column_mut(&place).ok_or(DockError::MissingTarget)?.groups, *group), - Place::Floating { window, group } => { - (&mut self.floating.get_mut(*window).ok_or(DockError::MissingTarget)?.groups, *group) - } - Place::Node(_) => return Err(DockError::InvalidPlacement), + Place::Node(_) | Place::Floating { .. } => return Err(DockError::InvalidPlacement), }; let index = (index + usize::from(below)).min(groups.len()); groups.splice(index..index, moving.groups); @@ -662,7 +794,7 @@ impl Layout

{ fn index_in(&self, place: &Place, panel: &P) -> Option { match place { - Place::Node(path) => match self.root.as_ref().and_then(|root| node_at(root, path))? { + Place::Node(_) | Place::Floating { .. } => match self.node_of(place)? { Node::Tabs { panels, .. } => panels.iter().position(|p| p == panel), Node::Stack { entries } => entries.iter().position(|e| &e.panel == panel), _ => None, @@ -676,7 +808,7 @@ impl Layout

{ fn merge(&mut self, place: &Place, index: Option, moving: Moving

) -> Result<(), DockError> { let (panels, selected) = flatten(moving.groups); match place { - Place::Node(path) => match self.root.as_mut().and_then(|root| node_at_mut(root, path)) { + Place::Node(_) | Place::Floating { .. } => match self.node_of_mut(place) { Some(Node::Tabs { panels: target, active }) => { splice(target, active, index.unwrap_or(target.len()), panels, selected); Ok(()) @@ -698,6 +830,14 @@ impl Layout

{ } } +fn leaf_holds(leaf: &Node

, panel: &P) -> bool { + match leaf { + Node::Tabs { panels, .. } => panels.contains(panel), + Node::Stack { entries } => entries.iter().any(|e| &e.panel == panel), + Node::Split { .. } | Node::Dock { .. } => false, + } +} + /// All panels in order, and the index of the first group's active panel among them. fn flatten

(groups: Vec>) -> (Vec

, usize) { let selected = groups.first().map_or(0, |g| g.active.min(g.panels.len().saturating_sub(1))); diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 87e1c4a0..654b637f 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -360,7 +360,7 @@ fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() { let out = d.step(vec![button(pos2(560.0, 300.0), false)]); assert!(matches!(moved(&out.actions), Some((2, Target::Float { .. }))), "{:?}", out.actions); assert_eq!(columns(&d.layout, Side::Start), vec![vec![vec![5]]]); - assert_eq!(d.layout.floating[0].groups, vec![group(&[2]), group(&[3])]); + assert_eq!(d.layout.floating[0].groups(), vec![group(&[2]), group(&[3])]); } #[test] diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 021b8570..82480948 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -38,7 +38,7 @@ fn workspace() -> Layout { second: Box::new(tabs(&[0], 0)), }), floating: vec![ - Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [300.0, 80.0, 240.0, 300.0], native: None }, + Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [300.0, 80.0, 240.0, 300.0]), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]), ], } @@ -213,7 +213,7 @@ fn window_of(layout: &Layout, panel: u32) -> Option<&Floating> { } fn groups_of(window: &Floating) -> Vec> { - window.groups.iter().map(|g| g.panels.clone()).collect() + window.groups().iter().map(|g| g.panels.clone()).collect() } #[test] @@ -228,16 +228,16 @@ fn title_bar_drag_moves_every_stacked_group_and_drops_into_a_dock_as_stacked_gro d.step(vec![button(title + vec2(40.0, 30.0), false)]); assert_eq!(d.layout, workspace()); - // Released over another window's lower band: its groups stack below that window's. + // Released over another window's lower band: its tree goes below that window's group, + // unless that leaves a group without room; then its panels join that group. let out = d.drag(title, pos2(660.0, 480.0)); assert!( - out.actions - .contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Group { below: true, height: None } } }), + out.actions.contains(&Action::MoveWindow { panel: 5, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Center) } }), "{:?}", out.actions ); assert_eq!(d.layout.floating.len(), 1); - assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8], vec![5], vec![6, 7]]); + assert_eq!(groups_of(&d.layout.floating[0]), vec![vec![8, 5, 6, 7]]); // Released over a docked group's strip: every panel joins it as tabs. let mut d = Driver::new(workspace(), false); @@ -285,38 +285,32 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() { } #[test] -fn floating_stack_seams_and_bands_insert_groups() { - // A group dragged by its bar to another window's top band stacks above that group. +fn a_floating_window_is_a_tree_whose_groups_split_and_resize_like_frames() { + // A group dragged by its bar to another window's top band splits that window's group. let mut d = Driver::new(workspace(), false); let bar = d.response(Id::new(AREA).with(("drag-bar", Some(&6u32)))).unwrap().center(); let out = d.drag(bar, pos2(660.0, 360.0)); assert!( - out.actions - .contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Group { below: false, height: None } } }), + out.actions.contains(&Action::MoveGroup { panel: 6, to: Target::At { anchor: 8, placement: Placement::Split(Zone::Top) } }), "{:?}", out.actions ); assert_eq!(groups_of(window_of(&d.layout, 8).unwrap()), vec![vec![6, 7], vec![8]]); assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5]]); - // A tab dropped on the seam between two stacked groups becomes a group between them. + // The gap between two groups of a window is no target. let mut d = Driver::new(workspace(), false); let tab = d.tab(8).unwrap().center(); - let seam = pos2(420.0, 237.0); - let out = d.drag(tab, seam); - assert!( - out.actions.contains(&Action::Move { panel: 8, anchor: 5, placement: Placement::Group { below: true, height: None } }), - "{:?}", - out.actions - ); - assert_eq!(groups_of(window_of(&d.layout, 5).unwrap()), vec![vec![5], vec![8], vec![6, 7]]); + let gap = pos2(420.0, d.tab(6).unwrap().top() - 2.0); + d.drag(tab, gap); + assert_eq!(d.layout, workspace()); - // The divider between two open groups resizes both. + // The divider between them resizes both. let mut d = Driver::new(workspace(), false); - let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::Group(5u32)))).unwrap().center(); + let divider = d.response(DockArea::hit_id(Id::new(AREA), &Hit::Divider(Divider::WindowSplit(5u32, Vec::new())))).unwrap().center(); d.drag(divider, divider + vec2(0.0, 40.0)); - let heights: Vec<_> = window_of(&d.layout, 5).unwrap().groups.iter().map(|g| g.height.unwrap()).collect(); - assert!((heights[0] - 179.5).abs() < 2.0 && (heights[1] - 99.5).abs() < 2.0, "{heights:?}"); + let Node::Split { size: SplitSize::Ratio(ratio), .. } = window_of(&d.layout, 5).unwrap().root else { panic!("{:?}", d.layout) }; + assert!(ratio > 0.6, "{ratio}"); } #[test] @@ -325,15 +319,16 @@ fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window( let title = d.title(5).center(); d.click(title); d.click(title); - assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| g.minimized)); + assert!(window_of(&d.layout, 5).unwrap().minimized); d.wait(); d.click(title); d.click(title); - assert!(window_of(&d.layout, 5).unwrap().groups.iter().all(|g| !g.minimized)); + assert!(!window_of(&d.layout, 5).unwrap().minimized); let close = d.response(Id::new(AREA).with(("move-group", Some(&5u32))).with("close")).unwrap(); assert_eq!(close.center().x, 530.0, "× sits 10 pt in from the right edge"); let before = d.layout.clone(); + d.wait(); let out = d.click(close.center()); assert_eq!(out.actions, vec![Action::CloseWindow { panel: 5 }]); assert_eq!(before.closed_by(&Action::CloseWindow { panel: 5 }), Ok(vec![5, 6, 7])); @@ -677,3 +672,44 @@ fn top_edges_resize_from_the_band_above_the_title_bar() { d.step(vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); assert_eq!(window_of(&d.layout, 8).unwrap().rect, [510.0, 292.0, 200.0, 208.0]); } + +#[test] +fn undock_panel_group_floats_one_group_of_a_floating_window() { + use egui_kittest::{Harness, kittest::Queryable}; + let stacked = || Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0])] }; + type State = (Content, Layout, Vec>); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, (content, layout, actions): &mut State| { + let mut style = DockStyle::from_ui(ui); + frame_style(&mut style); + let out = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); + actions.extend(out.actions); + }, + (Content::default(), stacked(), Vec::new()), + ); + h.run_steps(2); + let menu = |h: &mut Harness<'_, State>, panel: u32| { + let at = h.ctx.read_response(Id::new(AREA).with(("tab", &panel))).unwrap().rect.center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + }; + menu(&mut h, 6); + h.get_by_label("Undock Panel Group").click(); + h.run_steps(2); + let actions = std::mem::take(&mut h.state_mut().2); + assert!(matches!(&actions[..], [Action::MoveGroup { panel: 6, to: Target::Float { rect: Some(_) } }]), "{actions:?}"); + let mut layout = stacked(); + layout.apply_all(actions, |_| Permissions::default()).unwrap(); + assert_eq!(layout.floating.iter().map(groups_of).collect::>(), vec![vec![vec![5]], vec![vec![6, 7]]]); + + // A window's only group has nothing to undock from. + h.state_mut().1 = layout; + h.run_steps(2); + menu(&mut h, 5); + assert_eq!(h.query_all_by_label("Undock Panel Group").count(), 0); +} diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 9d81bb9a..a11e2796 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -240,7 +240,7 @@ fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() { assert_eq!((*width, *height), (border.width(), 600.0)); assert_eq!(out.group_drop, Some((2, pos2(700.0, 200.0)))); assert_eq!(d.layout.root, Some(tabs(&[1], 0))); - assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![2, 3], 1)]); + assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![2, 3], 1)]); // The same group in a dock column joins the group below it. let mut d = Driver::new(docked()); @@ -266,7 +266,7 @@ fn drag_bar_survives_a_strip_full_of_tabs() { assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs overflow before the bar narrows"); let out = d.drag(bar.center(), pos2(500.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); - assert_eq!(d.layout.floating[0].groups[0].panels.len(), 8); + assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8); } #[test] @@ -412,7 +412,7 @@ fn floating_window_is_opaque_with_a_transparent_host_background() { fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() { let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; let mut d = Driver::new(layout); - let minimized = |d: &Driver| d.layout.floating[0].groups[0].minimized; + let minimized = |d: &Driver| d.layout.floating[0].minimized; let tab = d.tab(6).center(); d.click(tab); let out = d.click(tab); @@ -424,7 +424,7 @@ fn double_click_on_a_floating_tab_or_bar_minimizes_and_another_tab_expands() { let other = d.tab(5).center(); d.click(other); assert!(!minimized(&d)); - assert_eq!(d.layout.floating[0].groups[0].active_panel(), Some(&5)); + assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5)); d.wait(); let bar = d.rect("drag-bar", 5).center(); @@ -462,7 +462,7 @@ fn floating_border_and_drag_bar_move_and_cancel_as_a_group() { assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{name}: {:?}", out.actions); let [x, y, w, h] = d.layout.floating[0].rect; assert_eq!([x, y, w, h], [130.0, 110.0, 300.0, 220.0], "{name}"); - assert_eq!(d.layout.floating[0].groups[0], Group::new(vec![5, 6], 1)); + assert_eq!(d.layout.floating[0].groups()[0], Group::new(vec![5, 6], 1)); } } @@ -551,15 +551,15 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { #[test] fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { - let stack = Floating { groups: vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], rect: [80.0, 70.0, 300.0, 400.0], native: None }; + let stack = Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [80.0, 70.0, 300.0, 400.0]); let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![stack] }; let mut d = Driver::new(layout); let from = d.rect("drag-bar", 6).center(); let out = d.drag(from, pos2(700.0, 300.0)); - assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 189.5]) })), "{:?}", out.actions); + assert!(matches!(moved_group(&out.actions), Some(Target::Float { rect: Some([_, _, 300.0, 189.0]) })), "{:?}", out.actions); assert_eq!(d.layout.floating.len(), 2); - assert_eq!(d.layout.floating[0].groups, vec![Group::new(vec![5], 0)]); - assert_eq!(d.layout.floating[1].groups, vec![Group::new(vec![6, 7], 1)]); + assert_eq!(d.layout.floating[0].groups(), vec![Group::new(vec![5], 0)]); + assert_eq!(d.layout.floating[1].groups(), vec![Group::new(vec![6, 7], 1)]); } #[test] diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 17b37149..28bcf65d 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -71,6 +71,9 @@ pub enum Divider

{ /// Where the divider of a split inside another split ends on the outer split's divider, /// by the outer and the inner split's paths. Dragging it moves both dividers. Intersection(Vec, Vec), + /// A divider in the floating window holding the panel, by its path from the window's root. + WindowSplit(P, Vec), + WindowIntersection(P, Vec, Vec), } fn key(area: Id) -> Id { @@ -105,6 +108,8 @@ impl DockArea<'_, P> { Hit::Divider(Divider::Group(p)) => id.with(("group-resize", Some(p))), Hit::Divider(Divider::Split(path)) => id.with(("split", path)), Hit::Divider(Divider::Intersection(outer, inner)) => id.with(("intersection", outer, inner)), + Hit::Divider(Divider::WindowSplit(p, path)) => id.with(("window-split", p, path)), + Hit::Divider(Divider::WindowIntersection(p, outer, inner)) => id.with(("window-intersection", p, outer, inner)), Hit::Title(p) => id.with(("move-group", Some(p))), Hit::Close(p) => id.with(("move-group", Some(p))).with("close"), Hit::Resize(p, edge) => { @@ -172,13 +177,35 @@ pub(super) fn named(area: name(Hit::Resize(p(), edge)); } } - let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); + if let Some(root) = &layout.root { + name_splits(root, &mut |path, outer| match outer { + None => name(Hit::Divider(Divider::Split(path))), + Some(outer) => name(Hit::Divider(Divider::Intersection(outer, path))), + }); + } + for window in &layout.floating { + let Some(key) = window.panels().next() else { continue }; + name_splits(&window.root, &mut |path, outer| match outer { + None => name(Hit::Divider(Divider::WindowSplit(key.clone(), path))), + Some(outer) => name(Hit::Divider(Divider::WindowIntersection(key.clone(), outer, path))), + }); + } + names +} + +/// Every split of a tree by its path (`None`), and every pair of a split and a split inside it +/// (`Some(outer)`), whose dividers may meet. +fn name_splits

(root: &Node

, name: &mut dyn FnMut(Vec, Option>)) { + let mut nodes = vec![(root, Vec::::new())]; let mut splits: Vec> = Vec::new(); while let Some((node, path)) = nodes.pop() { + if splits.len() > super::model::MAX_NODES { + break; + } if let Node::Split { first, second, .. } = node { - name(Hit::Divider(Divider::Split(path.clone()))); + name(path.clone(), None); for outer in splits.iter().filter(|outer| path.starts_with(outer)) { - name(Hit::Divider(Divider::Intersection(outer.clone(), path.clone()))); + name(path.clone(), Some(outer.clone())); } splits.push(path.clone()); let mut second_path = path.clone(); @@ -189,7 +216,6 @@ pub(super) fn named(area: nodes.push((first, first_path)); } } - names } /// The layout of the last frame and the rects of the controls it registered, by widget id. diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 825a2687..bc946daf 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -283,8 +283,7 @@ fn hit_rects_cover_every_gesture_source() { first: Box::new(Node::Tabs { panels: vec![0], active: 0 }), second: Box::new(stack), }; - let mut window = Floating::single(vec![12], 0, [500.0, 60.0, 220.0, 300.0]); - window.groups.push(Group::new(vec![13], 0)); + let window = Floating::stack(vec![Group::new(vec![12], 0), Group::new(vec![13], 0)], [500.0, 60.0, 220.0, 300.0]); let layout = Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }), floating: vec![window], diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 2fb46499..0a88dc73 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,6 +1,8 @@ use std::{collections::HashSet, fmt, hash::Hash}; -use super::dock_model::{Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, valid_column}; +use super::dock_model::{ + Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, stack_tree, tree_panels, valid_column, +}; use crate::layout::{SplitAxis, SplitSize}; /// Limits are checked before cloning or recursively modifying an application layout. @@ -54,27 +56,50 @@ pub struct StackEntry

{ pub height: Option, } -/// A floating window: a stack of groups, top to bottom. +/// A floating window: a tree of groups like the docked layout's, without docks. /// -/// Serde reads the v1 shape `{panels, active, rect}` as one group. A window with one plain group -/// (not minimized, no height, no native rect) writes that shape, so older builds can read files +/// Serde reads the v1 shape `{panels, active, rect}` as one group, and a stack of groups +/// `{groups, rect}` as a vertical split of them, sized by their heights. A window with one +/// group (not minimized, no native rect) writes the v1 shape, so older builds can read files /// that use no newer feature. #[derive(Clone, Debug, PartialEq)] pub struct Floating

{ - pub groups: Vec>, + /// `Split`, `Tabs` and `Stack` nodes; a `Dock` is invalid here. + pub root: Node

, /// `[x, y, width, height]` in logical points, in the application's egui viewport. pub rect: [f32; 4], /// Desktop rect while the window is in its own OS window. pub native: Option<[f32; 4]>, + /// Only the title bar shows. + pub minimized: bool, } impl

Floating

{ + pub fn new(root: Node

, rect: [f32; 4]) -> Self { + Self { root, rect, native: None, minimized: false } + } + pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { - Self { groups: vec![Group::new(panels, active)], rect, native: None } + Self::new(Node::Tabs { panels, active }, rect) } pub fn panels(&self) -> impl Iterator { - self.groups.iter().flat_map(|group| group.panels.iter()) + tree_panels(&self.root).into_iter() + } + + /// Groups stacked top to bottom, as a v1 layout stored them. + #[cfg(test)] + pub(crate) fn stack(groups: Vec>, rect: [f32; 4]) -> Self { + Self::new(stack_tree(groups).expect("a group"), rect) + } + + /// The window's groups, first to last. + #[cfg(test)] + pub(crate) fn groups(&self) -> Vec> + where + P: Clone, + { + super::dock_model::tree_groups(&self.root) } /// True when a group of this window holds `panel`. Takes any borrowed form of `P`, such @@ -91,7 +116,7 @@ impl

Floating

{ mod floating_serde { use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use super::{Floating, Group}; + use super::{Floating, Group, Node, stack_tree}; #[derive(Serialize)] struct V1<'a, P> { @@ -101,20 +126,22 @@ mod floating_serde { } #[derive(Serialize)] - struct V2<'a, P> { - groups: &'a [Group

], + struct Tree<'a, P> { + root: &'a Node

, rect: [f32; 4], #[serde(skip_serializing_if = "Option::is_none")] native: Option<[f32; 4]>, + #[serde(skip_serializing_if = "std::ops::Not::not")] + minimized: bool, } impl Serialize for Floating

{ fn serialize(&self, serializer: S) -> Result { - match self.groups.as_slice() { - [only] if !only.minimized && only.height.is_none() && self.native.is_none() => { - V1 { panels: &only.panels, active: only.active, rect: self.rect }.serialize(serializer) + match &self.root { + Node::Tabs { panels, active } if !self.minimized && self.native.is_none() => { + V1 { panels, active: *active, rect: self.rect }.serialize(serializer) } - groups => V2 { groups, rect: self.rect, native: self.native }.serialize(serializer), + root => Tree { root, rect: self.rect, native: self.native, minimized: self.minimized }.serialize(serializer), } } } @@ -122,6 +149,7 @@ mod floating_serde { #[derive(Deserialize)] #[serde(bound(deserialize = "P: Deserialize<'de>"))] struct Repr

{ + root: Option>, groups: Option>>, panels: Option>, #[serde(default)] @@ -129,18 +157,25 @@ mod floating_serde { rect: [f32; 4], #[serde(default)] native: Option<[f32; 4]>, + #[serde(default)] + minimized: bool, } impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating

{ fn deserialize>(deserializer: D) -> Result { let repr = Repr::

::deserialize(deserializer)?; - let groups = match (repr.groups, repr.panels) { - (Some(groups), None) => groups, - (None, Some(panels)) => vec![Group::new(panels, repr.active)], - (None, None) => return Err(serde::de::Error::missing_field("groups")), - (Some(_), Some(_)) => return Err(serde::de::Error::duplicate_field("panels")), + let mut minimized = repr.minimized; + let root = match (repr.root, repr.groups, repr.panels) { + (Some(root), None, None) => root, + (None, Some(groups), None) => { + minimized |= !groups.is_empty() && groups.iter().all(|g| g.minimized); + stack_tree(groups).ok_or_else(|| serde::de::Error::invalid_length(0, &"at least one group"))? + } + (None, None, Some(panels)) => Node::Tabs { panels, active: repr.active }, + (None, None, None) => return Err(serde::de::Error::missing_field("root")), + _ => return Err(serde::de::Error::duplicate_field("root")), }; - Ok(Floating { groups, rect: repr.rect, native: repr.native }) + Ok(Floating { root, rect: repr.rect, native: repr.native, minimized }) } } } @@ -248,6 +283,13 @@ pub enum Action

{ path: Vec, size: SplitSize, }, + /// [`Action::ResizeSplit`] in the floating window holding `panel`, with a path from its + /// root. + ResizeWindowSplit { + panel: P, + path: Vec, + size: SplitSize, + }, SetStackOpen { panel: P, open: bool, @@ -470,38 +512,30 @@ impl Layout

{ } } } - Node::Tabs { panels, active } => { - if panels.is_empty() { - return Err(DockError::InvalidGeometry); - } - check_tabs(panels, *active, &mut seen)?; - } - Node::Stack { entries } => { - if entries.is_empty() { - return Err(DockError::InvalidGeometry); - } - if entries.len() > MAX_PANELS { - return Err(DockError::LimitExceeded); - } - for entry in entries { - if !valid_height(entry.height) { - return Err(DockError::InvalidGeometry); - } - check_panel(&entry.panel, &mut seen)?; - } - } + Node::Tabs { .. } | Node::Stack { .. } => check_leaf(node, &mut seen)?, } } for floating in &self.floating { - if !valid_rect(floating.rect) || floating.groups.is_empty() || floating.native.is_some_and(|r| !valid_rect(r)) { + if !valid_rect(floating.rect) || floating.native.is_some_and(|r| !valid_rect(r)) { return Err(DockError::InvalidGeometry); } - count = count.saturating_add(floating.groups.len()); - if count > MAX_NODES { - return Err(DockError::LimitExceeded); - } - for group in &floating.groups { - check_group(group, &mut seen)?; + let mut visit = vec![(&floating.root, 1usize)]; + while let Some((node, depth)) = visit.pop() { + count = count.saturating_add(1); + if depth > MAX_DEPTH || count > MAX_NODES { + return Err(DockError::LimitExceeded); + } + match node { + Node::Split { size, first, second, .. } => { + if !valid_size(*size) { + return Err(DockError::InvalidGeometry); + } + visit.push((second, depth + 1)); + visit.push((first, depth + 1)); + } + Node::Dock { .. } => return Err(DockError::InvalidPlacement), + Node::Tabs { .. } | Node::Stack { .. } => check_leaf(node, &mut seen)?, + } } } Ok(()) @@ -555,11 +589,13 @@ impl Layout

{ pub fn location(&self, panel: &P) -> Result, DockError> { self.validate()?; let place = self.place(panel).ok_or(DockError::MissingPanel)?; - let (groups, group, floating, column) = match &place { + let (groups, group, column) = match &place { Place::Node(_) => return self.root.as_ref().and_then(|node| node_location(node, panel)).ok_or(DockError::MissingPanel), - Place::Floating { window, group } => { + Place::Floating { window, .. } => { let window = self.floating.get(*window).ok_or(DockError::MissingPanel)?; - (&window.groups, *group, Some(window.rect), None) + let mut location = node_location(&window.root, panel).ok_or(DockError::MissingPanel)?; + location.floating = Some(window.rect); + return Ok(location); } Place::Dock { path, group, .. } => { let side = match self.root.as_ref().and_then(|root| node_at(root, path)) { @@ -568,12 +604,11 @@ impl Layout

{ }; let column = self.column(&place).ok_or(DockError::MissingPanel)?; let state = ColumnLocation { side, width: column.width, iconic: column.iconic, iconic_width: column.iconic_width }; - (&column.groups, *group, None, Some(state)) + (&column.groups, *group, Some(state)) } }; let own = groups.get(group).ok_or(DockError::MissingPanel)?; let mut location = group_location(&own.panels, panel).ok_or(DockError::MissingPanel)?; - location.floating = floating; location.group = Some(GroupLocation { minimized: own.minimized, height: own.height, column }); if location.anchor.is_some() { return Ok(location); @@ -606,11 +641,15 @@ impl Layout

{ pub fn panel_state(&self, panel: &P) -> PanelState { let showing = match self.place(panel) { None => return PanelState::Hidden, - Some(Place::Node(path)) => match self.root.as_ref().and_then(|root| node_at(root, &path)) { - Some(Node::Tabs { panels, active }) => panels.get(*active) == Some(panel), - Some(Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && e.open), - _ => false, - }, + Some(place @ (Place::Node(_) | Place::Floating { .. })) => { + let minimized = matches!(place, Place::Floating { window, .. } if self.floating.get(window).is_some_and(|w| w.minimized)); + !minimized + && match self.node_of(&place) { + Some(Node::Tabs { panels, active }) => panels.get(*active) == Some(panel), + Some(Node::Stack { entries }) => entries.iter().any(|e| &e.panel == panel && e.open), + _ => false, + } + } Some(place) => { let iconic = self.column(&place).is_some_and(|c| c.iconic); self.group(&place).is_some_and(|g| !g.minimized && g.active_panel() == Some(panel)) && !iconic @@ -649,20 +688,13 @@ impl Layout

{ } }; let mut proposed = self.clone(); - if let Some(rect) = location.floating { - if !valid_rect(rect) { - return Err(DockError::InvalidGeometry); - } + if location.floating.is_some_and(|rect| !valid_rect(rect)) { + return Err(DockError::InvalidGeometry); + } + let window_open = location.anchor.as_ref().is_some_and(|anchor| proposed.floating.iter().any(|w| w.contains(anchor))); + if let Some(rect) = location.floating.filter(|_| !window_open) { // A window that closed as a whole reopens at its last rect. - if let Some(anchor) = location.anchor.as_ref().filter(|anchor| proposed.floating.iter().any(|g| g.contains(anchor))) { - if let Placement::Group { .. } = location.placement { - proposed.insert_moving(moving(), Target::At { anchor: anchor.clone(), placement: location.placement.clone() })?; - } else { - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; - } - } else { - proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; - } + proposed.insert_moving(moving(), Target::Float { rect: Some(rect) })?; } else if let Some(anchor) = &location.anchor && matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) { @@ -695,18 +727,14 @@ impl Layout

{ if members.len() != location.sibling.len() || !members.contains(anchor) { return Err(DockError::InvalidPlacement); } - around = proposed - .root - .as_mut() - .is_some_and(|root| restore_around_subtree(root, &panel, &members, zone, location.size.unwrap_or(SplitSize::Ratio(0.5)))); + let size = location.size.unwrap_or(SplitSize::Ratio(0.5)); + around = proposed.trees_mut().any(|root| restore_around_subtree(root, &panel, &members, zone, size)); } // A subtree that changed since (a migrated or reconciled layout) splits the anchor. if !around { proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; - if let Some(size) = location.size - && let Some(root) = proposed.root.as_mut() - { - restore_parent_size(root, &panel, size); + if let Some(size) = location.size { + proposed.trees_mut().any(|root| restore_parent_size(root, &panel, size)); } } } else if proposed.root.is_none() { @@ -714,13 +742,13 @@ impl Layout

{ } else { return Err(DockError::MissingTarget); } - if let Some((open, height)) = location.stack - && let Some(root) = proposed.root.as_mut() - { - restore_stack_kind(root, &panel); - if let Some(entry) = stack_entry_mut(root, &panel) { - entry.open = open; - entry.height = height; + if let Some((open, height)) = location.stack { + for root in proposed.trees_mut() { + restore_stack_kind(root, &panel); + if let Some(entry) = stack_entry_mut(root, &panel) { + entry.open = open; + entry.height = height; + } } } proposed.validate()?; @@ -884,7 +912,12 @@ impl Layout

{ group.minimized = false; return Ok(()); } - if self.root.as_mut().is_some_and(|node| activate(node, &panel)) { + if let Place::Floating { window, .. } = place + && let Some(window) = self.floating.get_mut(window) + { + window.minimized = false; + } + if self.trees_mut().any(|node| activate(node, &panel)) { return Ok(()); } Err(DockError::MissingPanel) @@ -929,17 +962,20 @@ impl Layout

{ if !valid_size(size) { return Err(DockError::InvalidGeometry); } - let mut node = self.root.as_mut().ok_or(DockError::MissingTarget)?; - for second_child in path { - let Node::Split { first, second, .. } = node else { return Err(DockError::MissingTarget) }; - node = if second_child { second } else { first }; + resize_split(self.root.as_mut().ok_or(DockError::MissingTarget)?, &path, size) + } + Action::ResizeWindowSplit { panel, path, size } => { + if path.len() >= MAX_DEPTH { + return Err(DockError::LimitExceeded); } - let Node::Split { size: old, .. } = node else { return Err(DockError::MissingTarget) }; - *old = size; - Ok(()) + if !valid_size(size) { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|w| w.contains(&panel)).ok_or(DockError::MissingPanel)?; + resize_split(&mut window.root, &path, size) } Action::SetStackOpen { panel, open } => { - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; entry.open = open; Ok(()) } @@ -947,7 +983,7 @@ impl Layout

{ if !valid_height(height) { return Err(DockError::InvalidGeometry); } - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::MissingPanel)?; entry.height = height; Ok(()) } @@ -984,7 +1020,11 @@ impl Layout

{ group.minimized = minimized; return Ok(()); } - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + if let Place::Floating { window, .. } = place { + self.floating.get_mut(window).ok_or(DockError::MissingPanel)?.minimized = minimized; + return Ok(()); + } + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; entry.open = !minimized; Ok(()) } @@ -997,7 +1037,7 @@ impl Layout

{ group.height = height; return Ok(()); } - let entry = self.root.as_mut().and_then(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; + let entry = self.trees_mut().find_map(|n| stack_entry_mut(n, &panel)).ok_or(DockError::InvalidPlacement)?; entry.height = height; Ok(()) } @@ -1046,43 +1086,36 @@ impl Layout

{ if removed.is_some() { return removed; } - for window in &mut self.floating { - for group in &mut window.groups { - if let Some(p) = remove_tab(&mut group.panels, &mut group.active, panel) { - removed = Some(StackEntry { panel: p, open: true, height: None }); - break; + if let Some(index) = self.floating.iter().position(|w| w.contains(panel)) + && let Some(window) = self.floating.get_mut(index) + { + let root = std::mem::replace(&mut window.root, Node::Tabs { panels: Vec::new(), active: 0 }); + match remove_node(root, panel, &mut removed) { + Some(root) => window.root = root, + None => { + self.floating.remove(index); } } - if removed.is_some() { - window.groups.retain(|g| !g.panels.is_empty()); - break; - } } - self.floating.retain(|g| !g.groups.is_empty()); removed } fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; - if matches!(place, Place::Dock { .. } | Place::Floating { .. }) - || matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_)) - { + let tree = place.tree_path().map(|(window, path)| (window, path.to_vec())); + let Some((window, path)) = tree.filter(|_| !matches!(placement, Placement::Group { .. } | Placement::Column { .. } | Placement::Edge(_))) + else { let group = Group::new(vec![entry.panel], 0); return self.insert_moving(Moving::group(group), Target::At { anchor: anchor.clone(), placement }); - } - if let (Placement::Split(zone), Place::Node(path)) = (&placement, &place) - && *zone != Zone::Center + }; + let root = self.tree_mut(window).ok_or(DockError::MissingTarget)?; + if let Placement::Split(zone) = placement + && zone != Zone::Center { - let root = self.root.as_mut().ok_or(DockError::MissingTarget)?; let new = Node::Tabs { panels: vec![entry.panel], active: 0 }; - return if split_frame(root, path, new, *zone) { Ok(()) } else { Err(DockError::MissingTarget) }; + return if split_frame(root, &path, new, zone) { Ok(()) } else { Err(DockError::MissingTarget) }; } - if let Some(root) = self.root.as_mut() - && insert_node(root, &entry, anchor, &placement) - { - return Ok(()); - } - Err(DockError::MissingTarget) + if insert_node(root, &entry, anchor, &placement) { Ok(()) } else { Err(DockError::MissingTarget) } } } @@ -1114,6 +1147,34 @@ fn check_panel<'a, P: Eq + Hash>(panel: &'a P, seen: &mut HashSet<&'a P>) -> Res Ok(()) } +/// A `Tabs` or `Stack` node: not empty, a valid selection and heights, panels not seen before. +fn check_leaf<'a, P: Eq + Hash>(node: &'a Node

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { + match node { + Node::Tabs { panels, active } => { + if panels.is_empty() { + return Err(DockError::InvalidGeometry); + } + check_tabs(panels, *active, seen) + } + Node::Stack { entries } => { + if entries.is_empty() { + return Err(DockError::InvalidGeometry); + } + if entries.len() > MAX_PANELS { + return Err(DockError::LimitExceeded); + } + for entry in entries { + if !valid_height(entry.height) { + return Err(DockError::InvalidGeometry); + } + check_panel(&entry.panel, seen)?; + } + Ok(()) + } + Node::Split { .. } | Node::Dock { .. } => Ok(()), + } +} + fn check_group<'a, P: Eq + Hash>(group: &'a Group

, seen: &mut HashSet<&'a P>) -> Result<(), DockError> { if group.panels.is_empty() || !valid_height(group.height) { return Err(DockError::InvalidGeometry); @@ -1181,6 +1242,16 @@ fn remove_node(node: Node

, panel: &P, removed: &mut Option(mut node: &mut Node

, path: &[bool], size: SplitSize) -> Result<(), DockError> { + for second_child in path { + let Node::Split { first, second, .. } = node else { return Err(DockError::MissingTarget) }; + node = if *second_child { second } else { first }; + } + let Node::Split { size: old, .. } = node else { return Err(DockError::MissingTarget) }; + *old = size; + Ok(()) +} + fn activate(node: &mut Node

, panel: &P) -> bool { match node { Node::Tabs { panels, active } => { diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index c7211902..583a2dd9 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -23,7 +23,7 @@ pub(super) fn docked() -> Layout { Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2, 3], 1), g(&[4], 0)], 180.0), Column::new(vec![g(&[5], 0)], 120.0)] }; Layout { root: Some(hsplit(SplitSize::FixedSecond(300.0), middle, dock)), - floating: vec![Floating { groups: vec![g(&[6, 7], 1), g(&[8], 0)], rect: [100.0, 100.0, 300.0, 400.0], native: None }], + floating: vec![Floating::stack(vec![g(&[6, 7], 1), g(&[8], 0)], [100.0, 100.0, 300.0, 400.0])], } } @@ -38,7 +38,7 @@ fn sorted(layout: &Layout) -> Vec { /// Members, active panel and minimized state of the group holding `panel`. fn group_state(layout: &Layout, panel: u32) -> (Vec, u32, bool) { match layout.place(&panel).unwrap() { - Place::Node(path) => match node_at(layout.root.as_ref().unwrap(), &path).unwrap() { + place @ (Place::Node(_) | Place::Floating { .. }) => match layout.node_of(&place).unwrap() { Node::Tabs { panels, active } => (panels.clone(), panels[*active], false), other => panic!("{other:?}"), }, @@ -78,7 +78,13 @@ fn group_moves_keep_order_active_tab_and_minimized_state() { } let before = group_state(&layout, source); layout.apply(Action::MoveGroup { panel: source, to: to.clone() }).unwrap_or_else(|e| panic!("{source} {to:?}: {e}")); - assert_eq!(group_state(&layout, source), before, "{source} {to:?}"); + let after = group_state(&layout, source); + assert_eq!((&after.0, after.1), (&before.0, before.1), "{source} {to:?}"); + // A group keeps its minimized state from dock to dock; a floating window's tree + // has no minimized groups. + if matches!((source, layout.place(&source)), (2, Some(Place::Dock { .. }))) { + assert_eq!(after.2, before.2, "{source} {to:?}"); + } assert_eq!(sorted(&layout), ALL); } } @@ -86,7 +92,7 @@ fn group_moves_keep_order_active_tab_and_minimized_state() { let mut layout = docked(); layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 1, placement: Placement::Split(Zone::Left) } }).unwrap(); assert_eq!(group_state(&layout, 6), (vec![6, 7], 7, false)); - assert_eq!(layout.floating[0].groups.len(), 1); + assert_eq!(layout.floating[0].groups().len(), 1); } #[test] @@ -94,7 +100,7 @@ fn group_merge_into_tabs_inserts_every_member_before_the_anchor_tab() { let mut layout = docked(); layout.apply(Action::MoveGroup { panel: 6, to: Target::At { anchor: 2, placement: Placement::Tab { before: Some(3) } } }).unwrap(); assert_eq!(group_state(&layout, 2), (vec![2, 6, 7, 3], 7, false)); - assert_eq!(panels_of(&layout.floating[0].groups), [vec![8]]); + assert_eq!(panels_of(&layout.floating[0].groups()), [vec![8]]); let mut layout = docked(); layout.apply(Action::MoveGroup { panel: 2, to: Target::At { anchor: 11, placement: Placement::Tab { before: Some(12) } } }).unwrap(); @@ -134,7 +140,7 @@ fn column_moves_and_floats_keep_every_group() { layout.apply(Action::MoveColumn { panel: 3, to: Target::Float { rect: None } }).unwrap(); let window = layout.floating.last().unwrap(); - assert_eq!(panels_of(&window.groups), [vec![2, 3], vec![4]]); + assert_eq!(panels_of(&window.groups()), [vec![2, 3], vec![4]]); assert_eq!(window.rect[2], 180.0); assert_eq!(sorted(&layout), ALL); assert_eq!(layout.apply(Action::MoveColumn { panel: 11, to: Target::Float { rect: None } }), Err(DockError::InvalidPlacement)); @@ -182,7 +188,7 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { workspace.set_visible(panel, true, None, |_| Permissions::default()).unwrap(); } assert_eq!(workspace.layout.floating.len(), 1); - assert_eq!(panels_of(&workspace.layout.floating[0].groups), [vec![6, 7], vec![8]]); + assert_eq!(panels_of(&workspace.layout.floating[0].groups()), [vec![6, 7], vec![8]]); assert_eq!(workspace.layout.floating[0].rect, [100.0, 100.0, 300.0, 400.0]); workspace.apply(Action::CloseOthers { panel: 12 }, allow).unwrap(); @@ -207,11 +213,7 @@ fn restore_into_a_column_group_seam_and_new_column_round_trips() { let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![g(&[10], 0)], 200.0)] }; let original = Layout { root: Some(hsplit(SplitSize::FixedFirst(200.0), start, hsplit(SplitSize::FixedSecond(300.0), tabs(&[1], 0), end))), - floating: vec![Floating { - groups: vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], - rect: [10.0, 10.0, 200.0, 300.0], - native: None, - }], + floating: vec![Floating::stack(vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], [10.0, 10.0, 200.0, 300.0])], }; original.validate().unwrap(); // 4: the seam between [2, 3] and [9]; 9: below [4]; 5: a new column after A; 10: alone in @@ -262,11 +264,14 @@ fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad } let mut duplicate = docked(); - duplicate.floating[0].groups[1].panels.push(2); + duplicate.floating[0].root = Node::Tabs { panels: vec![6, 7, 2], active: 0 }; assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); let mut empty_window = docked(); - empty_window.floating[0].groups.clear(); + empty_window.floating[0].root = Node::Tabs { panels: Vec::new(), active: 0 }; assert_eq!(empty_window.validate(), Err(DockError::InvalidGeometry)); + let mut docked_window = docked(); + docked_window.floating[0].root = Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[6, 7], 0)], 100.0)] }; + assert_eq!(docked_window.validate(), Err(DockError::InvalidPlacement)); let mut native = docked(); native.floating[0].native = Some([0.0, 0.0, -1.0, 10.0]); assert_eq!(native.validate(), Err(DockError::InvalidGeometry)); @@ -385,17 +390,36 @@ mod serde_tests { use super::*; use serde_json::{Value, json}; + #[test] + fn a_saved_stack_of_groups_loads_as_a_vertical_split_by_their_heights() { + let saved = json!({ + "groups": [{"panels": [4], "active": 0, "height": 100.0}, {"panels": [5, 6], "active": 1, "height": 300.0}], + "rect": [1.0, 2.0, 300.0, 400.0], + }); + let window: Floating = serde_json::from_value(saved).unwrap(); + let expected = Node::Split { + axis: SplitAxis::Vertical, + size: SplitSize::Ratio(0.25), + first: Box::new(Node::Tabs { panels: vec![4], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![5, 6], active: 1 }), + }; + assert_eq!((window.root, window.minimized), (expected, false)); + let all_minimized = json!({"groups": [{"panels": [4], "active": 0, "minimized": true}], "rect": [1.0, 2.0, 300.0, 400.0]}); + let window: Floating = serde_json::from_value(all_minimized).unwrap(); + assert_eq!((window.root, window.minimized), (Node::Tabs { panels: vec![4], active: 0 }, true)); + } + #[test] fn single_plain_floating_window_writes_the_v1_shape() { let rect = [1.0, 2.0, 300.0, 200.0]; let plain = Floating::single(vec![4u32, 5], 1, rect); assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect})); - let minimized = Floating { groups: vec![Group { minimized: true, ..g(&[4], 0) }], rect, native: None }; - let stacked = Floating { groups: vec![g(&[4], 0), g(&[5], 0)], rect, native: None }; + let minimized = Floating { minimized: true, ..Floating::single(vec![4u32], 0, rect) }; + let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect); let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) }; for window in [minimized, stacked, native] { let value = serde_json::to_value(&window).unwrap(); - assert!(value.get("groups").is_some() && value.get("panels").is_none(), "{value}"); + assert!(value.get("root").is_some() && value.get("panels").is_none(), "{value}"); assert_eq!(serde_json::from_value::>(value).unwrap(), window); } assert!(serde_json::from_value::>(json!({"rect": rect})).is_err()); diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 16932bf8..eb4e4934 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -474,9 +474,9 @@ impl Renderer<'_, P> { || self.iconic.covers(pointer) || self .frame - .stacks + .windows .iter() - .any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.groups.iter().any(|(panels, ..)| panels.contains(p)))) + .any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.members.contains(p))) } /// Dock targets, with the new-dock bands along the docked area's left and right edges that diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index a321f3aa..72ec389c 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -4,21 +4,14 @@ use egui::{Context, Id, Key, Pos2, Rect, Ui, pos2, vec2}; use super::{GroupKind, Renderer, clamp_float, node_limits}; use crate::docking::chrome::{ChromeStyle, FloatingFrame, RESIZE_BAND, TitleBar}; -use crate::docking::{Action, DropProposal, Floating, Group, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target}; -use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; +use crate::docking::{Action, DropProposal, Floating, GroupDrag, GroupGesture, Node, PanelLimits, Placement, Target}; pub(in crate::docking) use crate::docking::chrome::RESIZE_HANDLES; -/// Height of the bar between two stacked groups of a floating window. -const GAP: f32 = 3.0; - -/// A floating stack as drawn, for its band and seam targets. -pub(in crate::docking) struct StackHit

{ +/// A floating window as drawn: what covers the layout behind it. +pub(in crate::docking) struct WindowHit

{ pub(super) window: Rect, - /// Each group's members, rect and tab strip, top to bottom. - pub(super) groups: Vec<(Vec

, Rect, Rect)>, - /// The upper group's index and the band of each seam. - pub(super) seams: Vec<(usize, Rect)>, + pub(super) members: Vec

, } impl Renderer<'_, P> { @@ -40,24 +33,32 @@ impl Renderer<'_, P> { } } - /// Size limits of a floating window: its title bar, each group's strip and the bodies of - /// the groups that are not minimized. + /// Size limits of a floating window: its title bar and, unless it is minimized, its tree. pub(super) fn window_limits(&self, window: &Floating

) -> PanelLimits { - // A movable window is resizable, with a resize band above its title bar. + let chrome = self.window_chrome(window); + let tree = node_limits(&window.root, self.style, &*self.content); + let body = match (&window.root, window.minimized) { + (Node::Tabs { .. }, true) => (self.style.tab_height, self.style.tab_height), + (_, true) => (0.0, 0.0), + (_, false) => (tree.min.y, tree.max.y), + }; + let min = vec2(tree.min.x, chrome + body.0).min(vec2(1_000_000.0, 1_000_000.0)); + PanelLimits { min, max: vec2(tree.max.x, chrome + body.1).max(min).min(vec2(1_000_000.0, 1_000_000.0)) } + } + + /// The title bar and, for a movable (resizable) window, the resize band above it. + fn window_chrome(&self, window: &Floating

) -> f32 { let band = if window.panels().all(|panel| self.permissions(panel).movable) { RESIZE_BAND } else { 0.0 }; - let chrome = band + self.style.title_height; - let mut limits = PanelLimits { min: vec2(0.0, chrome), max: vec2(1_000_000.0, chrome) }; - for (index, group) in window.groups.iter().enumerate() { - let policy = node_limits(&Node::Tabs { panels: group.panels.clone(), active: 0 }, self.style, &*self.content); - let gap = if index > 0 { GAP } else { 0.0 }; - limits.min.x = limits.min.x.max(policy.min.x); - limits.max.x = limits.max.x.min(policy.max.x); - let (min, max) = if group.minimized { (self.style.tab_height, self.style.tab_height) } else { (policy.min.y, policy.max.y) }; - limits.min.y += min + gap; - limits.max.y += max + gap; - } - let min = limits.min.min(vec2(1_000_000.0, 1_000_000.0)); - PanelLimits { min, max: limits.max.max(min).min(vec2(1_000_000.0, 1_000_000.0)) } + band + self.style.title_height + } + + /// Where a floating window draws its tree, as [`Self::floating_window`] places it. + pub(super) fn window_body(&self, window: &Floating

) -> Rect { + let [x, y, width, height] = window.rect; + let limits = self.window_limits(window); + let size = vec2(width, height).max(limits.min).min(limits.max); + let rect = crate::docking::chrome::clamp(Rect::from_min_size(pos2(x, y), size), self.float_bounds); + Rect::from_min_max(pos2(rect.left(), (rect.top() + self.window_chrome(window)).min(rect.bottom())), rect.max) } fn floating_window(&mut self, ctx: &Context, window: &Floating

, screen: Rect) { @@ -97,17 +98,33 @@ impl Renderer<'_, P> { } } - /// The title bar, then the stacked groups below it, in `contents`. Moves carry the whole + /// The title bar, then the window's tree below it, in `contents`. Moves carry the whole /// `frame`, resize band included. fn window_contents(&mut self, ui: &mut Ui, window: &Floating

, contents: Rect, frame: Rect, style: &ChromeStyle) { let title = Rect::from_min_max(contents.min, pos2(contents.right(), (contents.top() + self.style.title_height).min(contents.bottom()))); self.title_bar(ui, window, title, frame, style); let body = Rect::from_min_max(pos2(contents.left(), title.bottom()), contents.max); - self.floating_stack(ui, &window.groups, body, frame); + self.floating_body(ui, window, body, frame); } - /// Drag moves the whole window live, double-click minimizes or expands every group, × - /// closes every panel in it. + /// A floating window's tree in `body`, like the docked layout's: splits, groups, dividers + /// and intersections. A minimized window shows the tab strip of its only group, or nothing. + pub(super) fn floating_body(&mut self, ui: &mut Ui, window: &Floating

, body: Rect, frame: Rect) { + let Some(key) = window.panels().next().cloned() else { return }; + match (&window.root, window.minimized) { + (Node::Tabs { panels, active }, true) => self.group(ui, panels, *active, true, body, GroupKind::Floating { window: frame }), + (_, true) => {} + (root, false) => { + let outer = self.tree.replace((key, frame)); + self.node(ui, root, body, &mut Vec::new()); + self.tree = outer; + } + } + self.frame.windows.push(WindowHit { window: frame, members: window.panels().cloned().collect() }); + } + + /// Drag moves the whole window live, double-click minimizes or expands it, × closes every + /// panel in it. fn title_bar(&mut self, ui: &mut Ui, window: &Floating

, title: Rect, rect: Rect, style: &ChromeStyle) { let Some(first) = window.panels().next() else { return }; let movable = window.panels().all(|p| self.permissions(p).movable); @@ -143,12 +160,7 @@ impl Renderer<'_, P> { self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] }); } if response.bar.double_clicked() { - let minimized = !window.groups.iter().all(|g| g.minimized); - for group in window.groups.iter().filter(|g| g.minimized != minimized) { - if let Some(panel) = group.active_panel() { - self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized }); - } - } + self.output.actions.push(Action::SetMinimized { panel: first.clone(), minimized: !window.minimized }); } if let Some(close) = response.close { self.register_interaction(ui.ctx(), close.id); @@ -158,86 +170,19 @@ impl Renderer<'_, P> { } } - /// Groups top to bottom with a bar between them. Minimized groups take their strip; the - /// others share the rest by their preferred heights, equally when none is set. - pub(super) fn floating_stack(&mut self, ui: &mut Ui, groups: &[Group

], body: Rect, window: Rect) { - let strip = self.style.tab_height; - let open = groups.iter().filter(|g| !g.minimized).count(); - let fixed = groups.len().saturating_sub(open) as f32 * strip + groups.len().saturating_sub(1) as f32 * GAP; - let available = (body.height() - fixed).max(0.0); - let equal = if open > 0 { available / open as f32 } else { 0.0 }; - let wanted = |g: &Group

| g.height.filter(|h| h.is_finite() && *h > 0.0).unwrap_or(equal).max(1.0); - let total: f32 = groups.iter().filter(|g| !g.minimized).map(wanted).sum(); - let scale = if total > 0.0 { available / total } else { 0.0 }; - let mut hit = StackHit { window, groups: Vec::new(), seams: Vec::new() }; - let mut top = body.top(); - let mut rects = Vec::new(); - for (index, group) in groups.iter().enumerate() { - if index > 0 { - let gap = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + GAP).min(body.bottom()))); - ui.painter().rect_filled(gap, 0.0, self.style.tab_background); - hit.seams.push((index - 1, Rect::from_center_size(gap.center(), vec2(gap.width(), self.style.seam_band)))); - top = gap.bottom(); - } - let height = if group.minimized { strip } else { wanted(group) * scale }; - let rect = Rect::from_min_max(pos2(body.left(), top), pos2(body.right(), (top + height).min(body.bottom()))); - self.group(ui, &group.panels, group.active, group.minimized, rect, GroupKind::Floating { window }); - let strip_rect = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + strip).min(rect.bottom()))); - hit.groups.push((group.panels.clone(), rect, strip_rect)); - rects.push(rect); - top = rect.bottom(); - } - // Dividers between two open groups resize both; registered after the strips, so their - // hit region wins at the shared edge. - for (index, pair) in groups.windows(2).enumerate() { - let ([upper, lower], Some(a), Some(b)) = (pair, rects.get(index), rects.get(index + 1)) else { continue }; - if upper.minimized || lower.minimized || scale <= 0.0 { - continue; - } - let (Some(upper_panel), Some(lower_panel)) = (upper.active_panel(), lower.active_panel()) else { continue }; - let divider = Rect::from_min_max(pos2(body.left(), a.bottom()), pos2(body.right(), b.top())); - let least = strip + self.style.min_pane; - let pair_size = a.height() + b.height(); - let (lo, hi) = if pair_size >= 2.0 * least { (least, pair_size - least) } else { (a.height(), a.height()) }; - let response = ResizeHandle::new( - self.id.with(("group-resize", upper.panels.first())), - SplitAxis::Vertical, - divider, - &self.style.resize_label, - ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, - ) - .value(a.height(), lo..=hi) - .show(ui); - self.register_interaction(ui.ctx(), response.response.id); - if response.delta != 0.0 { - let next = (a.height() + response.delta).clamp(lo, hi); - self.output.actions.push(Action::ResizeGroup { panel: upper_panel.clone(), height: Some(next / scale) }); - self.output.actions.push(Action::ResizeGroup { panel: lower_panel.clone(), height: Some((pair_size - next) / scale) }); - } - } - self.frame.stacks.push(hit); - } - - /// Targets the group targets do not cover: the bands and seams of a floating stack (P3, - /// P4), then the layout's outer edge (P8). None leaves the pointer to the group targets. - /// `skip_own` looks through the window holding `members` (a window moving live). + /// The title bar of a floating window occludes what lies behind it: a no-op proposal. Then + /// the layout's outer edge (P8). None leaves the pointer to the group targets. `skip_own` + /// looks through the window holding `members` (a window moving live). pub(super) fn extra_proposal(&self, members: &[P], pointer: Pos2, skip_own: bool) -> Option> { let own = |panels: &[P]| panels.iter().any(|p| members.contains(p)); - let window = self.frame.stacks.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && w.groups.iter().any(|(g, ..)| own(g)))); + let window = self.frame.windows.iter().rev().find(|w| w.window.contains(pointer) && !(skip_own && own(&w.members))); if let Some(window) = window { - let in_group = window.groups.iter().any(|(_, rect, _)| rect.contains(pointer)) || window.seams.iter().any(|(_, b)| b.contains(pointer)); - if !in_group { - // The title bar occludes what lies behind the window. - let anchor = members.first()?.clone(); - return Some(DropProposal { - anchor, - placement: Placement::Tab { before: None }, - preview: Rect::NOTHING, - insertion: None, - noop: true, - }); + let in_group = self.targets.iter().any(|t| t.rect.contains(pointer) && t.tabs.iter().any(|(p, _)| window.members.contains(p))); + if in_group { + return None; } - return self.stack_proposal(window, members, pointer); + let anchor = members.first()?.clone(); + return Some(DropProposal { anchor, placement: Placement::Tab { before: None }, preview: Rect::NOTHING, insertion: None, noop: true }); } let (anchor, zone, preview) = self.edge_proposal(members, pointer)?; let lead = members.first()?.clone(); @@ -251,74 +196,30 @@ impl Renderer<'_, P> { Some(DropProposal { anchor, placement: Placement::Edge(zone), preview, insertion: None, noop: false }) } - fn stack_proposal(&self, hit: &StackHit

, members: &[P], pointer: Pos2) -> Option> { - let line = |y: f32| Rect::from_min_max(pos2(hit.window.left(), y - 1.0), pos2(hit.window.right(), y + 1.0)); - let same = |panels: &[P]| panels == members; - // An anchor in the group that is not itself moving. - let anchor = |panels: &[P]| panels.iter().find(|p| !members.contains(p)).cloned(); - for (upper, band) in &hit.seams { - if !band.contains(pointer) { - continue; - } - let (Some((a, rect, _)), Some((b, ..))) = (hit.groups.get(*upper), hit.groups.get(upper + 1)) else { return None }; - let line = line(rect.bottom() + GAP * 0.5); - if same(a) || same(b) { - let anchor = members.first()?.clone(); - return Some(DropProposal { - anchor, - placement: Placement::Group { below: true, height: None }, - preview: Rect::NOTHING, - insertion: None, - noop: true, - }); - } - let (anchor, below) = match anchor(a) { - Some(panel) => (panel, true), - None => (anchor(b)?, false), - }; - return Some(DropProposal { - anchor, - placement: Placement::Group { below, height: None }, - preview: Rect::NOTHING, - insertion: Some(line), - noop: false, - }); - } - let (panels, rect, strip) = hit.groups.iter().find(|(_, rect, _)| rect.contains(pointer))?; - if strip.contains(pointer) || same(panels) { - return None; - } - let band = rect.height() * 0.25; - let below = if pointer.y < rect.top() + band { - false - } else if pointer.y > rect.bottom() - band { - true - } else { - return None; - }; - let insertion = Some(line(if below { rect.bottom() } else { rect.top() })); - Some(DropProposal { - anchor: anchor(panels)?, - placement: Placement::Group { below, height: None }, - preview: Rect::NOTHING, - insertion, - noop: false, - }) - } - /// What a group drag carries: the whole window rect for the only group of a floating /// window, so floating it again keeps the window's size; else the group's rect. pub(super) fn drag_source(&self, owner: &super::group::TabGroup<'_, P>) -> Rect { match owner.kind { - GroupKind::Floating { window } - if owner.panels.first().is_some_and(|p| self.layout.floating.iter().any(|w| w.groups.len() == 1 && w.contains(p))) => - { - window - } + GroupKind::Floating { window } if owner.panels.first().is_some_and(|p| self.whole_window(p)) => window, _ => owner.rect, } } + /// Whether a double-click or the group menu minimizes `owner`: a dock group, or the only + /// group of a floating window, which minimizes the window to that group's strip. + pub(super) fn minimizes(&self, owner: &super::group::TabGroup<'_, P>) -> bool { + match owner.kind { + GroupKind::Dock => true, + GroupKind::Floating { .. } => self.whole_window(owner.selected), + GroupKind::Frame => false, + } + } + + /// Whether `panel`'s group is the whole tree of a floating window. + pub(super) fn whole_window(&self, panel: &P) -> bool { + matches!(self.place(panel), Some(crate::docking::dock_model::Place::Floating { path, .. }) if path.is_empty()) + } + /// Release of a title bar drag. The window already sits where it was dragged; a dock or /// group target docks or merges every group in it, else hosts get the release in /// `group_drop`. diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index f7fa7953..e86f1b7c 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -5,7 +5,7 @@ use std::{borrow::Cow, collections::HashMap, fmt::Debug, hash::Hash}; use egui::{Context, Pos2, Rect, pos2, vec2}; -use super::{GroupKind, Renderer, floating::StackHit, group::TabGroup}; +use super::{GroupKind, Renderer, floating::WindowHit, group::TabGroup}; use crate::docking::{ Action, Column, DockContent, DockEvent, Floating, Group, HidePanels, Layout, Node, Zone, dock_model::{Place, node_at}, @@ -18,12 +18,14 @@ const HINT_OFFSET: f32 = 28.0; pub(in crate::docking) struct FrameState

{ /// View path to layout path of each split, when the view is not the layout. paths: Option, Vec>>, + /// The same for each floating window's splits, by the window's first shown panel. + window_paths: HashMap, Vec>>, /// The view node whose rect is the outer edge area; none while a frame is maximized. edge_path: Option>, pub(super) edge: Option, pub(super) maximized: Option

, - /// Floating stacks as drawn, back to front. - pub(super) stacks: Vec>, + /// Floating windows as drawn, back to front. + pub(super) windows: Vec>, } /// The layout as shown: a maximized frame alone, and hidden panels left out. @@ -48,7 +50,7 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( } Place::Floating { .. } => None, }); - let mut state = FrameState { paths: None, edge_path: None, edge: None, maximized: None, stacks: Vec::new() }; + let mut state = FrameState { paths: None, window_paths: HashMap::new(), edge_path: None, edge: None, maximized: None, windows: Vec::new() }; if maximized.is_none() && matches!(hide, HidePanels::None) { state.edge_path = Some(layout.edge_path()); return (Cow::Borrowed(layout), state); @@ -61,14 +63,16 @@ pub(in crate::docking) fn view<'a, P: Clone + Eq + Hash>( } None => { let root = layout.root.as_ref().and_then(|root| prune(root, Vec::new(), Vec::new(), &hidden, &mut paths)); - let floating = layout - .floating - .iter() - .filter_map(|window| { - let groups: Vec<_> = window.groups.iter().filter_map(|g| prune_group(g, &hidden)).collect(); - (!groups.is_empty()).then(|| Floating { groups, rect: window.rect, native: window.native }) - }) - .collect(); + let mut floating = Vec::new(); + for window in &layout.floating { + let mut paths = HashMap::new(); + let Some(root) = prune(&window.root, Vec::new(), Vec::new(), &hidden, &mut paths) else { continue }; + let shown = Floating { root, ..window.clone() }; + if let Some(key) = shown.panels().next() { + state.window_paths.insert(key.clone(), paths); + } + floating.push(shown); + } Layout { root, floating } } }; @@ -148,14 +152,18 @@ fn prune_group(group: &Group

, hidden: &dyn Fn(&P) -> bo impl Renderer<'_, P> { pub(super) fn note_edge(&mut self, path: &[bool], rect: Rect) { - if self.frame.edge_path.as_deref() == Some(path) { + if self.tree.is_none() && self.frame.edge_path.as_deref() == Some(path) { self.frame.edge = Some(rect); } } - /// The layout path of a split drawn at view path `path`. + /// The layout path of a split drawn at view path `path`, in the tree being drawn. pub(super) fn original_path(&self, path: &[bool]) -> Vec { - self.frame.paths.as_ref().and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) + let paths = match &self.tree { + None => self.frame.paths.as_ref(), + Some((key, _)) => self.frame.window_paths.get(key), + }; + paths.and_then(|paths| paths.get(path).cloned()).unwrap_or_else(|| path.to_vec()) } /// The outer edge band under the pointer (P8): top and bottom, and left and right with @@ -202,7 +210,7 @@ impl Renderer<'_, P> { /// Ctrl/Cmd held when a split-tree tab drag starts: the drop floats (S9). pub(super) fn latch_undock(&self, ctx: &Context, kind: Option) { let command = ctx.input(|i| i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd); - if self.style.command_press_floats && kind == Some(GroupKind::Frame) && command { + if self.style.command_press_floats && matches!(kind, Some(GroupKind::Frame | GroupKind::Floating { .. })) && command { ctx.data_mut(|d| { if let Some(drag) = d.get_temp::>(self.id.with("drag")) { d.insert_temp(self.id.with("drag"), crate::docking::Drag { undock: true, ..drag }); diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 8ce1ecb9..ec7d98bd 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -13,7 +13,7 @@ pub(in crate::docking) enum GroupKind { Frame, /// A group in a dock column. Dock, - /// A group in the floating window drawn at `window`. + /// A `Tabs` node of the floating window drawn at `window`: side drops split it. Floating { window: Rect }, } @@ -40,7 +40,7 @@ impl Renderer<'_, P> { let Some(selected) = panels.get(active) else { return }; self.record_float(panels, rect.size(), rect.size()); let policy = self.permissions(selected); - let splits = kind == GroupKind::Frame && policy.accepts_splits; + let splits = matches!(kind, GroupKind::Frame | GroupKind::Floating { .. }) && policy.accepts_splits; let rtl = self.style.right_to_left; if stripless(panels, &*self.content) { self.contents(ui, selected, rect); @@ -175,7 +175,7 @@ impl Renderer<'_, P> { if !bar { return; } - if owner.kind != GroupKind::Frame && response.double_clicked() { + if self.minimizes(owner) && response.double_clicked() { self.output.actions.push(Action::SetMinimized { panel: owner.selected.clone(), minimized: !owner.minimized }); } let active = response.has_focus() || response.hovered(); @@ -258,7 +258,7 @@ impl Renderer<'_, P> { if closable && self.menu_action(ui, &self.style.close_group_label.clone()) { self.output.actions.push(Action::CloseGroup { panel: panel.clone() }); } - if owner.kind != GroupKind::Frame { + if self.minimizes(owner) { let label = if owner.minimized { self.style.expand_label.clone() } else { self.style.minimize_label.clone() }; if self.menu_action(ui, &label) { self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: !owner.minimized }); @@ -267,7 +267,8 @@ impl Renderer<'_, P> { if owner.kind == GroupKind::Dock && self.menu_action(ui, &self.style.collapse_to_icons_label.clone()) { self.output.actions.push(Action::SetIconic { panel: panel.clone(), iconic: true }); } - if floatable && !matches!(owner.kind, GroupKind::Floating { .. }) && self.menu_action(ui, &self.style.float_group_label.clone()) { + let whole_window = matches!(owner.kind, GroupKind::Floating { .. }) && self.whole_window(panel); + if floatable && !whole_window && self.menu_action(ui, &self.style.float_group_label.clone()) { let size = self.group_float_size(owner.panels, owner.rect.size()); let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); self.output.actions.push(Action::MoveGroup { @@ -353,7 +354,7 @@ impl Renderer<'_, P> { } pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) { - let minimized = owner.filter(|owner| owner.kind != GroupKind::Frame).map(|owner| owner.minimized); + let minimized = owner.filter(|owner| self.minimizes(owner)).map(|owner| owner.minimized); let policy = self.permissions(panel); let label = self.label_of(panel); let color = if active { self.style.text } else { self.style.inactive_text }; diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 742839a7..fc8fd3d4 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -55,6 +55,9 @@ pub(super) struct Renderer<'a, P> { pub(super) contents: HashMap, /// Split dividers drawn so far, by view path, for the intersections of their ancestors. pub(super) dividers: Vec, + /// The floating window whose tree is being drawn: its first panel, which names it in + /// ids and actions, and its rect. `None` while the docked layout draws. + pub(super) tree: Option<(P, Rect)>, } pub(super) struct DrawnDivider { @@ -127,7 +130,7 @@ impl Renderer<'_, P> { // Named by its layout path, which `ResizeSplit` takes and hits report. let layout_path = self.original_path(path); let response = ResizeHandle::new( - self.id.with(("split", &layout_path)), + self.split_id(&layout_path), *axis, split.divider, &self.style.resize_label, @@ -145,7 +148,13 @@ impl Renderer<'_, P> { self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, rect, divider: split.divider }); } } - Node::Tabs { panels, active } => self.group(ui, panels, *active, false, rect, GroupKind::Frame), + Node::Tabs { panels, active } => { + let kind = match &self.tree { + None => GroupKind::Frame, + Some((_, window)) => GroupKind::Floating { window: *window }, + }; + self.group(ui, panels, *active, false, rect, kind); + } Node::Dock { side, columns } => self.dock(ui, *side, columns, rect, rect.width()), Node::Stack { entries } => { let total_header = (entries.len() as f32 * self.style.tab_height).min(rect.height()); @@ -247,7 +256,20 @@ impl Renderer<'_, P> { /// `path` in `rect`, by `delta` points. fn divider_actions(&self, node: &Node

, rect: Rect, path: &[bool], delta: f32) -> Vec> { let sizes = self.sizer().drag(node, rect, delta, &mut path.to_vec()); - sizes.into_iter().map(|(path, size)| Action::ResizeSplit { path: self.original_path(&path), size }).collect() + let resize = |path: Vec, size| match &self.tree { + None => Action::ResizeSplit { path, size }, + Some((panel, _)) => Action::ResizeWindowSplit { panel: panel.clone(), path, size }, + }; + sizes.into_iter().map(|(path, size)| resize(self.original_path(&path), size)).collect() + } + + /// The id of the divider of the split at `path` in the tree being drawn, as + /// [`crate::docking::DockArea::hit_id`] names it. + fn split_id(&self, path: &[bool]) -> Id { + match &self.tree { + None => self.id.with(("split", path)), + Some((panel, _)) => self.id.with(("window-split", panel, path)), + } } /// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on @@ -268,7 +290,11 @@ impl Renderer<'_, P> { } for (inner_path, inner_rect, hit) in found { let Some(inner) = inner_path.get(path.len()..).and_then(|rest| crate::docking::dock_model::node_at(node, rest)) else { continue }; - let id = self.id.with(("intersection", self.original_path(path), self.original_path(&inner_path))); + let (outer, inner_layout) = (self.original_path(path), self.original_path(&inner_path)); + let id = match &self.tree { + None => self.id.with(("intersection", outer, inner_layout)), + Some((panel, _)) => self.id.with(("window-intersection", panel, outer, inner_layout)), + }; let response = ui.interact(hit, id, Sense::drag()); self.register_interaction(ui.ctx(), id); if response.hovered() || response.dragged() { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 349dcbf2..911ae225 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -71,7 +71,7 @@ impl Renderer<'_, P> { // and size, which are read back below. let spawn_key = self.id.with(("native-spawn", &first)); let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect))); - let title = window.groups.first().and_then(|g| g.active_panel()).map(|p| self.label_of(p)).unwrap_or_default(); + let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() .with_title(title) .with_position([spawn[0], spawn[1]]) @@ -80,11 +80,11 @@ impl Renderer<'_, P> { if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } - let (targets, stacks) = (self.targets.len(), self.frame.stacks.len()); + let (targets, windows) = (self.targets.len(), self.frame.windows.len()); let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| { let rect = ui.max_rect(); ui.painter().rect_filled(rect, 0.0, self.style.floating_background); - self.floating_stack(ui, &window.groups, rect, rect); + self.floating_body(ui, window, rect, rect); // An embedded fallback reads the main window's input: its close request and place // are not this window's. if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { @@ -95,7 +95,7 @@ impl Renderer<'_, P> { }); // Targets in another OS window are in its coordinates, and no drag crosses windows. self.targets.truncate(targets); - self.frame.stacks.truncate(stacks); + self.frame.windows.truncate(windows); ctx.data_mut(|d| { if d.get_temp::>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) { d.remove::>(self.id.with("drag")); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index a767750a..0189f810 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -6,7 +6,7 @@ use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, - dock_model::{Place, node_at}, + dock_model::Place, resolve_drop, surface::TAB_PADDING, }; @@ -104,7 +104,9 @@ impl Renderer<'_, P> { }); } let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?; - if window && matches!(proposal.placement, Placement::Split(_)) { + // A window moving live splits only groups of other floating windows. + let into_window = matches!(self.place(&proposal.anchor), Some(Place::Floating { .. })); + if window && !into_window && matches!(proposal.placement, Placement::Split(_)) { return None; } valid(proposal) @@ -138,6 +140,9 @@ impl Renderer<'_, P> { if let Some(root) = &layout.root { sizer.groups(root, self.workspace, &mut out); } + for window in layout.floating.iter().filter(|w| !w.minimized) { + sizer.groups(&window.root, self.window_body(window), &mut out); + } out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect.size(), d.limits.min))).collect::>() }; let before: HashMap = drawn(self.layout).into_iter().map(|(panel, size, _)| (panel, size)).collect(); @@ -406,7 +411,7 @@ impl Renderer<'_, P> { /// The active panel of the group at `place` when `members` are that whole group. fn group_active<'a>(&'a self, members: &[P], place: &Place) -> Option<&'a P> { let (panels, active) = match place { - Place::Node(path) => match self.layout.root.as_ref().and_then(|root| node_at(root, path)) { + Place::Node(_) | Place::Floating { .. } => match self.layout.node_of(place) { Some(Node::Tabs { panels, active }) => (panels, *active), _ => return None, }, diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 3354d9f0..69e1313d 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -506,3 +506,107 @@ fn the_title_bar_moves_a_floating_window_without_docking() { assert!(window.rect[0] < 1000.0 && window.rect[1] > 400.0, "moved towards the drop: {:?}", window.rect); assert_eq!(d.layout.root, editing().root); } + +fn panel_set(layout: &Layout) -> Vec { + let mut panels: Vec = layout.panels().into_iter().copied().collect(); + panels.sort_unstable(); + panels +} + +fn window_of(layout: &Layout, panel: u32) -> &Floating { + layout.floating.iter().find(|w| w.contains(&panel)).unwrap_or_else(|| panic!("{panel} does not float: {layout:?}")) +} + +#[test] +fn tabs_and_drag_bars_dock_into_a_floating_window_and_out_of_it() { + let mut d = film(); + let all = panel_set(&d.layout); + let info = group(&d, INFO); + let out = d.drag(tab(&d, PROGRAM), pos2(info.left() + 10.0, info.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: PROGRAM, anchor: INFO, placement: Placement::Split(Zone::Left) }]); + d.settle(); + assert!(matches!(window_of(&d.layout, INFO).root, Node::Split { axis: SplitAxis::Horizontal, .. }), "{:?}", d.layout.floating); + let (program, info) = (group(&d, PROGRAM), group(&d, INFO)); + assert!(program.right() < info.left() && (program.width() - info.width()).abs() < 2.0, "{program:?} {info:?}"); + + // A drag bar inside the window moves its group to a group of the main area. + let timeline = group(&d, TIMELINE); + let bar = d.hit(&Hit::DragBar(INFO)).unwrap().center(); + let out = d.drag(bar, timeline.center() + vec2(0.0, 13.0)); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: INFO, to: Target::At { anchor: TIMELINE, placement: Placement::Split(Zone::Center) } }]); + d.settle(); + assert_eq!(window_of(&d.layout, PROGRAM).root, tabs(&[PROGRAM])); + assert_eq!(panel_set(&d.layout), all); +} + +#[test] +fn a_floating_window_tree_resizes_by_its_dividers_and_intersections() { + let mut layout = editing(); + let tree = hsplit(0.5, tabs(&[INFO]), split(SplitAxis::Vertical, 0.5, tabs(&[50]), tabs(&[51]))); + layout.floating = vec![Floating::new(tree, [700.0, 100.0, 600.0, 500.0])]; + let mut d = driver(layout, Film::default()); + let ratios = |d: &Driver| match &d.layout.floating[0].root { + Node::Split { size: SplitSize::Ratio(outer), second, .. } => match &**second { + Node::Split { size: SplitSize::Ratio(inner), .. } => (*outer, *inner), + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + let outer = Hit::Divider(Divider::WindowSplit(INFO, Vec::new())); + let inner = Hit::Divider(Divider::WindowSplit(INFO, vec![true])); + for (hit, delta) in [(&outer, vec2(30.0, 0.0)), (&outer, vec2(-60.0, 0.0)), (&inner, vec2(0.0, 30.0)), (&inner, vec2(0.0, -60.0))] { + let before = ratios(&d); + drag_hit(&mut d, hit.clone(), delta); + let after = ratios(&d); + let moved = if hit == &outer { (after.0 - before.0) * delta.x } else { (after.1 - before.1) * delta.y }; + assert!(moved > 0.0, "{hit:?} by {delta:?}: {before:?} to {after:?}"); + assert_eq!(d.layout.root, editing().root, "the main area keeps its sizes"); + } + // The four-way handle where the inner divider meets the outer one moves both. + let before = ratios(&d); + drag_hit(&mut d, Hit::Divider(Divider::WindowIntersection(INFO, Vec::new(), vec![true])), vec2(25.0, 25.0)); + let after = ratios(&d); + assert!(after.0 > before.0 && after.1 > before.1, "{before:?} to {after:?}"); +} + +#[test] +fn moves_between_windows_are_one_action_and_lose_no_panel() { + let mut layout = editing(); + layout.floating.push(Floating::single(vec![50, 51], 0, [200.0, 520.0, 320.0, 260.0])); + let mut d = driver(layout, Film::default()); + let all = panel_set(&d.layout); + // Floating to floating: a whole group, by its drag bar, beside Info. + let info = group(&d, INFO); + let bar = d.hit(&Hit::DragBar(50)).unwrap().center(); + let out = d.drag(bar, pos2(info.right() - 10.0, info.center().y + 13.0)); + assert_eq!(out.actions, vec![Action::MoveGroup { panel: 50, to: Target::At { anchor: INFO, placement: Placement::Split(Zone::Right) } }]); + d.settle(); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(panel_set(&d.layout), all); + // Main to floating: a tab joins the window's group. + let fifty = group(&d, 50); + let out = d.drag(tab(&d, SOURCE[1]), fifty.center() + vec2(0.0, 13.0)); + assert_eq!(out.actions, vec![Action::Move { panel: SOURCE[1], anchor: 50, placement: Placement::Split(Zone::Center) }]); + d.settle(); + assert!(window_of(&d.layout, 50).contains(&SOURCE[1])); + assert_eq!(panel_set(&d.layout), all); + assert_eq!(d.layout.validate(), Ok(())); +} + +#[test] +fn a_closed_floating_window_closes_its_panels_and_each_reopens_where_it_was() { + let mut layout = editing(); + let tree = hsplit(0.4, tabs(&[INFO]), tabs(&[50])); + layout.floating = vec![Floating::new(tree.clone(), [700.0, 100.0, 600.0, 400.0])]; + let mut d = driver(layout, Film::default()); + let locations: Vec<_> = [INFO, 50].into_iter().map(|p| (p, d.layout.location(&p).unwrap())).collect(); + let close = d.hit(&Hit::Close(INFO)).unwrap().center(); + let out = d.click(close); + assert_eq!(out.actions, vec![Action::CloseWindow { panel: INFO }]); + assert!(d.layout.floating.is_empty() && !d.layout.contains(&50)); + for (panel, location) in &locations { + d.layout.restore(*panel, location).unwrap(); + } + let window = &d.layout.floating[0]; + assert_eq!((&window.root, window.rect), (&tree, [700.0, 100.0, 600.0, 400.0])); +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 1595ef3e..0de4119a 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -821,6 +821,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { places, contents: content_index, dividers: Vec::new(), + tree: None, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 87b2cb0a..61c45051 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -29,8 +29,8 @@ fn floating_brush_panel_joins_main_tabs_without_losing_other_panels() { let Node::Split { size, second, .. } = layout.root.as_ref().unwrap() else { panic!() }; assert_eq!(*size, SplitSize::FixedSecond(240.0)); assert_eq!(**second, tabs(&[2, 3, 5, 4], 2)); - assert_eq!(layout.floating[0].groups[0].panels, vec![6]); - assert_eq!(layout.floating[0].groups[0].active, 0); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups()[0].active, 0); layout.validate().unwrap(); assert_eq!(layout.panels().len(), 6); } @@ -43,8 +43,8 @@ fn detach_redock_merge_and_collapse_empty_source_containers() { layout.apply(move_to(1, 2, Zone::Left)).unwrap(); assert_eq!(layout.floating.len(), 1); layout.apply(move_to(5, 6, Zone::Center)).unwrap(); - assert_eq!(layout.floating[0].groups[0].panels, vec![6, 5]); - assert_eq!(layout.floating[0].groups[0].active, 1); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6, 5]); + assert_eq!(layout.floating[0].groups()[0].active, 1); layout.apply(move_to(6, 3, Zone::Center)).unwrap(); layout.apply(move_to(5, 3, Zone::Center)).unwrap(); assert!(layout.floating.is_empty()); @@ -82,7 +82,7 @@ fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() { for action in [ move_to(5, 99, Zone::Center), move_to(99, 2, Zone::Center), - move_to(5, 6, Zone::Left), + Action::Move { panel: 5, anchor: 6, placement: Placement::Column { after: true } }, move_to(5, 5, Zone::Center), Action::Move { panel: 5, anchor: 2, placement: Placement::Tab { before: Some(6) } }, Action::Float { panel: 3, rect: [f32::NAN, 0.0, 100.0, 100.0] }, @@ -208,12 +208,18 @@ fn closing_and_reopening_an_entire_accordion_restores_its_kind_and_heights() { #[test] fn invalid_saved_layouts_reject_duplicates_selection_depth_and_nonfinite_geometry() { let mut duplicate = sample(); - duplicate.floating[0].groups[0].panels.push(1); + if let Node::Tabs { panels, .. } = &mut duplicate.floating[0].root { + panels.push(1); + } assert_eq!(duplicate.validate(), Err(DockError::DuplicatePanel)); let mut invalid = sample(); - invalid.floating[0].groups[0].active = usize::MAX; + if let Node::Tabs { active, .. } = &mut invalid.floating[0].root { + *active = usize::MAX; + } assert_eq!(invalid.validate(), Err(DockError::InvalidSelection)); - invalid.floating[0].groups[0].active = 0; + if let Node::Tabs { active, .. } = &mut invalid.floating[0].root { + *active = 0; + } invalid.floating[0].rect[3] = f32::INFINITY; assert_eq!(invalid.validate(), Err(DockError::InvalidGeometry)); let mut deep = tabs(&[0], 0); @@ -715,8 +721,8 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]); assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }))); - assert_eq!(layout.floating[0].groups[0].panels, original.groups[0].panels); - assert_eq!(layout.floating[0].groups[0].active, original.groups[0].active); + assert_eq!(layout.floating[0].groups()[0].panels, original.groups()[0].panels); + assert_eq!(layout.floating[0].groups()[0].active, original.groups()[0].active); assert_ne!(layout.floating[0].rect, original.rect); step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]); for _ in 0..2 { @@ -731,7 +737,7 @@ fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() { let detached = step(&ctx, &mut layout, vec![pointer_button(outside, false)]); assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. }))); assert_eq!(layout.floating.len(), 2); - assert_eq!(layout.floating[0].groups[0].panels, vec![6]); + assert_eq!(layout.floating[0].groups()[0].panels, vec![6]); } #[test] diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index b7f2e432..d16cf98d 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -146,7 +146,7 @@ fn drag_bar_draws_no_mark_at_rest_and_a_group_drag_paints_a_filled_labeled_ghost fn double_click_on_a_tab_of_a_minimized_group_ends_expanded() { let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0])] }; let mut d = Driver::new(layout); - let minimized = |d: &Driver| d.layout.floating[0].groups[0].minimized; + let minimized = |d: &Driver| d.layout.floating[0].minimized; let active = d.tab(6).center(); d.click(active); d.click(active); @@ -157,7 +157,7 @@ fn double_click_on_a_tab_of_a_minimized_group_ends_expanded() { d.click(other); d.click(other); assert!(!minimized(&d)); - assert_eq!(d.layout.floating[0].groups[0].active_panel(), Some(&5)); + assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5)); } #[test] @@ -364,8 +364,8 @@ fn for_zone_maps_side_zones_onto_columns_groups_and_splits() { assert_eq!(zone(2, Zone::Right, true), Ok(Placement::Column { after: false })); assert_eq!(zone(4, Zone::Bottom, false), Ok(Placement::Group { below: true, height: None })); assert_eq!(zone(2, Zone::Center, false), Ok(Placement::Split(Zone::Center))); - assert_eq!(zone(5, Zone::Top, false), Ok(Placement::Group { below: false, height: None })); - assert_eq!(zone(5, Zone::Left, false), Err(DockError::InvalidPlacement)); + assert_eq!(zone(5, Zone::Top, false), Ok(Placement::Split(Zone::Top))); + assert_eq!(zone(5, Zone::Left, false), Ok(Placement::Split(Zone::Left))); assert_eq!(zone(0, Zone::Left, true), Ok(Placement::Split(Zone::Left))); assert_eq!(zone(9, Zone::Left, false), Err(DockError::MissingTarget)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index f92d1aa9..26b58eae 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -37,8 +37,12 @@ their sides. | `Stack` | Accordion entries, each open or closed, with an optional height. | | `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | -A `Floating` window has one or more stacked groups, a rect, and an optional native-window rect. -`Floating::single` makes a window with one group; `panels()` and `contains()` read it. +A `Floating` window holds a tree like the docked layout's (`root`: splits, tab groups and +accordions, no docks), a rect, an optional native-window rect and `minimized`. +`Floating::single` makes a window with one group and `Floating::new` one with a tree; `panels()` +and `contains()` read it. Saved layouts keep loading: a window stored as one group (v1) or as a +stack of groups loads as that group or as a vertical split of the stacked groups, sized by their +heights. `P` is the app's panel id: stable, not localized, `Clone + Eq + Hash`. The renderer also needs `Debug + Send + Sync + 'static`. A panel occurs at most once in a layout. @@ -68,12 +72,16 @@ layout.apply(Action::Move { - Containers take different placements: a dock column takes groups and columns, not splits. `Placement::for_zone(layout, anchor, zone, right_to_left)` maps a left, right, top, bottom or center zone onto what the anchor's container takes: a split in the frame tree, a new column on - that side or a new group above or below in a dock, a group above or below in a floating window. + that side or a new group above or below in a dock. A floating window takes splits as the + docked layout does; `Placement::Group` on a floating anchor splits its group above or below. - A side split zone puts what moves in a new half-size split of the anchor's frame, and no other frame changes size. The new split has its own divider, and the drop preview shows the half the new frame takes. -- Other actions select, minimize, collapse to icons, resize splits, columns and groups, and move - a floating window to its own OS window (`SetNative`, feature `native-windows`). +- Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked + layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and + move a floating window to its own OS window (`SetNative`, feature `native-windows`). +- A move between windows (docked to floating, floating to floating) is one action, checked as a + whole like any other. Every action checks the whole result before it commits. A bad target, a duplicate id, a stale split path or invalid geometry returns a `DockError` and leaves the layout unchanged. The source @@ -188,6 +196,14 @@ A flyout fits its content the same way, without the title bar. the whole group. A column bar drags a column. A floating window's title bar drags the window; with `title_bar_docks` a release over a target docks the whole window there, else it only moves. +- Inside a floating window, tabs and drag bars drag and dock as they do in the docked layout, + into any window. Its groups split, join and resize like frames, with dividers and four-way + handles (`Divider::WindowSplit` and `Divider::WindowIntersection`, named by the window's first + panel). Double-clicking the title bar minimizes the window to its title bar, or to the tab strip + of its only group; so does double-clicking that group's strip. The group menu's float item + floats one group of a window that has several. Closing the window closes its panels; each + reopens where it was while the window or its neighbour in it is open, else in a window at the + window's last rect. - A movable floating window resizes from every edge and corner. The top edge and corners are a 4 pt band above the title bar (`chrome::RESIZE_BAND`), so they take no moves and no clicks on the title bar's buttons. A resize keeps the opposite edges in place and stays within the size From 281ea71f5b55258bcd3b768eb5b3b6cbcd20ab24 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:20:26 +0000 Subject: [PATCH 085/160] docking: the drop preview shows the zone under the pointer With side_edge_zones, a drop on a group fills the zone the pointer is in (a side's trapezoid, or the center rectangle with the tab strip) and outlines the rect the drop gives what moves. zone_shape gives a zone's outline; DockDriver::filled_polygon checks it. The dock-and-canvas arrangement keeps its filled half and insertion lines. --- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/render/preview.rs | 27 +++++++++++++-- crates/ui/src/docking/split_tests.rs | 46 +++++++++++++++++++++++-- crates/ui/src/docking/targets.rs | 29 ++++++++++++++-- crates/ui/src/docking/testing.rs | 12 +++++++ docs/ui-docking.md | 5 ++- 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 2587dca0..dd4b50bc 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -30,7 +30,7 @@ pub use surface::{ DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, float_space, is_dragging, }; -pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect}; +pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; use surface::{Drag, GroupDrag, GroupGesture}; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0189f810..cabb8cdc 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -220,7 +220,7 @@ impl Renderer<'_, P> { } if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); - if crate::docking::targets::usable(proposal.preview) { + if crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); } if let Some(marker) = proposal.insertion { @@ -322,7 +322,7 @@ impl Renderer<'_, P> { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) { + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, true); } if let Some(marker) = proposal.insertion { @@ -336,6 +336,29 @@ impl Renderer<'_, P> { } } + /// With [`crate::docking::DockStyle::side_edge_zones`], a drop on a group shows the zone + /// under the pointer (a side's trapezoid, or the center rectangle with the tab strip) + /// filled, and the rect the drop gives what moves outlined. False when it painted nothing. + fn paint_zone(&self, painter: &egui::Painter, proposal: &DropProposal

, pointer: Pos2) -> bool { + let zone = match proposal.placement { + Placement::Split(zone) => zone, + Placement::Tab { .. } => Zone::Center, + _ => return false, + }; + let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer)); + let (Some(target), true) = (target, self.style.side_edge_zones) else { return false }; + let Some(points) = crate::docking::targets::zone_shape(target.rect, zone) else { return false }; + let fill = self.style.accent.gamma_multiply(0.25); + painter.add(egui::Shape::convex_polygon(points, fill, egui::Stroke::NONE)); + if zone == Zone::Center + && let Some(strip) = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|r| crate::docking::targets::usable(*r)) + { + painter.rect_filled(strip, 0.0, fill); + } + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, false); + true + } + /// The single drop preview layer, above every floating window. pub(super) fn preview_painter(&self, ctx: &Context) -> egui::Painter { let layer = egui::LayerId::new(egui::Order::Tooltip, self.id.with("drop-preview")); diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 69e1313d..21bfcc16 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -133,7 +133,7 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let at = inset(zone); d.hold(tab(&d, INFO), at); let half = zone_rect(timeline, zone).unwrap(); - assert!(d.filled(half.shrink(1.0)), "{zone:?}: the preview is that half of the group"); + assert!(d.outlined(half.shrink(1.0)), "{zone:?}: the preview outlines that half of the group"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}"); d.settle(); @@ -275,7 +275,7 @@ fn a_drop_beside_a_narrow_group_splits_that_group_only() { assert!(tools.width() > 120.0, "{tools:?}"); let at = pos2(tools.right() - 8.0, tools.center().y + 13.0); d.hold(tab(&d, INFO), at); - assert!(d.filled(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview is Tools' right half"); + assert!(d.outlined(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview outlines Tools' right half"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]); d.settle(); @@ -610,3 +610,45 @@ fn a_closed_floating_window_closes_its_panels_and_each_reopens_where_it_was() { let window = &d.layout.floating[0]; assert_eq!((&window.root, window.rect), (&tree, [700.0, 100.0, 600.0, 400.0])); } + +#[test] +fn the_preview_fills_the_zone_under_the_pointer_and_outlines_the_result() { + let d = film(); + let timeline = group(&d, TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + // A side band: its trapezoid, and the half the panel takes. + for (zone, at) in [ + (Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), + (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0)), + ] { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, zone).unwrap()), "{zone:?}"); + assert!(d.outlined(zone_rect(timeline, zone).unwrap().shrink(1.0)), "{zone:?}"); + d.key(egui::Key::Escape); + } + // The middle and the tab row: the center rectangle and the strip, and the whole group. + for at in [timeline.center() + vec2(0.0, 13.0), strip.center()] { + let mut d = film(); + d.hold(tab(&d, INFO), at); + assert!(d.filled_polygon(&zone_shape(timeline, Zone::Center).unwrap()), "{at:?}"); + assert!(d.filled(strip), "{at:?}"); + assert!(d.outlined(timeline.shrink(1.0)), "{at:?}"); + d.key(egui::Key::Escape); + } + // A group's drag bar shows the same. + let mut d = film(); + let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); + d.hold(bar, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + d.key(egui::Key::Escape); + + // The dock-and-canvas arrangement keeps its filled half. + let mut d = DockDriver::new(editing(), Film::default()); + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + d.area = d.screen; + d.settle(); + d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); + assert!(!d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + assert!(d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); +} diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index ff515ec0..7a816f1a 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,4 +1,4 @@ -use egui::{Pos2, Rangef, Rect, pos2}; +use egui::{Pos2, Rangef, Rect, Vec2, pos2, vec2}; use super::{Placement, Side, Target, Zone}; @@ -45,8 +45,7 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { if pointer.y < rect.top() + tab_height.min(rect.height()) { return Some(Zone::Center); } - let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); - let (x, y) = (depth(rect.width()), depth(rect.height())); + let Vec2 { x, y } = side_depths(rect); let distances = [ ((pointer.x - rect.left()) / x, Zone::Left), ((rect.right() - pointer.x) / x, Zone::Right), @@ -57,6 +56,30 @@ pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { Some(if distance > 1.0 { Zone::Center } else { zone }) } +/// How deep the side zones of a group drawn at `rect` reach in from its left and right edges +/// (`x`) and its top and bottom edges (`y`). +fn side_depths(rect: Rect) -> Vec2 { + let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); + vec2(depth(rect.width()), depth(rect.height())) +} + +/// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it: a side's +/// trapezoid (its outer edge the whole side, its inner edge on the center rectangle), or the +/// center rectangle. The tab strip is a grouping zone too. +pub fn zone_shape(rect: Rect, zone: Zone) -> Option> { + if !usable(rect) { + return None; + } + let inner = rect.shrink2(side_depths(rect)); + Some(match zone { + Zone::Left => vec![rect.left_top(), inner.left_top(), inner.left_bottom(), rect.left_bottom()], + Zone::Right => vec![inner.right_top(), rect.right_top(), rect.right_bottom(), inner.right_bottom()], + Zone::Top => vec![rect.left_top(), rect.right_top(), inner.right_top(), inner.left_top()], + Zone::Bottom => vec![inner.left_bottom(), inner.right_bottom(), rect.right_bottom(), rect.left_bottom()], + Zone::Center => vec![inner.left_top(), inner.right_top(), inner.right_bottom(), inner.left_bottom()], + }) +} + /// Preview of the region occupied after a split or group merge. pub fn zone_rect(rect: Rect, zone: Zone) -> Option { if !usable(rect) { diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 868a6c4c..81db4769 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -215,6 +215,18 @@ impl> Do }) } + /// Whether the last frame filled a polygon with these corners, each within half a point. + pub fn filled_polygon(&self, corners: &[Pos2]) -> bool { + self.shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Path(path) => { + path.fill != egui::Color32::TRANSPARENT + && path.points.len() == corners.len() + && path.points.iter().zip(corners).all(|(a, b)| a.distance(*b) <= 0.5) + } + _ => false, + }) + } + /// Whether the last frame outlined `rect` without filling it. pub fn outlined(&self, rect: Rect) -> bool { self.shapes.iter().any(|clipped| match &clipped.shape { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 26b58eae..adaec4ab 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -224,7 +224,10 @@ A flyout fits its content the same way, without the title bar. for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, - a filled half for a split. Escape or losing window focus cancels a drag. + a filled half for a split. With `side_edge_zones`, a drop on a group instead fills the zone + under the pointer (a side's trapezoid, or the center rectangle and the tab strip; `zone_shape` + gives the outline) and outlines the rect the drop gives what moves. Escape or losing window + focus cancels a drag. - With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such as a panel dropped on a protected canvas. Without it, that release cancels the drag and nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release From deec5af9cf6060f731573b836fae4cb4f683ea2a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:22:00 +0000 Subject: [PATCH 086/160] docking: random drops reach floating windows and move panels between windows The property test starts with a split floating window beside Info's, drags tabs and drag bars into, out of and between windows, floats some with Ctrl/Cmd, and checks after every drop that no panel is lost, every group in every window keeps its minimum and every divider can travel both ways. --- crates/ui/src/docking/split_tests.rs | 100 ++++++++++++++++++--------- 1 file changed, 67 insertions(+), 33 deletions(-) diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 21bfcc16..9ad5e5c5 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -298,49 +298,71 @@ fn every_divider_travels_after_drops() { assert_dividers_travel(&mut d); } -/// Each split divider moves both ways when dragged, from the layout as it is. +/// Each split divider, in the docked layout and in floating windows, moves both ways when +/// dragged, from the layout as it is. fn assert_dividers_travel(d: &mut Driver) { let start = d.layout.clone(); - let paths: Vec> = d + let dividers: Vec> = d .hits() .into_iter() - .filter_map(|(hit, _)| match hit { - Hit::Divider(Divider::Split(path)) => Some(path), - _ => None, + .filter_map(|(hit, _)| matches!(hit, Hit::Divider(Divider::Split(_) | Divider::WindowSplit(..))).then_some(hit)) + .collect(); + assert!(!dividers.is_empty()); + // A floating window may cover the dividers of the main area and of other windows. + let covers: Vec<(Rect, &Floating)> = start + .floating + .iter() + .map(|w| { + let drawn = w.panels().filter(|p| d.content.bodies.contains_key(p)).map(|p| group(d, *p)); + let rect = Rect::from_min_size(pos2(w.rect[0], w.rect[1]), vec2(w.rect[2], w.rect[3])); + (drawn.fold(rect, |a, b| a.union(b)), w) }) .collect(); - assert!(!paths.is_empty()); - for path in paths { + for hit in dividers { + let own = |w: &Floating| matches!(&hit, Hit::Divider(Divider::WindowSplit(key, _)) if w.contains(key)); + if d.hit(&hit).is_some_and(|r| covers.iter().any(|(c, w)| c.intersects(r) && !own(w))) { + continue; + } for step in [12.0, -24.0] { - let Some(rect) = d.hit(÷r(&path)) else { continue }; + let Some(rect) = d.hit(&hit) else { continue }; let delta = if rect.width() < rect.height() { vec2(step, 0.0) } else { vec2(0.0, step) }; let before = d.layout.clone(); - drag_hit(d, divider(&path), delta); - assert_ne!(d.layout, before, "divider {path:?} does not move by {step}"); + drag_hit(d, hit.clone(), delta); + assert_ne!(d.layout, before, "divider {hit:?} does not move by {step}"); } d.layout = start.clone(); d.settle(); } } -/// Every group at or above its minimum, and every divider with room both ways. +/// Every group at or above its minimum, and every divider with room both ways, in the docked +/// layout and in each floating window. Reads the bodies drawn last. fn assert_sizes(d: &Driver, context: &str) { let mut style = DockStyle::default(); frame_style(&mut style); - let Some(root) = &d.layout.root else { return }; let sizer = Sizer { style: &style, content: &d.content }; - let mut groups: Vec> = Vec::new(); - sizer.groups(root, d.area, &mut groups); - for g in &groups { - let room = g.rect.size() - g.limits.min; - assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min); + let mut trees: Vec<(&Node, Rect)> = d.layout.root.iter().map(|root| (root, d.area)).collect(); + for window in d.layout.floating.iter().filter(|w| !w.minimized) { + // The window's body is the union of its groups. + let drawn = window.panels().filter(|p| d.content.bodies.contains_key(p)).map(|p| group(d, *p)); + if let Some(rect) = drawn.reduce(|a, b| a.union(b)) { + trees.push((&window.root, rect)); + } } - let mut nodes = vec![(root, d.area)]; - while let Some((node, rect)) = nodes.pop() { - let Some(children) = sizer.children(node, rect) else { continue }; - let (lo, hi) = sizer.range(node, rect); - assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout); - nodes.extend(children); + for (root, area) in trees { + let mut groups: Vec> = Vec::new(); + sizer.groups(root, area, &mut groups); + for g in &groups { + let room = g.rect.size() - g.limits.min; + assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min); + } + let mut nodes = vec![(root, area)]; + while let Some((node, rect)) = nodes.pop() { + let Some(children) = sizer.children(node, rect) else { continue }; + let (lo, hi) = sizer.range(node, rect); + assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout); + nodes.extend(children); + } } } @@ -362,16 +384,25 @@ impl Lcg { #[test] fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() { + // Drops land in the docked layout and in two floating windows, one of them split; tabs and + // drag bars move panels and groups between all three, and Ctrl/Cmd floats some. + let windows = || { + let mut layout = editing(); + layout.floating.push(Floating::new(hsplit(0.5, tabs(&[8]), tabs(&[50, 51])), [80.0, 420.0, 640.0, 380.0])); + layout + }; for seed in 1..=6u64 { let mut rng = Lcg(seed); - let mut d = film(); - for step in 0..14 { + let mut d = driver(windows(), Film::default()); + let all = panel_set(&d.layout); + for step in 0..16 { // Only the bodies drawn now. d.content.bodies.clear(); d.settle(); let panels: Vec = d.layout.panels().into_iter().copied().collect(); let panel = panels[rng.below(panels.len())]; - let Some(from) = d.hit(&Hit::Tab(panel)).map(|r| r.center()) else { continue }; + let bar = d.hit(&Hit::DragBar(panel)).filter(|_| rng.below(3) == 0); + let Some(from) = bar.or_else(|| d.hit(&Hit::Tab(panel))).map(|r| r.center()) else { continue }; let target = panels[rng.below(panels.len())]; let Some(rect) = d.content.bodies.get(&target).copied() else { continue }; let to = match rng.below(8) { @@ -390,14 +421,17 @@ fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() { // Anywhere in the body: an edge band or the middle. _ => rect.lerp_inside(vec2(0.02 + rng.unit() * 0.96, 0.02 + rng.unit() * 0.96)), }; - d.drag(from, to); - d.settle(); - if d.layout.floating.iter().any(|w| w.contains(&panel)) && panel != INFO { - // Back into the tree: this test is about the docked layout. - d.layout.apply(Action::Move { panel, anchor: target, placement: Placement::Split(Zone::Center) }).ok(); - d.settle(); + if rng.below(12) == 0 { + d.modifiers = Modifiers::COMMAND; } - assert_sizes(&d, &format!("seed {seed}, step {step}")); + d.drag(from, to); + d.modifiers = Modifiers::NONE; + d.content.bodies.clear(); + d.settle(); + let context = format!("seed {seed}, step {step}"); + assert_eq!(d.layout.validate(), Ok(()), "{context}"); + assert_eq!(panel_set(&d.layout), all, "{context}: a panel was lost"); + assert_sizes(&d, &context); } assert_dividers_travel(&mut d); } From 9ff107c2c182fbd30edffd446fb8ff2a55f2c11c Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:23:27 +0000 Subject: [PATCH 087/160] docking: rustfmt, and doc comments for floating trees --- crates/ui/src/docking/command.rs | 5 +++-- crates/ui/src/docking/dock_model.rs | 1 + crates/ui/src/docking/floating_tests.rs | 5 ++++- crates/ui/src/docking/hits.rs | 2 +- crates/ui/src/docking/model.rs | 19 +++++++++++-------- crates/ui/src/docking/render/dock.rs | 6 +----- crates/ui/src/docking/render/preview.rs | 8 +++----- crates/ui/src/docking/split_tests.rs | 7 +++---- crates/ui/src/docking/surface.rs | 3 +-- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 132d8539..36b84bb8 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -40,7 +40,7 @@ use crate::layout::SplitSize; /// | `ui.dock` | `{action}`, or `{operation, …}`, see [`DockOperation`] | /// /// `zone` is `tab` (the default for `move` and `dock`), `center`, `left`, `right`, `top`, -/// `bottom`, `above` or `below` (a new group in a dock column or floating stack). The side +/// `bottom`, `above` or `below` (a new group in a dock column, or a split in a floating window). The side /// zones map onto the anchor's container with [`Placement::for_zone`]. `before` /// names the tab to insert before; `null` appends. `group` moves the panel's whole group, /// `column` its dock column. `side` is `Start` or `End`. @@ -369,7 +369,8 @@ impl DockCommand

{ let any = given.iter().any(Option::is_some); let unit = Unit::of(*group, *column); let lone = layout.floating.iter().any(|w| w.contains(panel) && w.panels().count() == 1); - let whole = layout.floating.iter().any(|w| w.contains(panel) && matches!(w.root, super::Node::Tabs { .. } | super::Node::Stack { .. })); + let whole = + layout.floating.iter().any(|w| w.contains(panel) && matches!(w.root, super::Node::Tabs { .. } | super::Node::Stack { .. })); // The rect a drag would float the unit at, inside the area. let placed = || { let docked = layout.location(panel).ok().and_then(|l| l.group).and_then(|g| g.column).map(|c| c.width); diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 9226bb4f..540c35e2 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -264,6 +264,7 @@ pub(super) fn tree_panels

(root: &Node

) -> Vec<&P> { } /// The shown panel of a tree's first group. +#[cfg(feature = "native-windows")] pub(super) fn first_active

(root: &Node

) -> Option<&P> { match leaves(root).into_iter().next()?.0 { Node::Tabs { panels, active } => panels.get(*active), diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 82480948..f930aed4 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -676,7 +676,10 @@ fn top_edges_resize_from_the_band_above_the_title_bar() { #[test] fn undock_panel_group_floats_one_group_of_a_floating_window() { use egui_kittest::{Harness, kittest::Queryable}; - let stacked = || Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0])] }; + let stacked = || Layout { + root: Some(tabs(&[1], 0)), + floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0])], + }; type State = (Content, Layout, Vec>); let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( |ui, (content, layout, actions): &mut State| { diff --git a/crates/ui/src/docking/hits.rs b/crates/ui/src/docking/hits.rs index 28bcf65d..3e59e022 100644 --- a/crates/ui/src/docking/hits.rs +++ b/crates/ui/src/docking/hits.rs @@ -66,7 +66,7 @@ pub enum Divider

{ Stack(P), /// [D] The inner edge of the column holding this panel first. Column(P), - /// Below the dock or floating group holding this panel first. + /// Below the dock group holding this panel first. Group(P), /// Where the divider of a split inside another split ends on the outer split's divider, /// by the outer and the inner split's paths. Dragging it moves both dividers. diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 0a88dc73..23efd9ea 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -1,7 +1,7 @@ use std::{collections::HashSet, fmt, hash::Hash}; use super::dock_model::{ - Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, stack_tree, tree_panels, valid_column, + Column, ColumnLocation, Group, GroupLocation, Moving, PanelState, Place, Side, Target, Unit, node_at, split_frame, tree_panels, valid_column, }; use crate::layout::{SplitAxis, SplitSize}; @@ -90,7 +90,7 @@ impl

Floating

{ /// Groups stacked top to bottom, as a v1 layout stored them. #[cfg(test)] pub(crate) fn stack(groups: Vec>, rect: [f32; 4]) -> Self { - Self::new(stack_tree(groups).expect("a group"), rect) + Self::new(super::dock_model::stack_tree(groups).expect("a group"), rect) } /// The window's groups, first to last. @@ -116,7 +116,8 @@ impl

Floating

{ mod floating_serde { use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use super::{Floating, Group, Node, stack_tree}; + use super::{Floating, Group, Node}; + use crate::docking::dock_model::stack_tree; #[derive(Serialize)] struct V1<'a, P> { @@ -204,7 +205,8 @@ pub enum Placement

{ /// along the same axis keeps it: on its outer side the new split wraps the parent, on its /// inner side it halves the parent's other child. Split(Zone), - /// A new group above or below the anchor's group, in a dock column or a floating stack. + /// A new group above or below the anchor's group in a dock column. On an anchor in a + /// floating window, a split of the anchor's group above or below. /// `height` is the inserted group's preferred height ([`super::Group::height`]); `None` /// keeps a moved group's own height, or shares the free height for a new group. Group { @@ -236,7 +238,7 @@ pub struct Location

{ /// rather than accidentally inserting beside only its first leaf when restoring. #[cfg_attr(feature = "serde", serde(default))] pub sibling: Vec

, - /// Group and column state when the panel was in a dock or a floating stack. Applies when + /// Group and column state when the panel was in a dock. Applies when /// restoring creates a new group, column or dock for the panel. #[cfg_attr(feature = "serde", serde(default))] pub group: Option, @@ -273,7 +275,7 @@ pub enum Action

{ anchor: P, placement: Placement

, }, - /// Reposition/resize a floating group, identified by one of its panels. + /// Reposition/resize a floating window, identified by one of its panels. MoveFloating { panel: P, rect: [f32; 4], @@ -327,7 +329,8 @@ pub enum Action

{ CloseOthers { panel: P, }, - /// Minimize a dock or floating group to its tab strip. On an accordion entry, closes it. + /// Minimize a dock group to its tab strip, or the floating window holding the panel to its + /// title bar (and the strip of its only group). On an accordion entry, closes it. SetMinimized { panel: P, minimized: bool, @@ -340,7 +343,7 @@ pub enum Action

{ panel: P, width: f32, }, - /// Preferred height of a dock or floating group, or of an accordion entry. + /// Preferred height of a dock group or of an accordion entry. ResizeGroup { panel: P, height: Option, diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index eb4e4934..55336dca 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -472,11 +472,7 @@ impl Renderer<'_, P> { fn dock_covered(&self, pointer: Pos2, moving: Option<&P>) -> bool { self.blockers.iter().any(|r| r.contains(pointer)) || self.iconic.covers(pointer) - || self - .frame - .windows - .iter() - .any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.members.contains(p))) + || self.frame.windows.iter().any(|window| window.window.contains(pointer) && !moving.is_some_and(|p| window.members.contains(p))) } /// Dock targets, with the new-dock bands along the docked area's left and right edges that diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index cabb8cdc..96fc1120 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -6,9 +6,7 @@ use super::{DROP_MARGIN, Drawn, Renderer, clamp_float, dock::group_action}; use crate::docking::chrome::drop_preview; use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, - dock_model::Place, - resolve_drop, - surface::TAB_PADDING, + dock_model::Place, resolve_drop, surface::TAB_PADDING, }; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. @@ -259,8 +257,8 @@ impl Renderer<'_, P> { } let float_only = float_only(ctx) || drag.undock; let outside = gone || !ctx.content_rect().contains(pointer); - let empty_drop = self.style.float_on_empty_drop - && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); + let empty_drop = + self.style.float_on_empty_drop && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); let floatable = self.permissions(&drag.panel).floatable && (float_only || outside || empty_drop); let floating_rect = floatable.then(|| self.tab_float_rect(&drag, pointer)); if self.host_drop(ctx, &drag.panel, pointer, false, released, floating_rect) { diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 9ad5e5c5..c9485557 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -651,10 +651,9 @@ fn the_preview_fills_the_zone_under_the_pointer_and_outlines_the_result() { let timeline = group(&d, TIMELINE); let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); // A side band: its trapezoid, and the half the panel takes. - for (zone, at) in [ - (Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), - (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0)), - ] { + for (zone, at) in + [(Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0))] + { let mut d = film(); d.hold(tab(&d, INFO), at); assert!(d.filled_polygon(&zone_shape(timeline, zone).unwrap()), "{zone:?}"); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 0de4119a..2bfdb4db 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -56,8 +56,7 @@ pub struct DockStyle { pub new_dock_zones: bool, /// [D] Height of the bar above each dock column. It drags the column and holds its »/« button. pub column_bar_height: f32, - /// [D] Height of the band around the seam between two stacked groups, in docks and - /// floating windows. + /// [D] Height of the band around the seam between two stacked groups in a dock column. pub seam_band: f32, /// [D] Width of the band along the docked area's left and right edges that makes a new dock. pub new_dock_band: f32, From 01ce7162a48dfca1026ea4e0da26afcb312564d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 06:11:03 +0000 Subject: [PATCH 088/160] docking: Floating::stack is public, and four-way handles take the pointer only Floating::stack builds a window from groups stacked top to bottom, so an app's own stacked layouts migrate without building the split chain. The four-way handle no longer takes keyboard focus: it has no label or key action, and the two dividers it joins take arrow keys. --- crates/ui/src/docking/floating_tests.rs | 4 ++-- crates/ui/src/docking/group_tests.rs | 2 +- crates/ui/src/docking/iconic_tests.rs | 2 +- crates/ui/src/docking/model.rs | 8 ++++---- crates/ui/src/docking/model_tests.rs | 6 +++--- crates/ui/src/docking/render/mod.rs | 3 ++- docs/ui-docking.md | 5 +++-- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index f930aed4..9217965e 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -38,7 +38,7 @@ fn workspace() -> Layout { second: Box::new(tabs(&[0], 0)), }), floating: vec![ - Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [300.0, 80.0, 240.0, 300.0]), + Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [300.0, 80.0, 240.0, 300.0]).unwrap(), Floating::single(vec![8], 0, [560.0, 300.0, 200.0, 200.0]), ], } @@ -678,7 +678,7 @@ fn undock_panel_group_floats_one_group_of_a_floating_window() { use egui_kittest::{Harness, kittest::Queryable}; let stacked = || Layout { root: Some(tabs(&[1], 0)), - floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0])], + floating: vec![Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 0)], [300.0, 80.0, 300.0, 400.0]).unwrap()], }; type State = (Content, Layout, Vec>); let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index a11e2796..f6027aed 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -551,7 +551,7 @@ fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() { #[test] fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() { - let stack = Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [80.0, 70.0, 300.0, 400.0]); + let stack = Floating::stack(vec![Group::new(vec![5], 0), Group::new(vec![6, 7], 1)], [80.0, 70.0, 300.0, 400.0]).unwrap(); let layout = Layout { root: Some(tabs(&[1], 0)), floating: vec![stack] }; let mut d = Driver::new(layout); let from = d.rect("drag-bar", 6).center(); diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index bc946daf..c648e43e 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -283,7 +283,7 @@ fn hit_rects_cover_every_gesture_source() { first: Box::new(Node::Tabs { panels: vec![0], active: 0 }), second: Box::new(stack), }; - let window = Floating::stack(vec![Group::new(vec![12], 0), Group::new(vec![13], 0)], [500.0, 60.0, 220.0, 300.0]); + let window = Floating::stack(vec![Group::new(vec![12], 0), Group::new(vec![13], 0)], [500.0, 60.0, 220.0, 300.0]).unwrap(); let layout = Layout { root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(200.0), first: Box::new(dock), second: Box::new(frames) }), floating: vec![window], diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index e11a0417..ec4cb137 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -87,10 +87,10 @@ impl

Floating

{ tree_panels(&self.root).into_iter() } - /// Groups stacked top to bottom, as a v1 layout stored them. - #[cfg(test)] - pub(crate) fn stack(groups: Vec>, rect: [f32; 4]) -> Self { - Self::new(super::dock_model::stack_tree(groups).expect("a group"), rect) + /// Groups stacked top to bottom, as a v1 layout stored them: each group over a vertical + /// split of the ones below it, sized by their heights. `None` without groups. + pub fn stack(groups: Vec>, rect: [f32; 4]) -> Option { + super::dock_model::stack_tree(groups).map(|root| Self::new(root, rect)) } /// The window's groups, first to last. diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 583a2dd9..11f3fcde 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -23,7 +23,7 @@ pub(super) fn docked() -> Layout { Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2, 3], 1), g(&[4], 0)], 180.0), Column::new(vec![g(&[5], 0)], 120.0)] }; Layout { root: Some(hsplit(SplitSize::FixedSecond(300.0), middle, dock)), - floating: vec![Floating::stack(vec![g(&[6, 7], 1), g(&[8], 0)], [100.0, 100.0, 300.0, 400.0])], + floating: vec![Floating::stack(vec![g(&[6, 7], 1), g(&[8], 0)], [100.0, 100.0, 300.0, 400.0]).unwrap()], } } @@ -213,7 +213,7 @@ fn restore_into_a_column_group_seam_and_new_column_round_trips() { let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![g(&[10], 0)], 200.0)] }; let original = Layout { root: Some(hsplit(SplitSize::FixedFirst(200.0), start, hsplit(SplitSize::FixedSecond(300.0), tabs(&[1], 0), end))), - floating: vec![Floating::stack(vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], [10.0, 10.0, 200.0, 300.0])], + floating: vec![Floating::stack(vec![g(&[6], 0), Group { height: Some(90.0), ..g(&[7], 0) }], [10.0, 10.0, 200.0, 300.0]).unwrap()], }; original.validate().unwrap(); // 4: the seam between [2, 3] and [9]; 9: below [4]; 5: a new column after A; 10: alone in @@ -415,7 +415,7 @@ mod serde_tests { let plain = Floating::single(vec![4u32, 5], 1, rect); assert_eq!(serde_json::to_value(&plain).unwrap(), json!({"panels": [4, 5], "active": 1, "rect": rect})); let minimized = Floating { minimized: true, ..Floating::single(vec![4u32], 0, rect) }; - let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect); + let stacked = Floating::stack(vec![g(&[4], 0), g(&[5], 0)], rect).unwrap(); let native = Floating { native: Some(rect), ..Floating::single(vec![4u32], 0, rect) }; for window in [minimized, stacked, native] { let value = serde_json::to_value(&window).unwrap(); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index fc8fd3d4..f4fbf37e 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -295,7 +295,8 @@ impl Renderer<'_, P> { None => self.id.with(("intersection", outer, inner_layout)), Some((panel, _)) => self.id.with(("window-intersection", panel, outer, inner_layout)), }; - let response = ui.interact(hit, id, Sense::drag()); + // Pointer only: each of the two dividers it joins already takes keyboard focus. + let response = ui.interact(hit, id, Sense::DRAG); self.register_interaction(ui.ctx(), id); if response.hovered() || response.dragged() { ui.ctx().set_cursor_icon(CursorIcon::Move); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index adaec4ab..177c1c2d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -39,8 +39,9 @@ their sides. A `Floating` window holds a tree like the docked layout's (`root`: splits, tab groups and accordions, no docks), a rect, an optional native-window rect and `minimized`. -`Floating::single` makes a window with one group and `Floating::new` one with a tree; `panels()` -and `contains()` read it. Saved layouts keep loading: a window stored as one group (v1) or as a +`Floating::single` makes a window with one group, `Floating::new` one with a tree, and +`Floating::stack` one with groups stacked top to bottom (a vertical split sized by the groups' +heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Saved layouts keep loading: a window stored as one group (v1) or as a stack of groups loads as that group or as a vertical split of the stacked groups, sized by their heights. From 9c869166e851433d744064c3b5be31afb5c77ae2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 08:26:56 +0000 Subject: [PATCH 089/160] docking: floating windows keep a stable id and store their monitor Floating::id names a window across panel moves: fresh from Floating::new and on load, unique within a loaded layout, kept by a whole window that moves and by the receiver of a merge. Floating::monitor and monitor_size record where a native window is; Action::SetMonitor sets them. Equality ignores the id. --- crates/ui/src/docking/dock_model.rs | 17 ++-- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/model.rs | 128 ++++++++++++++++++++++++++-- 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 540c35e2..8fb3ffe0 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -194,11 +194,13 @@ pub(super) struct Moving

{ pub iconic_width: Option, pub rect: Option<[f32; 4]>, pub native: Option<[f32; 4]>, + /// The id of a whole floating window in transit, which a new window keeps. + pub id: Option, } impl

Moving

{ pub fn group(group: Group

) -> Self { - Self { groups: vec![group], node: None, minimized: false, width: None, iconic: false, iconic_width: None, rect: None, native: None } + Self { groups: vec![group], node: None, minimized: false, width: None, iconic: false, iconic_width: None, rect: None, native: None, id: None } } fn into_column(self, width: f32) -> Column

{ @@ -574,10 +576,10 @@ impl Layout

{ let (window, path) = place.tree_path().map(|(w, p)| (w, p.to_vec())).ok_or(DockError::MissingPanel)?; // A group leaving a floating window keeps its width; the window's only group // takes the window's rect along. - let source = window.and_then(|w| self.floating.get(w)).map(|w| (w.rect, w.native, w.minimized)); + let source = window.and_then(|w| self.floating.get(w)).map(|w| (w.rect, w.native, w.minimized, w.id)); let whole = |moving: Moving

| match source { - Some((rect, native, minimized)) if path.is_empty() => { - Moving { width: rect.get(2).copied(), rect: Some(rect), native, minimized, ..moving } + Some((rect, native, minimized, id)) if path.is_empty() => { + Moving { width: rect.get(2).copied(), rect: Some(rect), native, minimized, id: Some(id), ..moving } } Some((rect, ..)) => Moving { width: rect.get(2).copied(), ..moving }, None => moving, @@ -589,7 +591,7 @@ impl Layout

{ let entry = entries.remove(index); let group = Group { panels: vec![entry.panel], active: 0, minimized: !entry.open, height: entry.height }; let moving = whole(Moving::group(group)); - return Ok(Moving { rect: None, native: None, ..moving }); + return Ok(Moving { rect: None, native: None, id: None, ..moving }); } match self.take_node_in(window, &path) { Some(Node::Tabs { panels, active }) => Ok(whole(Moving::group(Group::new(panels, active)))), @@ -638,6 +640,7 @@ impl Layout

{ iconic_width: taken.iconic_width, rect: None, native: None, + id: None, }) } @@ -656,6 +659,7 @@ impl Layout

{ iconic_width: None, rect: Some(taken.rect), native: taken.native, + id: Some(taken.id), }) } @@ -678,7 +682,8 @@ impl Layout

{ Some(node) => node, None => stack_tree(moving.groups).ok_or(DockError::MissingPanel)?, }; - self.floating.push(Floating { root, rect, native, minimized: moving.minimized }); + let window = Floating { native, minimized: moving.minimized, ..Floating::new(root, rect) }; + self.floating.push(Floating { id: moving.id.unwrap_or(window.id), ..window }); Ok(()) } Target::NewDock { side } => { diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index dd4b50bc..45e20828 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -24,7 +24,7 @@ mod workspace; pub use command::{DockCommand, DockOperation, UiDock, ZoneName}; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use hits::{Divider, Hit, ResizeEdge}; -pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; +pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, Monitor, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index ec4cb137..145bc888 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -61,22 +61,69 @@ pub struct StackEntry

{ /// Serde reads the v1 shape `{panels, active, rect}` as one group, and a stack of groups /// `{groups, rect}` as a vertical split of them, sized by their heights. A window with one /// group (not minimized, no native rect) writes the v1 shape, so older builds can read files -/// that use no newer feature. -#[derive(Clone, Debug, PartialEq)] +/// that use no newer feature. A native window also writes its `id`; any other window gets a +/// fresh one on load. +/// +/// Equality ignores `id`: two windows with the same contents and place are the same layout. +#[derive(Clone, Debug)] pub struct Floating

{ + /// Names the window across panel moves, and its OS window with feature `native-windows`. + /// Unique within a layout: [`Self::new`] and loading draw fresh ids, and merging windows + /// keeps the receiving window's. + pub id: u64, /// `Split`, `Tabs` and `Stack` nodes; a `Dock` is invalid here. pub root: Node

, /// `[x, y, width, height]` in logical points, in the application's egui viewport. pub rect: [f32; 4], /// Desktop rect while the window is in its own OS window. pub native: Option<[f32; 4]>, + /// The monitor holding the center of `native`, from [`super::DockArea::monitors`]. + pub monitor: Option, + /// Without a monitor list: the size of the monitor egui reported for the native window. + pub monitor_size: Option<[f32; 2]>, /// Only the title bar shows. pub minimized: bool, } +impl PartialEq for Floating

{ + fn eq(&self, other: &Self) -> bool { + self.root == other.root + && self.rect == other.rect + && self.native == other.native + && self.monitor == other.monitor + && self.monitor_size == other.monitor_size + && self.minimized == other.minimized + } +} + +/// A display, in desktop logical points. Applications on an egui with monitor reports fill +/// [`super::DockArea::monitors`] with these; native floating windows store the one they are on. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Monitor { + pub name: Option, + /// `[x, y, width, height]` on the desktop. + pub rect: [f32; 4], + /// Physical pixels per logical point. + pub scale: f32, +} + +/// A window id no other window of this process has drawn, from a random start so ids saved +/// by earlier runs are unlikely to repeat. +pub(super) fn fresh_window_id() -> u64 { + use std::hash::{BuildHasher, Hasher}; + use std::sync::{ + OnceLock, + atomic::{AtomicU64, Ordering}, + }; + static NEXT: OnceLock = OnceLock::new(); + let next = NEXT.get_or_init(|| AtomicU64::new(std::collections::hash_map::RandomState::new().build_hasher().finish() >> 1)); + next.fetch_add(1, Ordering::Relaxed) +} + impl

Floating

{ pub fn new(root: Node

, rect: [f32; 4]) -> Self { - Self { root, rect, native: None, minimized: false } + Self { id: fresh_window_id(), root, rect, native: None, monitor: None, monitor_size: None, minimized: false } } pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { @@ -116,7 +163,7 @@ impl

Floating

{ mod floating_serde { use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use super::{Floating, Group, Node}; + use super::{Floating, Group, Monitor, Node, fresh_window_id}; use crate::docking::dock_model::stack_tree; #[derive(Serialize)] @@ -128,10 +175,16 @@ mod floating_serde { #[derive(Serialize)] struct Tree<'a, P> { + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, root: &'a Node

, rect: [f32; 4], #[serde(skip_serializing_if = "Option::is_none")] native: Option<[f32; 4]>, + #[serde(skip_serializing_if = "Option::is_none")] + monitor: Option<&'a Monitor>, + #[serde(skip_serializing_if = "Option::is_none")] + monitor_size: Option<[f32; 2]>, #[serde(skip_serializing_if = "std::ops::Not::not")] minimized: bool, } @@ -142,7 +195,16 @@ mod floating_serde { Node::Tabs { panels, active } if !self.minimized && self.native.is_none() => { V1 { panels, active: *active, rect: self.rect }.serialize(serializer) } - root => Tree { root, rect: self.rect, native: self.native, minimized: self.minimized }.serialize(serializer), + root => Tree { + id: self.native.map(|_| self.id), + root, + rect: self.rect, + native: self.native, + monitor: self.monitor.as_ref(), + monitor_size: self.monitor_size, + minimized: self.minimized, + } + .serialize(serializer), } } } @@ -150,6 +212,8 @@ mod floating_serde { #[derive(Deserialize)] #[serde(bound(deserialize = "P: Deserialize<'de>"))] struct Repr

{ + #[serde(default)] + id: Option, root: Option>, groups: Option>>, panels: Option>, @@ -159,9 +223,25 @@ mod floating_serde { #[serde(default)] native: Option<[f32; 4]>, #[serde(default)] + monitor: Option, + #[serde(default)] + monitor_size: Option<[f32; 2]>, + #[serde(default)] minimized: bool, } + /// Floating windows with a fresh id for each one whose id an earlier window took. + pub(super) fn unique_ids<'de, D: Deserializer<'de>, P: Deserialize<'de>>(deserializer: D) -> Result>, D::Error> { + let mut windows = Vec::>::deserialize(deserializer)?; + let mut seen = std::collections::HashSet::new(); + for window in &mut windows { + while !seen.insert(window.id) { + window.id = fresh_window_id(); + } + } + Ok(windows) + } + impl<'de, P: Deserialize<'de>> Deserialize<'de> for Floating

{ fn deserialize>(deserializer: D) -> Result { let repr = Repr::

::deserialize(deserializer)?; @@ -176,7 +256,15 @@ mod floating_serde { (None, None, None) => return Err(serde::de::Error::missing_field("root")), _ => return Err(serde::de::Error::duplicate_field("root")), }; - Ok(Floating { root, rect: repr.rect, native: repr.native, minimized }) + Ok(Floating { + id: repr.id.unwrap_or_else(fresh_window_id), + root, + rect: repr.rect, + native: repr.native, + monitor: repr.monitor, + monitor_size: repr.monitor_size, + minimized, + }) } } } @@ -185,6 +273,7 @@ mod floating_serde { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Layout

{ pub root: Option>, + #[cfg_attr(feature = "serde", serde(deserialize_with = "floating_serde::unique_ids"))] pub floating: Vec>, } @@ -357,6 +446,13 @@ pub enum Action

{ panel: P, rect: Option<[f32; 4]>, }, + /// Record the monitor of the native window holding `panel`: from the application's + /// monitor list, or else only the size egui reported. + SetMonitor { + panel: P, + monitor: Option, + monitor_size: Option<[f32; 2]>, + }, /// Move one panel, without the rest of its group. `Target::At` is [`Action::Move`]; /// `NewDock` and `Float` give the panel a group of its own. MovePanel { @@ -908,8 +1004,11 @@ impl Layout

{ if !valid_rect(rect) { return Err(DockError::InvalidGeometry); } + // A window's only panel keeps the window's id. + let lone = self.floating.iter().find(|w| w.contains(&panel)).filter(|w| w.panels().count() == 1).map(|w| w.id); let entry = self.take(&panel).ok_or(DockError::MissingPanel)?; - self.floating.push(Floating::single(vec![entry.panel], 0, rect)); + let window = Floating::single(vec![entry.panel], 0, rect); + self.floating.push(Floating { id: lone.unwrap_or(window.id), ..window }); Ok(()) } Action::Close { panel } => self.take(&panel).map(|_| ()).ok_or(DockError::MissingPanel), @@ -1074,6 +1173,21 @@ impl Layout

{ } let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; window.native = rect; + if rect.is_none() { + window.monitor = None; + window.monitor_size = None; + } + Ok(()) + } + Action::SetMonitor { panel, monitor, monitor_size } => { + let valid = monitor.as_ref().is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) + && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])); + if !valid { + return Err(DockError::InvalidGeometry); + } + let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; + window.monitor = monitor; + window.monitor_size = monitor_size; Ok(()) } } From 799ab4e691dd89f7d734688f30284d4f125665b5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 08:27:13 +0000 Subject: [PATCH 090/160] docking: every float opens in its own OS window With native-windows on a backend with viewports, each floating window whose panels allow it is native: in-window floats open over their place in the app window, and restored windows go back to their monitor or onto the main window's. Windows stay on top and hide while the app is inactive. A thin bar with title and close replaces the OS title bar and moves the window; closing the window closes its panels. Keys a native window leaves unused come back in DockOutput::forwarded_events, and DockArea::monitors takes the monitor list. Move Panel to New Window goes, since every float is native. --- crates/ui/src/docking/floating_tests.rs | 10 +- crates/ui/src/docking/labels.rs | 4 +- crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/placement.rs | 187 ++++++++++++++ crates/ui/src/docking/render/frame.rs | 4 +- crates/ui/src/docking/render/mod.rs | 11 + crates/ui/src/docking/render/native.rs | 325 ++++++++++++++++++------ crates/ui/src/docking/surface.rs | 46 +++- 8 files changed, 490 insertions(+), 101 deletions(-) create mode 100644 crates/ui/src/docking/placement.rs diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 9217965e..8e20d699 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -509,7 +509,7 @@ fn native_window_falls_back_in_window_when_viewports_are_embedded() { #[cfg(feature = "native-windows")] #[test] -fn native_window_close_returns_the_group_to_its_in_window_rect() { +fn native_window_close_closes_its_panels() { use std::{cell::Cell, rc::Rc}; let close = Rc::new(Cell::new(false)); let requested = close.clone(); @@ -543,12 +543,8 @@ fn native_window_close_returns_the_group_to_its_in_window_rect() { close.set(true); let out = d.step(vec![]); - assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: None }), "{:?}", out.actions); - assert_eq!(d.layout.floating[0].native, None); - assert_eq!(d.layout.floating[0].rect, [300.0, 80.0, 240.0, 300.0]); - d.step(vec![]); - d.step(vec![]); - assert_eq!(d.title(5).min, pos2(300.0, 84.0)); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + assert!(window_of(&d.layout, 5).is_none() && !d.layout.contains(&6) && !d.layout.contains(&7)); } #[test] diff --git a/crates/ui/src/docking/labels.rs b/crates/ui/src/docking/labels.rs index e900e6fc..3ad3a9ff 100644 --- a/crates/ui/src/docking/labels.rs +++ b/crates/ui/src/docking/labels.rs @@ -41,7 +41,6 @@ impl DockStyle { /// | `dock.closeOthers` | `close_others_label` | Close Other Panels in Group | /// | `dock.maximize` | `maximize_label` | Maximize Frame | /// | `dock.restoreFrame` | `restore_frame_label` | Restore Frame Size | - /// | `dock.newWindow` | `new_window_label` | Move Panel to New Window | /// | `dock.autoCollapse` | `auto_collapse_label` | Auto-Collapse Iconic Panels | /// | `dock.floatHint` | `float_hint_label` | Hold {key} to undock | #[must_use] @@ -54,7 +53,7 @@ impl DockStyle { self } - fn labels_mut(&mut self) -> [(&'static str, &mut String); 25] { + fn labels_mut(&mut self) -> [(&'static str, &mut String); 24] { [ ("dock.float", &mut self.float_label), ("dock.close", &mut self.close_label), @@ -78,7 +77,6 @@ impl DockStyle { ("dock.closeOthers", &mut self.close_others_label), ("dock.maximize", &mut self.maximize_label), ("dock.restoreFrame", &mut self.restore_frame_label), - ("dock.newWindow", &mut self.new_window_label), ("dock.autoCollapse", &mut self.auto_collapse_label), ("dock.floatHint", &mut self.float_hint_label), ] diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 45e20828..06f51a0c 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -12,6 +12,8 @@ mod dock_model; mod hits; mod labels; mod model; +#[cfg(feature = "native-windows")] +mod placement; mod render; mod resize; mod surface; @@ -24,7 +26,7 @@ mod workspace; pub use command::{DockCommand, DockOperation, UiDock, ZoneName}; pub use dock_model::{Column, ColumnLocation, Group, GroupLocation, PanelState, Side, Target}; pub use hits::{Divider, Hit, ResizeEdge}; -pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, Monitor, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone}; +pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, diff --git a/crates/ui/src/docking/placement.rs b/crates/ui/src/docking/placement.rs new file mode 100644 index 00000000..b0aed6eb --- /dev/null +++ b/crates/ui/src/docking/placement.rs @@ -0,0 +1,187 @@ +//! Where native floating windows go on the desktop (feature `native-windows`). Rects are +//! `[x, y, width, height]` in desktop logical points. + +use egui::{Pos2, Rect, Vec2, pos2}; + +use super::Monitor; + +/// Largest native window, and the farthest it may sit from the desktop origin. +const MAX_SIZE: f32 = 16_384.0; +const MAX_POSITION: f32 = 100_000.0; +const MIN_SIZE: [f32; 2] = [160.0, 100.0]; + +/// A rect safe to hand to the windowing system: finite, its size and position bounded. +/// Hostile workspace files and bad monitor reports included. +pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] { + let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; + let [x, y, w, h] = rect; + [ + finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION), + finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE), + finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE), + ] +} + +fn usable(monitor: &Monitor) -> bool { + let [x, y, w, h] = monitor.rect; + [x, y, w, h].iter().all(|v| v.is_finite() && v.abs() <= MAX_POSITION) && w > 0.0 && h > 0.0 +} + +fn to_rect([x, y, w, h]: [f32; 4]) -> Rect { + Rect::from_min_size(pos2(x, y), Vec2::new(w, h)) +} + +/// The monitor holding the center of `rect`, else the one nearest to it. None for an empty +/// list. +pub(super) fn monitor_of(monitors: &[Monitor], rect: [f32; 4]) -> Option<&Monitor> { + let center = to_rect(rect).center(); + let monitors = || monitors.iter().filter(|m| usable(m)); + monitors() + .find(|m| to_rect(m.rect).contains(center)) + .or_else(|| monitors().min_by(|a, b| to_rect(a.rect).distance_sq_to_pos(center).total_cmp(&to_rect(b.rect).distance_sq_to_pos(center)))) +} + +/// `rect` moved, and shrunk if it must be, to lie wholly on `area`. +fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] { + let [x, y, w, h] = rect; + let [ax, ay, aw, ah] = area; + let (w, h) = (w.min(aw), h.min(ah)); + sanitized([x.clamp(ax, ax + aw - w), y.clamp(ay, ay + ah - h), w, h]) +} + +/// Where a native window from a saved layout opens. +/// +/// - Its saved monitor (`monitor`), found in `monitors` by name or else by rect, is connected: +/// the saved rect, moved in proportion if that monitor's rect changed. +/// - It is not: the main window's monitor, at the same offset from its origin. +/// - No monitor list: the main window's monitor as egui reports it, by its size alone. Its +/// origin is taken as the multiple of that size nearest below the main window's center, +/// which holds for a row or grid of monitors of one size. +/// +/// A window that moves to another monitor ends up wholly on it. +pub(super) fn restore(saved: [f32; 4], monitor: Option<&Monitor>, monitors: &[Monitor], main: Option, monitor_size: Option) -> [f32; 4] { + let saved = sanitized(saved); + let main = main.filter(|r| r.is_finite()).map(|r| [r.left(), r.top(), r.width().max(1.0), r.height().max(1.0)]); + if !monitors.iter().any(usable) { + let (Some(main), Some(size)) = (main, monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)) else { return saved }; + let center = to_rect(main).center(); + let origin = Pos2::new((center.x / size.x).floor() * size.x, (center.y / size.y).floor() * size.y); + return clamp_onto(saved, [origin.x, origin.y, size.x, size.y]); + } + let main_monitor = main.and_then(|main| monitor_of(monitors, main)).or_else(|| monitors.iter().find(|m| usable(m))); + let connected = monitor.and_then(|old| { + let by_name = old.name.as_ref().and_then(|name| monitors.iter().filter(|m| usable(m)).find(|m| m.name.as_ref() == Some(name))); + by_name.or_else(|| monitors.iter().filter(|m| usable(m)).find(|m| m.rect == old.rect)).map(|now| (old, now)) + }); + let [x, y, w, h] = saved; + match (connected, monitor) { + (Some((old, now)), _) if old.rect == now.rect => saved, + (Some((old, now)), _) => { + let [ox, oy, ow, oh] = old.rect; + let [nx, ny, nw, nh] = now.rect; + let (sx, sy) = if usable(old) { (nw / ow, nh / oh) } else { (1.0, 1.0) }; + clamp_onto([nx + (x - ox) * sx, ny + (y - oy) * sy, w, h], now.rect) + } + (None, Some(old)) => { + let Some(target) = main_monitor else { return saved }; + let [ox, oy, ..] = old.rect; + let [tx, ty, ..] = target.rect; + let (dx, dy) = if usable(old) { (x - ox, y - oy) } else { (0.0, 0.0) }; + clamp_onto([tx + dx, ty + dy, w, h], target.rect) + } + // No saved monitor: the window stays where its center is on a monitor. + (None, None) => { + let center = to_rect(saved).center(); + if monitors.iter().any(|m| usable(m) && to_rect(m.rect).contains(center)) { + return saved; + } + main_monitor.map_or(saved, |target| clamp_onto(saved, target.rect)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + + fn monitor(name: &str, rect: [f32; 4], scale: f32) -> Monitor { + Monitor { name: Some(name.into()), rect, scale } + } + + fn main_at(x: f32, y: f32) -> Option { + Some(Rect::from_min_size(pos2(x, y), vec2(1200.0, 800.0))) + } + + const LEFT: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0]; + const RIGHT: [f32; 4] = [1920.0, 0.0, 2560.0, 1440.0]; + + #[test] + fn a_connected_monitor_keeps_the_saved_rect() { + let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)]; + let saved = [2400.0, 300.0, 400.0, 500.0]; + assert_eq!(restore(saved, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), saved); + // Renamed, but the same rect. + let renamed = Monitor { name: Some("DP-2".into()), ..monitors[1].clone() }; + assert_eq!(restore(saved, Some(&renamed), &monitors, main_at(100.0, 100.0), None), saved); + // Partly off its monitor, as the user left it. + let edge = [4300.0, 300.0, 400.0, 500.0]; + assert_eq!(restore(edge, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), edge); + } + + #[test] + fn a_missing_monitor_moves_the_window_to_the_main_window_monitor() { + let saved_on = monitor("right", RIGHT, 2.0); + let monitors = [monitor("left", LEFT, 1.0)]; + // Same offset from the monitor's origin. + assert_eq!(restore([2020.0, 50.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [100.0, 50.0, 400.0, 500.0]); + // Clamped wholly on screen, shrunk if larger than the monitor. + assert_eq!(restore([4000.0, 1200.0, 400.0, 500.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [1520.0, 580.0, 400.0, 500.0]); + assert_eq!(restore([1920.0, 0.0, 3000.0, 1300.0], Some(&saved_on), &monitors, main_at(100.0, 100.0), None), [0.0, 0.0, 1920.0, 1080.0]); + } + + #[test] + fn a_resized_monitor_moves_the_window_in_proportion() { + let old = monitor("left", LEFT, 1.0); + let monitors = [monitor("left", [0.0, 0.0, 960.0, 540.0], 2.0)]; + assert_eq!(restore([960.0, 540.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [480.0, 270.0, 200.0, 200.0]); + assert_eq!(restore([1800.0, 1000.0, 200.0, 200.0], Some(&old), &monitors, main_at(0.0, 0.0), None), [760.0, 340.0, 200.0, 200.0]); + } + + #[test] + fn negative_coordinates_and_mixed_scales() { + let monitors = [monitor("left", [-2560.0, -200.0, 2560.0, 1440.0], 2.0), monitor("main", LEFT, 1.0)]; + assert_eq!(monitor_of(&monitors, [-500.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("left")); + assert_eq!(monitor_of(&monitors, [100.0, 0.0, 300.0, 300.0]).and_then(|m| m.name.as_deref()), Some("main")); + // Off every monitor: the nearest. + assert_eq!(monitor_of(&monitors, [-3000.0, 2000.0, 100.0, 100.0]).and_then(|m| m.name.as_deref()), Some("left")); + let saved = [-1000.0, -100.0, 300.0, 300.0]; + assert_eq!(restore(saved, Some(&monitors[0]), &monitors, main_at(100.0, 100.0), None), saved); + // Unplugged: the same offset on the main window's monitor, at its own scale. + let rest = &monitors[1..]; + assert_eq!(restore(saved, Some(&monitors[0]), rest, main_at(100.0, 100.0), None), [1560.0, 100.0, 300.0, 300.0]); + // A main window on the left monitor takes the window there. + let main = monitor("main", LEFT, 1.0); + assert_eq!(restore([100.0, 100.0, 400.0, 300.0], Some(&main), &monitors[..1], main_at(-2000.0, 0.0), None), [-2460.0, -100.0, 400.0, 300.0]); + } + + #[test] + fn without_a_monitor_list_the_window_goes_on_the_main_window_monitor_by_size() { + let size = Some(vec2(1920.0, 1080.0)); + let saved = [2500.0, 300.0, 400.0, 500.0]; + assert_eq!(restore(saved, None, &[], main_at(100.0, 100.0), size), [1520.0, 300.0, 400.0, 500.0]); + assert_eq!(restore(saved, None, &[], main_at(2000.0, 100.0), size), saved); + assert_eq!(restore(saved, None, &[], main_at(-1500.0, 100.0), size), [-400.0, 300.0, 400.0, 500.0]); + // Nothing known: the saved rect, made safe. + assert_eq!(restore([f32::NAN, 1e9, 10.0, 400.0], None, &[], None, None), [0.0, 100_000.0, 160.0, 400.0]); + } + + #[test] + fn a_window_without_a_saved_monitor_stays_on_screen() { + let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)]; + let on = [2000.0, 100.0, 400.0, 300.0]; + assert_eq!(restore(on, None, &monitors, main_at(100.0, 100.0), None), on); + assert_eq!(restore([5000.0, 100.0, 400.0, 300.0], None, &monitors, main_at(100.0, 100.0), None), [1520.0, 100.0, 400.0, 300.0]); + } +} diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index e86f1b7c..8fdbd529 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -220,7 +220,7 @@ impl Renderer<'_, P> { } /// The menu tail: Close Other Panels in Group and Maximize Frame when the style shows - /// them, and Move Panel to New Window where native windows are available. + /// them. pub(super) fn frame_items(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { let others = owner.panels.iter().filter(|p| *p != panel).all(|p| self.permissions(p).closable); if self.style.close_others_item @@ -239,7 +239,5 @@ impl Renderer<'_, P> { self.output.events.push(DockEvent::ToggleMaximize { panel }); } } - #[cfg(feature = "native-windows")] - self.new_window_item(ui, owner, panel); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index f4fbf37e..f86abb64 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -12,6 +12,14 @@ mod frame; mod iconic; #[cfg(feature = "native-windows")] mod native; +#[cfg(all(test, feature = "native-windows"))] +pub(in crate::docking) use native::floats_shown; + +/// The viewport of the native window `window` of the dock area `area`. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn native_viewport(area: Id, window: u64) -> egui::ViewportId { + native::viewport_id(area, window, 0) +} pub(in crate::docking) use frame::{FrameState, view}; @@ -58,6 +66,9 @@ pub(super) struct Renderer<'a, P> { /// The floating window whose tree is being drawn: its first panel, which names it in /// ids and actions, and its rect. `None` while the docked layout draws. pub(super) tree: Option<(P, Rect)>, + /// [`crate::docking::DockArea::monitors`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) monitors: Vec, } pub(super) struct DrawnDivider { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 911ae225..5dbc770c 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -1,31 +1,51 @@ -//! Floating windows in their own OS windows (feature `native-windows`). A window with a native -//! rect is drawn with `show_viewport_immediate` where the backend has viewports; where egui -//! embeds viewports (wasm, tests) it stays in-window at its in-window rect. Tabs and groups do -//! not drag between OS windows. +//! Floating windows in their own OS windows (feature `native-windows`). Where the backend has +//! viewports, every floating window whose panels allow it is drawn with +//! `show_viewport_immediate`, always on top of other windows and hidden while none of the +//! application's windows has focus. Where egui embeds viewports (wasm, tests) windows stay +//! in-window at their in-window rect. Tabs and groups do not drag between OS windows. use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Pos2, Rect, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, vec2}; +use egui::{Context, Event, Id, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, WindowLevel, pos2, vec2}; -use super::{Renderer, clamp_float, group::TabGroup}; +use super::Renderer; +use crate::docking::chrome::{ChromeStyle, TitleBar}; +use crate::docking::placement::{monitor_of, restore, sanitized}; use crate::docking::{Action, Drag, Floating, GroupDrag}; -/// Smallest and largest native window, and the farthest it may sit from the desktop origin. +/// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; -const MAX_SIZE: f32 = 16_384.0; -const MAX_POSITION: f32 = 100_000.0; +/// Height of the bar drawn at the top of a native window. +pub(in crate::docking) const BAR_HEIGHT: f32 = 18.0; +/// How long floating windows stay after the application's last window lost focus, so focus +/// moving between our own windows does not hide them. +const HIDE_DELAY: f64 = 0.25; -/// A rect safe to hand to the windowing system: finite, its size and position bounded. -/// Hostile workspace files and bad monitor reports included. -pub(super) fn sanitized(rect: [f32; 4]) -> [f32; 4] { - let finite = |v: f32, fallback: f32| if v.is_finite() { v } else { fallback }; - let [x, y, w, h] = rect; - [ - finite(x, 0.0).clamp(-MAX_POSITION, MAX_POSITION), - finite(y, 0.0).clamp(-MAX_POSITION, MAX_POSITION), - finite(w, 420.0).clamp(MIN_SIZE[0], MAX_SIZE), - finite(h, 320.0).clamp(MIN_SIZE[1], MAX_SIZE), - ] +/// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) struct WindowDrag { + /// [`Floating::id`] of the dragged window. + pub window: u64, + /// Where the pointer pressed, in the window's local points. + pub grab: Vec2, + /// The window's outer position on the desktop: reported when the drag began, then as last + /// commanded. The reported position lags and jitters while the window moves. + pub origin: Pos2, +} + +/// The viewport of the native window `window` of the dock area `area`. The `n`th window with +/// an id an earlier window of the same frame took gets its own. +pub(in crate::docking) fn viewport_id(area: Id, window: u64, n: usize) -> ViewportId { + match n { + 0 => ViewportId::from_hash_of((area, "native", window)), + n => ViewportId::from_hash_of((area, "native", window, n)), + } +} + +/// Whether floating windows show: while one of the application's windows has focus, and until +/// [`HIDE_DELAY`] after `last_focused`, when one last had it. +pub(in crate::docking) fn floats_shown(focused: bool, now: f64, last_focused: f64) -> bool { + focused || now - last_focused < HIDE_DELAY } /// The desktop rect a native viewport reports: outer position, inner size. None when the @@ -35,25 +55,46 @@ fn placed(outer: Option, inner: Option) -> Option<[f32; 4]> { (outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()])) } +/// What a native window's frame reports back to the main window. +#[derive(Default)] +struct Report { + closed: bool, + placed: Option<[f32; 4]>, + monitor_size: Option, +} + impl Renderer<'_, P> { /// Show the native windows and return the windows to draw in-window. pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { let native = !ctx.embed_viewports(); + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let visibility = if native { self.float_visibility(ctx) } else { (true, false) }; let mut shown = Vec::new(); let mut in_window = Vec::new(); + let mut ids = Vec::new(); for window in windows { - match window.native { - Some(rect) if native && window.panels().all(|p| self.content.allow_native(p)) => { - if let Some(id) = self.native_window(ctx, window, rect) { - shown.push(id); - } - } - _ => in_window.push(window), + let allowed = window.panels().all(|p| self.content.allow_native(p) && self.permissions(p).floatable); + let Some(first) = window.panels().next().cloned().filter(|_| native && allowed) else { + in_window.push(window); + continue; + }; + // Every float is native: one still in-window opens over its place in the app window. + let rect = window.native.unwrap_or_else(|| { + let [x, y, w, h] = window.rect; + let rect = sanitized([origin.x + x, origin.y + y, w, h]); + self.output.actions.push(Action::SetNative { panel: first, rect: Some(rect) }); + rect + }); + let n = ids.iter().filter(|id| **id == window.id).count(); + ids.push(window.id); + let viewport = viewport_id(self.id, window.id, n); + if let Some(key) = self.native_window(ctx, window, rect, viewport, visibility) { + shown.push(key); } } // Forget where windows that are no longer native were told to open. let key = self.id.with("native-shown"); - let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); + let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); ctx.data_mut(|d| { for id in previous.iter().filter(|id| !shown.contains(id)) { d.remove::<[f32; 4]>(*id); @@ -63,35 +104,78 @@ impl Renderer<'_, P> { in_window } + /// Whether floating windows show this frame, and whether that changed since the last. + fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { + let (focused, now) = ctx.input(|i| { + let infos = || i.raw.viewports.values(); + // A backend that reports no focus at all keeps the windows. + let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); + (focused, i.time) + }); + let key = self.id.with("native-visibility"); + let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); + let last = if focused { now } else { last }; + let visible = floats_shown(focused, now, last); + if visible && !focused { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); + } + ctx.data_mut(|d| d.insert_temp(key, (last, visible))); + (visible, visible != was) + } + /// One floating window in its own viewport. Returns the key of its spawn rect. - fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4]) -> Option { + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visibility: (bool, bool)) -> Option { + let (visible, toggled) = visibility; let first = window.panels().next()?.clone(); - let viewport = ViewportId::from_hash_of((self.id, "native", &first)); - // The window opens at its saved rect; after that the window system owns its position - // and size, which are read back below. - let spawn_key = self.id.with(("native-spawn", &first)); - let spawn = ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(spawn_key, || sanitized(rect))); + // The window opens at its saved place, moved onto a connected monitor; after that the + // window system owns its position and size, which are read back below. + let spawn_key = self.id.with(("native-spawn", viewport)); + let spawned = ctx.data(|d| d.get_temp::<[f32; 4]>(spawn_key)); + let spawn = spawned.unwrap_or_else(|| { + let (main, size) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + restore(rect, window.monitor.as_ref(), &self.monitors, main, size) + }); + ctx.data_mut(|d| d.insert_temp(spawn_key, spawn)); let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() .with_title(title) .with_position([spawn[0], spawn[1]]) .with_inner_size([spawn[2], spawn[3]]) - .with_min_inner_size(MIN_SIZE); - if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + .with_min_inner_size(MIN_SIZE) + .with_window_level(WindowLevel::AlwaysOnTop); + // The bar replaces the OS title bar. macOS keeps its frame for edge resizing. + #[cfg(target_os = "macos")] + let builder = builder.with_titlebar_shown(false).with_title_shown(false).with_fullsize_content_view(true).with_titlebar_buttons_shown(false); + #[cfg(not(target_os = "macos"))] + let builder = builder.with_decorations(false); + if toggled || (spawned.is_none() && !visible) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); + } + if visible && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } let (targets, windows) = (self.targets.len(), self.frame.windows.len()); - let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| { - let rect = ui.max_rect(); - ui.painter().rect_filled(rect, 0.0, self.style.floating_background); - self.floating_body(ui, window, rect, rect); - // An embedded fallback reads the main window's input: its close request and place - // are not this window's. - if matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { - ui.input(|i| (i.viewport().close_requested(), placed(i.viewport().outer_rect, i.viewport().inner_rect))) - } else { - (false, None) + let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { + let full = ui.max_rect(); + ui.painter().rect_filled(full, 0.0, self.style.floating_background); + self.native_bar(ui, window); + let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); + self.floating_body(ui, window, body, full); + #[cfg(not(target_os = "macos"))] + if window.panels().all(|p| self.permissions(p).movable) { + resize_edges(ui, self.id.with(("native-edges", window.id)), full); } + // An embedded fallback reads the main window's input: its close request, place and + // keys are not this window's. + if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { + return Report::default(); + } + self.forward_events(ui); + ui.input(|i| Report { + closed: i.viewport().close_requested(), + placed: placed(i.viewport().outer_rect, i.viewport().inner_rect), + monitor_size: i.viewport().monitor_size, + }) }); // Targets in another OS window are in its coordinates, and no drag crosses windows. self.targets.truncate(targets); @@ -104,50 +188,133 @@ impl Renderer<'_, P> { d.remove::>(self.id.with("group-drag")); } }); - if closed { - ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_key)); - self.output.actions.push(Action::SetNative { panel: first, rect: None }); - return None; + if report.closed { + // Like the bar's ×: its panels close and reopen from the application's menu. + self.output.actions.push(Action::CloseWindow { panel: first }); + return Some(spawn_key); } - if let Some(placed) = placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) { - self.output.actions.push(Action::SetNative { panel: first, rect: Some(placed) }); + let now = report.placed.unwrap_or(rect); + if let Some(placed) = report.placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) { + self.output.actions.push(Action::SetNative { panel: first.clone(), rect: Some(placed) }); + // The in-window rect follows, for builds that draw the window in-window. + if window.panels().all(|p| self.permissions(p).movable) { + let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); + let [x, y, w, h] = placed; + self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [x - origin.x, y - origin.y, w, h] }); + } + } + let monitor = monitor_of(&self.monitors, now).cloned(); + let size = match monitor { + Some(_) => None, + None => report.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0).map(|s| [s.x, s.y]), + }; + if (monitor.is_some() || size.is_some()) && (monitor != window.monitor || size != window.monitor_size) { + self.output.actions.push(Action::SetMonitor { panel: first, monitor, monitor_size: size }); } Some(spawn_key) } - /// Move Panel to New Window: a floating panel's window, or the panel floated on its own. - pub(super) fn new_window_item(&mut self, ui: &mut egui::Ui, owner: &TabGroup<'_, P>, panel: &P) { - let ctx = ui.ctx().clone(); - if ctx.embed_viewports() { - return; + /// The bar at the top of a native window: its title and ×. Dragging it moves the window. + fn native_bar(&mut self, ui: &mut Ui, window: &Floating

) { + let Some(first) = window.panels().next().cloned() else { return }; + let full = ui.max_rect(); + let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); + let movable = window.panels().all(|p| self.permissions(p).movable); + let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); + let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); + let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); + let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable); + if closable { + bar = bar.close(&close_label); } - let window = self.layout.floating.iter().find(|w| w.contains(panel)); - if window.is_some_and(|w| w.native.is_some()) { - return; + let response = bar.show(ui, rect, &ChromeStyle::from_dock(self.style)); + if let Some(close) = response.close + && close.clicked() + { + self.output.actions.push(Action::CloseWindow { panel: first }); } - let movers: Vec

= window.map_or_else(|| vec![panel.clone()], |w| w.panels().cloned().collect()); - let allowed = |p: &P| self.content.allow_native(p) && self.permissions(p).floatable && self.permissions(p).movable; - if !movers.iter().all(allowed) { - return; + if movable { + self.drag_window(ui, &response.bar, window.id); } - let in_window = match window { - Some(window) => window.rect, - None => { - let size = self.float_size(panel, owner.rect.size()); - let rect = clamp_float(Rect::from_min_size(owner.rect.min + vec2(24.0, 24.0), size), self.float_bounds); - [rect.left(), rect.top(), rect.width(), rect.height()] + } + + /// Move the window with its bar: by `OuterPosition` from the position last commanded, or, + /// where the window system reports no position (Wayland), by handing the move to it. + fn drag_window(&mut self, ui: &Ui, bar: &egui::Response, window: u64) { + let key = self.id.with("window-drag"); + let pointer = ui.input(|i| i.pointer.latest_pos()); + if bar.drag_started() { + let press = ui.input(|i| i.pointer.press_origin()).or(pointer); + match (ui.input(|i| i.viewport().outer_rect), press) { + (Some(outer), Some(press)) if outer.min.is_finite() => { + let drag = WindowDrag { window, grab: press.to_vec2(), origin: outer.min }; + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } + _ => ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag), } - }; - if !self.menu_action(ui, &self.style.new_window_label.clone()) { + } + let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return }; + if bar.dragged() + && let Some(pointer) = pointer + { + // The pointer on the desktop is the commanded origin plus the local pointer. + let step = pointer.to_vec2() - drag.grab; + if step != Vec2::ZERO { + drag.origin += step; + ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.origin)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } + } + if bar.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + ui.ctx().data_mut(|d| d.remove::(key)); + self.end_window_drag(drag); + } + } + + /// Release of a bar drag. The window already sits where it was dragged. + fn end_window_drag(&mut self, _drag: WindowDrag) {} + + /// Keys, text and clipboard events the window's widgets left over, for the application's + /// shortcuts. Nothing while a text field of the window has focus. + fn forward_events(&mut self, ui: &Ui) { + if ui.ctx().text_edit_focused() { return; } - // Over the panel's place in the app window: its content's position on the desktop. - let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); - let [x, y, w, h] = in_window; - if window.is_none() { - self.output.actions.push(Action::Float { panel: panel.clone(), rect: in_window }); + let events = ui.input(|i| { + i.events + .iter() + .filter(|e| matches!(e, Event::Key { .. } | Event::Text(_) | Event::Copy | Event::Cut | Event::Paste(_))) + .cloned() + .collect::>() + }); + self.output.forwarded_events.extend(events); + } +} + +/// Bands along the edges and corners of an undecorated window that resize it. +#[cfg(not(target_os = "macos"))] +fn resize_edges(ui: &Ui, id: Id, rect: Rect) { + use egui::{CursorIcon as C, ResizeDirection as D, Sense}; + const EDGE: f32 = 4.0; + let (e, c) = (EDGE, EDGE * 2.0); + let (l, r, t, b) = (rect.left(), rect.right(), rect.top(), rect.bottom()); + let zones = [ + (D::NorthWest, Rect::from_min_max(pos2(l, t), pos2(l + c, t + c)), C::ResizeNorthWest), + (D::NorthEast, Rect::from_min_max(pos2(r - c, t), pos2(r, t + c)), C::ResizeNorthEast), + (D::SouthWest, Rect::from_min_max(pos2(l, b - c), pos2(l + c, b)), C::ResizeSouthWest), + (D::SouthEast, Rect::from_min_max(pos2(r - c, b - c), pos2(r, b)), C::ResizeSouthEast), + (D::North, Rect::from_min_max(pos2(l + c, t), pos2(r - c, t + e)), C::ResizeNorth), + (D::South, Rect::from_min_max(pos2(l + c, b - e), pos2(r - c, b)), C::ResizeSouth), + (D::West, Rect::from_min_max(pos2(l, t + c), pos2(l + e, b - c)), C::ResizeWest), + (D::East, Rect::from_min_max(pos2(r - e, t + c), pos2(r, b - c)), C::ResizeEast), + ]; + for (i, (direction, zone, cursor)) in zones.into_iter().enumerate() { + if !zone.is_positive() { + continue; } - let native = sanitized([origin.x + x, origin.y + y, w, h]); - self.output.actions.push(Action::SetNative { panel: panel.clone(), rect: Some(native) }); + let response = ui.interact(zone, id.with(i), Sense::drag()).on_hover_cursor(cursor); + if response.drag_started() { + ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 2bfdb4db..40749353 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Color32, Context, FontId, Id, Pos2, Rect, Sense, Stroke, Ui, Vec2}; -use super::{Action, DockError, DropTarget, Layout, Permissions, render::Renderer, resize::AnchoredResize}; +use super::{Action, DockError, DropTarget, Layout, Monitor, Permissions, render::Renderer, resize::AnchoredResize}; /// Horizontal room around a tab's label, both when measuring its natural width and when cutting it. pub(super) const TAB_PADDING: f32 = 24.0; @@ -90,8 +90,6 @@ pub struct DockStyle { pub maximize_label: String, pub restore_frame_label: String, pub maximize_item: bool, - /// Shown where native windows are available (feature `native-windows`). - pub new_window_label: String, /// Shown under the pointer while a drag offers a docking target, when [`Self::float_hint`] /// is on. `{key}` stands for the modifier that floats the drop instead: ⌘ on macOS, Ctrl /// elsewhere. @@ -185,7 +183,6 @@ impl DockStyle { maximize_label: "Maximize Frame".into(), restore_frame_label: "Restore Frame Size".into(), maximize_item: false, - new_window_label: "Move Panel to New Window".into(), float_hint_label: "Hold {key} to undock".into(), float_hint: false, command_press_floats: false, @@ -240,11 +237,17 @@ pub struct DockOutput

{ pub group_drop: Option<(P, Pos2)>, /// Requests that are not layout actions. pub events: Vec>, + /// Key, text and clipboard events (`Key`, `Text`, `Copy`, `Cut`, `Paste`) that reached a + /// floating window in its own OS window and that none of its widgets used (feature + /// `native-windows`). egui hands each OS window only its own input, so an application + /// passes these to the shortcut handling it runs on the main window's input. A window + /// whose text field has focus forwards nothing. + pub forwarded_events: Vec, } impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new() } + Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new(), forwarded_events: Vec::new() } } } @@ -670,11 +673,37 @@ pub struct DockArea<'a, P = ()> { hide: HidePanels<'a, P>, auto_collapse: bool, float_bounds: Option, + monitors: Vec, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { pub fn new(id: Id) -> Self { - Self { id, activation: None, blockers: Vec::new(), maximized: None, hide: HidePanels::None, auto_collapse: true, float_bounds: None } + Self { + id, + activation: None, + blockers: Vec::new(), + maximized: None, + hide: HidePanels::None, + auto_collapse: true, + float_bounds: None, + monitors: Vec::new(), + } + } + + /// The connected monitors (feature `native-windows`). A native floating window stores the + /// one it is on, and a restored layout puts each window back on its monitor, or on the + /// main window's when that monitor is gone. egui 0.36 reports no monitors; without a list + /// windows store the monitor size egui reports and restore onto the main window's monitor. + pub fn monitors(mut self, monitors: Vec) -> Self { + self.monitors = monitors; + self + } + + /// The egui viewport of the native window of the floating window with [`super::Floating::id`] + /// `window`, in the dock area `id` (feature `native-windows`). + #[cfg(feature = "native-windows")] + pub fn window_viewport(id: Id, window: u64) -> egui::ViewportId { + super::render::native_viewport(id, window) } /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press @@ -736,8 +765,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { /// Render a validated snapshot and return requested actions for the app's command handler. /// No action is applied here. Panel contents get a global stable egui ID across moves. - /// Floating windows are egui areas inside this viewport; with feature `native-windows`, a - /// window with a native rect gets its own OS window where the backend has viewports. + /// Floating windows are egui areas inside this viewport; with feature `native-windows`, each + /// gets its own OS window where the backend has viewports (see docs/ui-docking.md). pub fn show( self, ui: &mut Ui, @@ -821,6 +850,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { contents: content_index, dividers: Vec::new(), tree: None, + monitors: self.monitors, }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); From 06c1fc88259a829f916df9f990f1421876d9b964 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 08:27:19 +0000 Subject: [PATCH 091/160] docking: DockDriver stands in for OS windows, and native window tests with_native_host draws native windows as their own egui frames with a desktop rect, focus, close requests and the commands they receive. Tests cover each way into a native window, closing, stable ids, place and monitor, restore, the bar drag, hiding and forwarded keys. --- crates/ui/src/docking/mod.rs | 2 + crates/ui/src/docking/native_tests.rs | 331 ++++++++++++++++++++++++++ crates/ui/src/docking/testing.rs | 193 ++++++++++++++- 3 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 crates/ui/src/docking/native_tests.rs diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 06f51a0c..7c1f4585 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -49,6 +49,8 @@ mod group_tests; mod iconic_tests; #[cfg(test)] mod model_tests; +#[cfg(all(test, feature = "native-windows"))] +mod native_tests; #[cfg(test)] mod split_tests; #[cfg(test)] diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs new file mode 100644 index 00000000..380aadb1 --- /dev/null +++ b/crates/ui/src/docking/native_tests.rs @@ -0,0 +1,331 @@ +//! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's +//! stand-in for OS windows. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, pos2, vec2}; + +use super::render::floats_shown; +use super::testing::DockDriver; +use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; +use crate::layout::{SplitAxis, SplitSize}; + +/// Desktop position of the main window's content. +const ORIGIN: Pos2 = pos2(100.0, 50.0); + +#[derive(Default)] +struct Content { + /// Panels that may not close. + fixed: Vec, + /// Panels that stay in-window. + in_window: Vec, +} + +impl DockContent for Content { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + ui.label(format!("panel {panel}")); + } + + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } + + fn permissions(&self, panel: &u32) -> Permissions { + Permissions { closable: !self.fixed.contains(panel), ..Permissions::default() } + } + + fn allow_native(&self, panel: &u32) -> bool { + !self.in_window.contains(panel) + } +} + +fn tabs(panels: &[u32]) -> Node { + Node::Tabs { panels: panels.to_vec(), active: 0 } +} + +/// Frames `[1, 2]` and `[3, 4]`, and window `[5, 6]` when `floating`. +fn layout(floating: bool) -> Layout { + Layout { + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[1, 2])), + second: Box::new(tabs(&[3, 4])), + }), + floating: if floating { vec![Floating::single(vec![5, 6], 0, [300.0, 80.0, 240.0, 300.0])] } else { Vec::new() }, + } +} + +fn driver(layout: Layout) -> DockDriver { + DockDriver::new(layout, Content::default()).with_style(|s| s.command_press_floats = true).with_native_host(ORIGIN) +} + +fn window(d: &DockDriver, panel: u32) -> &Floating { + d.layout.floating.iter().find(|w| w.contains(&panel)).expect("floating") +} + +/// The window's OS window rect, and how many frames it drew. +fn os_window(d: &DockDriver, panel: u32) -> Option<(Rect, usize)> { + let viewport = d.viewport(window(d, panel).id); + d.host().windows.get(&viewport).map(|w| (w.rect, w.frames)) +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn every_float_opens_in_its_own_os_window() { + // Undock Panel. + let mut d = driver(layout(false)); + d.layout.apply(Action::Float { panel: 2, rect: [200.0, 100.0, 300.0, 200.0] }).unwrap(); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 2, rect: Some([300.0, 150.0, 300.0, 200.0]) }), "{:?}", out.actions); + assert_eq!(window(&d, 2).native, Some([300.0, 150.0, 300.0, 200.0])); + assert_eq!(os_window(&d, 2).map(|(rect, _)| rect), Some(Rect::from_min_size(pos2(300.0, 150.0), vec2(300.0, 200.0)))); + assert!(d.hit(&Hit::Title(2)).is_none(), "not drawn in the main window"); + + // Undock Panel Group. + let mut d = driver(layout(false)); + d.layout.apply(Action::MoveGroup { panel: 3, to: Target::Float { rect: Some([20.0, 30.0, 250.0, 200.0]) } }).unwrap(); + d.step(vec![]); + assert_eq!(window(&d, 3).native, Some([120.0, 80.0, 250.0, 200.0])); + assert!(os_window(&d, 4).is_some()); + + // Ctrl/Cmd-drag. + let mut d = driver(layout(false)); + let (tab, to) = (d.hit(&Hit::Tab(1)).unwrap().center(), pos2(600.0, 400.0)); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, to); + d.modifiers = Modifiers::NONE; + let out = d.release(to); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); + d.step(vec![]); + let [x, y, ..] = window(&d, 1).rect; + assert_eq!(window(&d, 1).native.map(|r| [r[0], r[1]]), Some([x + ORIGIN.x, y + ORIGIN.y])); + assert!(os_window(&d, 1).is_some()); + + // A restored layout. + let mut d = driver(layout(true)); + d.settle(); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + assert!(os_window(&d, 5).is_some_and(|(_, frames)| frames > 0)); + + // A panel that must stay in-window keeps its window there. + let mut d = driver(layout(false)); + d.content.in_window = vec![5, 6]; + d.layout = layout(true); + d.settle(); + assert_eq!(window(&d, 5).native, None); + assert!(d.hit(&Hit::Title(5)).is_some()); + assert!(d.host().windows.is_empty()); +} + +#[test] +fn closing_the_os_window_closes_its_panels() { + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + assert!(!d.layout.contains(&5) && !d.layout.contains(&6)); + let frames = d.host().windows[&viewport].frames; + d.settle(); + assert_eq!(d.host().windows[&viewport].frames, frames, "no longer drawn"); + + // The bar's × does the same. + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let out = d.window_click(id, pos2(230.0, 9.0)); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + + // A panel that may not close keeps the window open. + let mut d = driver(layout(true)); + d.content.fixed = vec![6]; + let viewport = d.viewport(window(&d, 5).id); + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(out.error.is_some()); + assert!(d.layout.contains(&5)); +} + +#[test] +fn a_window_keeps_its_id_and_os_window_when_its_first_panel_moves_out() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let (_, frames) = os_window(&d, 6).unwrap(); + d.layout.apply(Action::Move { panel: 5, anchor: 1, placement: Placement::Tab { before: None } }).unwrap(); + d.settle(); + assert_eq!(window(&d, 6).id, id); + assert_eq!(d.host().windows.len(), 1, "the same OS window"); + assert!(os_window(&d, 6).unwrap().1 > frames); + + // Moving a whole window keeps its id; merging into another keeps the receiver's. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [10.0, 10.0, 200.0, 200.0])); + let (first, second) = (layout.floating[0].id, layout.floating[1].id); + assert_ne!(first, second); + layout.apply(Action::MoveWindow { panel: 5, to: Target::Float { rect: Some([0.0, 0.0, 300.0, 300.0]) } }).unwrap(); + assert_eq!(layout.floating.iter().find(|w| w.contains(&5)).unwrap().id, first); + layout.apply(Action::Float { panel: 7, rect: [50.0, 50.0, 200.0, 200.0] }).unwrap(); + assert_eq!(layout.floating.iter().find(|w| w.contains(&7)).unwrap().id, second); + layout.apply(Action::MoveWindow { panel: 7, to: Target::At { anchor: 5, placement: Placement::Tab { before: None } } }).unwrap(); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].id, first); + // A group split out of a window makes a new one. + layout.apply(Action::MovePanel { panel: 7, to: Target::Float { rect: Some([0.0, 0.0, 200.0, 200.0]) } }).unwrap(); + let ids: Vec = layout.floating.iter().map(|w| w.id).collect(); + assert!(ids.contains(&first) && ids.len() == 2 && ids[0] != ids[1], "{ids:?}"); +} + +#[test] +fn a_moved_window_reports_its_place_and_monitor() { + let mut d = driver(layout(true)); + d.monitors = vec![Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }]; + let viewport = d.viewport(window(&d, 5).id); + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(pos2(700.0, 200.0), vec2(240.0, 300.0)); + let out = d.step(vec![]); + assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([700.0, 200.0, 240.0, 300.0]) }), "{:?}", out.actions); + assert_eq!(window(&d, 5).rect, [600.0, 150.0, 240.0, 300.0], "the in-window rect follows"); + assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); + + // Without a monitor list, the size egui reports. + let mut d = driver(layout(true)); + d.host().monitor_size = Some(vec2(2560.0, 1440.0)); + d.settle(); + assert_eq!((window(&d, 5).monitor.clone(), window(&d, 5).monitor_size), (None, Some([2560.0, 1440.0]))); +} + +#[test] +fn a_restored_window_opens_on_a_connected_monitor() { + let left = Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let right = Monitor { name: Some("right".into()), rect: [1920.0, 0.0, 2560.0, 1440.0], scale: 2.0 }; + let mut saved = layout(true); + saved.floating[0].native = Some([3000.0, 100.0, 240.0, 300.0]); + saved.floating[0].monitor = Some(right.clone()); + + // Its monitor is connected: where it was. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.monitors = vec![left.clone(), right]; + d.layout = saved.clone(); + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(3000.0, 100.0)); + + // Unplugged: the main window's monitor, at the same offset. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.monitors = vec![left]; + d.layout = saved; + d.settle(); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(1080.0, 100.0)); + assert_eq!(window(&d, 5).native, Some([1080.0, 100.0, 240.0, 300.0])); + assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); +} + +#[test] +fn dragging_the_bar_moves_the_os_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + // Each move is relative to where the window was last moved. + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(70.0, 9.0))]); + let moves: Vec = d + .host() + .commands + .iter() + .filter_map(|(v, c)| match c { + ViewportCommand::OuterPosition(pos) if *v == viewport => Some(*pos), + _ => None, + }) + .collect(); + assert_eq!(moves.last(), Some(&pos2(450.0, 140.0)), "{moves:?}"); + d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window(&d, 5).native, Some([450.0, 140.0, 240.0, 300.0])); + + // Without a reported position (Wayland) the window system moves it. + let mut d = driver(layout(true)); + d.host().no_position = true; + d.step(vec![]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(d.host().commands.iter().any(|(_, c)| *c == ViewportCommand::StartDrag)); +} + +fn id_of(d: &DockDriver) -> u64 { + window(d, 5).id +} + +#[test] +fn floating_windows_hide_while_the_app_is_inactive() { + assert!(floats_shown(true, 10.0, 10.0)); + assert!(floats_shown(false, 10.2, 10.0)); + assert!(!floats_shown(false, 10.3, 10.0)); + + let mut d = driver(layout(true)); + let viewport = d.viewport(window(&d, 5).id); + let hides = + |d: &DockDriver| d.host().commands.iter().filter(|(v, c)| *v == viewport && *c == ViewportCommand::Visible(false)).count(); + // Focus moving to the floating window keeps it. + d.host().focus = None; + d.step(vec![]); + d.host().focus = Some(viewport); + for _ in 0..30 { + d.step(vec![]); + } + assert_eq!(hides(&d), 0); + // Another application: hidden after the delay, shown again on return. + d.host().focus = None; + for _ in 0..10 { + d.step(vec![]); + } + assert_eq!(hides(&d), 0, "not yet"); + for _ in 0..10 { + d.step(vec![]); + } + assert_eq!(hides(&d), 1); + assert!(!d.host().windows[&viewport].visible); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + assert!(d.host().windows[&viewport].visible); +} + +#[test] +fn unused_keys_in_a_native_window_reach_the_app() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id, vec![key.clone(), Event::Text("s".into()), Event::Copy, Event::PointerMoved(pos2(50.0, 50.0))]); + assert_eq!(out.forwarded_events, vec![key, Event::Text("s".into()), Event::Copy]); + // Keys of the main window stay its own. + let out = d.key(Key::A); + assert!(out.forwarded_events.is_empty()); +} + +#[cfg(feature = "serde")] +#[test] +fn a_native_window_saves_its_id_place_and_monitor() { + let mut layout = layout(true); + let monitor = Monitor { name: Some("DP-1".into()), rect: [-1920.0, 0.0, 1920.0, 1080.0], scale: 1.5 }; + layout.floating[0].native = Some([-1500.0, 100.0, 240.0, 300.0]); + layout.floating[0].monitor = Some(monitor); + layout.floating.push(Floating { + monitor_size: Some([2560.0, 1440.0]), + native: Some([5.0, 5.0, 300.0, 200.0]), + ..Floating::single(vec![7], 0, [0.0, 0.0, 300.0, 200.0]) + }); + let json = serde_json::to_string(&layout).unwrap(); + let back: Layout = serde_json::from_str(&json).unwrap(); + assert_eq!(back, layout); + assert_eq!(back.floating.iter().map(|w| w.id).collect::>(), layout.floating.iter().map(|w| w.id).collect::>()); + + // Old layouts get fresh ids, and copies of one id unique ones. + let old = r#"{"root": null, "floating": [{"panels": [5], "active": 0, "rect": [0, 0, 100, 100]}, {"panels": [6], "active": 0, "rect": [0, 0, 100, 100]}]}"#; + let old: Layout = serde_json::from_str(old).unwrap(); + assert_ne!(old.floating[0].id, old.floating[1].id); + let mut copies = layout.clone(); + copies.floating[1].id = copies.floating[0].id; + let copies: Layout = serde_json::from_str(&serde_json::to_string(&copies).unwrap()).unwrap(); + assert_eq!(copies.floating[0].id, layout.floating[0].id); + assert_ne!(copies.floating[1].id, copies.floating[0].id); + // An in-window window writes no id, as older builds did. + assert!(!serde_json::to_string(&Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0])).unwrap().contains("id")); +} diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index ed725b93..4876b624 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -12,7 +12,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, UiBuilder, epaint::ClippedShape, vec2}; -use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout}; +use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout, Monitor}; /// Pointer travel that starts a drag before moving to its destination. const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0); @@ -44,6 +44,11 @@ pub struct DockDriver { pub raise: Option

, /// Passed to [`DockArea::float_bounds`]. pub float_bounds: Option, + /// Passed to [`DockArea::monitors`]. + pub monitors: Vec, + /// The stand-in for OS windows, from [`Self::with_native_host`]. + #[cfg(feature = "native-windows")] + pub host: Option>>, style: Option, shapes: Vec, time: f64, @@ -66,6 +71,9 @@ impl> Do maximized: None, raise: None, float_bounds: None, + monitors: Vec::new(), + #[cfg(feature = "native-windows")] + host: None, style: None, shapes: Vec::new(), time: 0.0, @@ -92,14 +100,23 @@ impl> Do pub fn frame(&mut self, events: Vec) -> DockOutput

{ let mut output = DockOutput::default(); self.time += 1.0 / 60.0; - let input = RawInput { + #[allow(unused_mut)] + let mut input = RawInput { screen_rect: Some(self.screen), events: std::iter::once(Event::ModifiersChanged(self.modifiers)).chain(events).collect(), focused: self.focused, time: Some(self.time), ..Default::default() }; + #[cfg(feature = "native-windows")] + if let Some(host) = &self.host { + let mut host = host.borrow_mut(); + (host.time, host.screen) = (self.time, self.screen); + input.viewports = host.infos(); + input.focused = host.focus == Some(egui::ViewportId::ROOT); + } let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); + let monitors = self.monitors.clone(); let (style_fn, maximized, raise, auto, bounds) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds); let mut full = self.ctx.run_ui(input, |ui| { @@ -108,7 +125,7 @@ impl> Do adjust(&mut style); } ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { - let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto); + let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto).monitors(monitors.clone()); if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } @@ -119,10 +136,58 @@ impl> Do }); }); full.textures_delta.clear(); + #[cfg(feature = "native-windows")] + if let Some(host) = &self.host { + host.borrow_mut().apply(&full); + } self.shapes = full.shapes; output } + /// Draw native floating windows in a stand-in for OS windows (feature `native-windows`): + /// the main window's content sits at `origin` on the desktop, and each native window runs + /// its own frame on this context with its own input and desktop rect. Settles again. + #[cfg(feature = "native-windows")] + pub fn with_native_host(mut self, origin: Pos2) -> Self { + let host = std::rc::Rc::new(std::cell::RefCell::new(NativeHost::new(origin))); + let shared = host.clone(); + Context::set_immediate_viewport_renderer(move |ctx, viewport| NativeHost::render(&shared, ctx, viewport)); + self.ctx.set_embed_viewports(false); + self.host = Some(host); + self.settle(); + self + } + + /// The stand-in for OS windows. Panics without [`Self::with_native_host`]. + #[cfg(feature = "native-windows")] + pub fn host(&self) -> std::cell::RefMut<'_, NativeHost> { + self.host.as_ref().expect("with_native_host").borrow_mut() + } + + /// The viewport of the floating window with this [`super::Floating::id`]. + #[cfg(feature = "native-windows")] + pub fn viewport(&self, window: u64) -> egui::ViewportId { + DockArea::

::window_viewport(self.id, window) + } + + /// Give `events`, in its own coordinates, to the native window of the floating window + /// `window` on the next frame, and step. + #[cfg(feature = "native-windows")] + pub fn window_step(&mut self, window: u64, events: Vec) -> DockOutput

{ + let viewport = self.viewport(window); + if let Some(target) = self.host().windows.get_mut(&viewport) { + target.events.extend(events); + } + self.step(Vec::new()) + } + + /// Press and release at `pos` in a native window; the output is the release frame's. + #[cfg(feature = "native-windows")] + pub fn window_click(&mut self, window: u64, pos: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, true)]); + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + /// Render one frame and apply its actions, all or none, with the content's permissions. A /// rejected batch is reported in the output's `error`. pub fn step(&mut self, events: Vec) -> DockOutput

{ @@ -240,3 +305,125 @@ impl> Do Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: self.modifiers } } } + +/// OS windows for [`DockDriver::with_native_host`] (feature `native-windows`). +#[cfg(feature = "native-windows")] +pub struct NativeHost { + /// Desktop position of the main window's content. + pub origin: Pos2, + /// The monitor size egui reports for every window. + pub monitor_size: Option, + /// The window with focus: the main window (`ViewportId::ROOT`, the default), a native + /// window, or `None` while another application is active. + pub focus: Option, + /// Windows report no position, as on Wayland. + pub no_position: bool, + pub windows: std::collections::HashMap, + /// Every command sent to a native window, in order. + pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, + screen: Rect, + time: f64, +} + +/// One OS window of a [`NativeHost`]. +#[cfg(feature = "native-windows")] +#[derive(Debug)] +pub struct HostWindow { + /// Desktop rect of its content: where it opened, then moved by `OuterPosition`. + pub rect: Rect, + pub visible: bool, + /// Frames it drew. + pub frames: usize, + /// Events for its next frame, in its own coordinates. + pub events: Vec, + /// Ask to close it on its next frame, as the OS close button does. + pub close: bool, + /// Shapes of its last frame. + pub shapes: Vec, +} + +#[cfg(feature = "native-windows")] +impl NativeHost { + fn new(origin: Pos2) -> Self { + Self { + origin, + monitor_size: None, + focus: Some(egui::ViewportId::ROOT), + no_position: false, + windows: Default::default(), + commands: Vec::new(), + screen: Rect::NOTHING, + time: 0.0, + } + } + + /// What egui knows of every window. + fn infos(&self) -> egui::ViewportIdMap { + let info = |id: egui::ViewportId, rect: Rect| egui::ViewportInfo { + inner_rect: Some(rect), + outer_rect: (!self.no_position).then_some(rect), + focused: Some(self.focus == Some(id)), + monitor_size: self.monitor_size, + ..Default::default() + }; + let main = Rect::from_min_size(self.origin, self.screen.size()); + std::iter::once((egui::ViewportId::ROOT, info(egui::ViewportId::ROOT, main))) + .chain(self.windows.iter().map(|(id, window)| (*id, info(*id, window.rect)))) + .collect() + } + + /// Run one native window's frame. + fn render(host: &std::cell::RefCell, ctx: &Context, mut viewport: egui::ImmediateViewport<'_>) { + let id = viewport.ids.this; + let input = { + let mut host = host.borrow_mut(); + let builder = &viewport.builder; + let rect = Rect::from_min_size(builder.position.unwrap_or_default(), builder.inner_size.unwrap_or(vec2(300.0, 200.0))); + let visible = builder.visible.unwrap_or(true); + host.windows.entry(id).or_insert_with(|| HostWindow { rect, visible, frames: 0, events: Vec::new(), close: false, shapes: Vec::new() }); + let mut viewports = host.infos(); + let (time, focused) = (host.time, host.focus == Some(id)); + let Some(window) = host.windows.get_mut(&id) else { return }; + window.frames += 1; + if std::mem::take(&mut window.close) + && let Some(info) = viewports.get_mut(&id) + { + info.events.push(egui::ViewportEvent::Close); + } + RawInput { + viewport_id: id, + viewports, + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, window.rect.size())), + events: std::mem::take(&mut window.events), + focused, + time: Some(time), + ..Default::default() + } + }; + let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)); + full.textures_delta.clear(); + let mut host = host.borrow_mut(); + host.apply(&full); + if let Some(window) = host.windows.get_mut(&id) { + window.shapes = full.shapes; + } + } + + /// Carry out the commands a frame sent to native windows. + fn apply(&mut self, full: &egui::FullOutput) { + for (id, output) in &full.viewport_output { + if *id == egui::ViewportId::ROOT { + continue; + } + for command in &output.commands { + self.commands.push((*id, command.clone())); + let Some(window) = self.windows.get_mut(id) else { continue }; + match command { + egui::ViewportCommand::OuterPosition(pos) => window.rect = Rect::from_min_size(*pos, window.rect.size()), + egui::ViewportCommand::Visible(visible) => window.visible = *visible, + _ => {} + } + } + } + } +} From 99f4511e6dd21a5910f631c384e5d325a0489f8b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 08:27:19 +0000 Subject: [PATCH 092/160] docking: document native windows --- docs/ui-docking.md | 58 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 177c1c2d..e4e1cf11 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -38,7 +38,8 @@ their sides. | `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | A `Floating` window holds a tree like the docked layout's (`root`: splits, tab groups and -accordions, no docks), a rect, an optional native-window rect and `minimized`. +accordions, no docks), a rect, an optional native-window rect with the monitor it is on, and +`minimized`. Its `id` names the window across panel moves (see [Native windows](#native-windows)). `Floating::single` makes a window with one group, `Floating::new` one with a tree, and `Floating::stack` one with groups stacked top to bottom (a vertical split sized by the groups' heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Saved layouts keep loading: a window stored as one group (v1) or as a @@ -80,7 +81,8 @@ layout.apply(Action::Move { half the new frame takes. - Other actions select, minimize, collapse to icons, resize splits (`ResizeSplit` in the docked layout, `ResizeWindowSplit` in the floating window holding a panel), columns and groups, and - move a floating window to its own OS window (`SetNative`, feature `native-windows`). + move a floating window to its own OS window (`SetNative`, and `SetMonitor` for the monitor it + is on, feature `native-windows`). - A move between windows (docked to floating, floating to floating) is one action, checked as a whole like any other. @@ -310,6 +312,53 @@ panel and Lock change the workspace directly. Named workspaces and Reset belong `is_app_owned` is true for them and `apply` refuses them. `DockCommand::IDS` lists every id for the app's command registry. App-specific aliases stay in the app. +## Native windows + +With feature `native-windows`, on a backend with egui viewports (`!ctx.embed_viewports()`, +eframe on desktop), every floating window opens in its own OS window: a Ctrl/Cmd-drag, Undock +Panel and Undock Panel Group, every other drop that floats, and the floating windows of a +restored layout. A window still in-window opens over its place in the app window (the main +viewport's `inner_rect.min` plus its rect). A window with a panel whose +`DockContent::allow_native` is false, or that is not floatable, stays in-window. + +- **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change, + including when its first panel moves out. `Floating::new` and loading draw fresh ids; a layout + loads with unique ids; a window that merges into another takes the receiver's id, and a whole + window that moves (floated again, or its only group dragged) keeps its own. +- **Level.** The windows stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the + document does not bury them. While none of the app's windows has focus they hide + (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide waits + 250 ms, so focus moving between the app's own windows does not flicker. +- **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: + the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On + Windows and Linux the OS frame is off and the window resizes from its edges; on macOS the + frame stays with its title bar and buttons hidden, so the OS resizes it. On Wayland, where a + window cannot be placed, the bar hands the move to the compositor. +- **Close.** The bar's × and the OS close request close the window's panels, as the + floating window's × does. They reopen from the app's Window menu. +- **Place.** The window's desktop rect is in `Floating::native`, and the in-window `rect` + follows it for builds that draw the window in-window. `Floating::monitor` is the monitor + holding the window's center, from the list the app passes to `DockArea::monitors` (each a + `Monitor { name, rect, scale }` in desktop logical points). egui 0.36 reports no monitors, so + an app fills the list from its backend when it can; without one, `Floating::monitor_size` + keeps the monitor size egui reports. +- **Restore.** A restored window opens on its saved monitor when one with that name (or else that + rect) is connected, moved in proportion if the monitor's rect changed. Otherwise it opens on + the main window's monitor at the same offset from that monitor's origin, wholly on screen. + Without a monitor list, it is clamped onto the main window's monitor, taken from + `monitor_size` and the main window's position. Hostile rects are made safe first. +- **Shortcuts.** egui gives each OS window only its own input. Key, text, copy, cut and paste + events that reach a native window and that none of its widgets used come back in + `DockOutput::forwarded_events`; the app feeds them to the shortcut handling it runs on the main + window. A window whose text field has focus forwards nothing. +- **Drags.** Tabs and groups do not drag between OS windows yet: a drag that starts in a native + window is cancelled, and targets in another window are ignored. + +On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the +page at their in-window rect, `native` and the monitor fields are kept but unused, and +`forwarded_events` stays empty. Without the feature, the fields exist and are never set by the +renderer. + ## Labels Every visible and accessibility label is a `*_label` field of `DockStyle`, with an English @@ -349,7 +398,10 @@ assert!(driver.hit(&Hit::ColumnBar(PanelId::Layers)).is_some()); ``` It also has `press`, `release`, `click`, `hold`, `key`, `focus`, `hits`, and checks for painted -previews (`filled`, `outlined`). +previews (`filled`, `outlined`). With `native-windows`, `with_native_host(origin)` stands in for +OS windows: the main window's content sits at `origin` on the desktop, and each native window +runs its own frame with its own input and desktop rect (`host()`, `window_step`, +`window_click`). craft-ui's own tests in `crates/ui/tests/docking_workflows.rs`: From 53947dbfe8602f8c31e001ea2396cc147fa9d983 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:35:35 +0000 Subject: [PATCH 093/160] docking: the undock hint test expects the platform's key --- crates/ui/src/docking/split_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index c9485557..e87ed44f 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -439,7 +439,9 @@ fn random_drops_leave_every_group_its_minimum_and_every_divider_travel() { #[test] fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { - const HINT: &str = "Hold Ctrl to undock"; + // The hint names the platform's key, as `ctx.os()` reports it in tests. + let key = if egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; + let hint = format!("Hold {key} to undock"); let floated = |actions: &[Action], panel: u32| match actions { [Action::Float { panel: p, .. }] => *p == panel, [Action::MoveGroup { panel: p, to: Target::Float { .. } }] => *p == panel, @@ -465,14 +467,14 @@ fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { // Without the modifier, a release over a group docks there; the preview names the modifier. let mut d = film(); d.hold(from, over); - assert!(texts(&d).iter().any(|t| t == HINT), "{from:?}: {:?}", texts(&d)); + assert!(texts(&d).iter().any(|t| *t == hint), "{from:?}: {:?}", texts(&d)); let out = d.release(over); assert!(!out.actions.is_empty() && !floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); // Held, it floats, and the hint is gone. let mut d = film(); d.modifiers = Modifiers::COMMAND; d.hold(from, over); - assert!(!texts(&d).iter().any(|t| t == HINT), "{from:?}"); + assert!(!texts(&d).iter().any(|t| *t == hint), "{from:?}"); let out = d.release(over); assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); } From e350bfdb0174870fe1460981e43b76ed6479bea1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:40:17 +0000 Subject: [PATCH 094/160] docking: the undock hint test uses contains, as clippy asks --- crates/ui/src/docking/split_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index e87ed44f..69048efd 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -467,14 +467,14 @@ fn ctrl_or_cmd_floats_from_a_tab_or_a_drag_bar_and_the_preview_says_so() { // Without the modifier, a release over a group docks there; the preview names the modifier. let mut d = film(); d.hold(from, over); - assert!(texts(&d).iter().any(|t| *t == hint), "{from:?}: {:?}", texts(&d)); + assert!(texts(&d).contains(&hint), "{from:?}: {:?}", texts(&d)); let out = d.release(over); assert!(!out.actions.is_empty() && !floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); // Held, it floats, and the hint is gone. let mut d = film(); d.modifiers = Modifiers::COMMAND; d.hold(from, over); - assert!(!texts(&d).iter().any(|t| *t == hint), "{from:?}"); + assert!(!texts(&d).contains(&hint), "{from:?}"); let out = d.release(over); assert!(floated(&out.actions, PROJECT[0]), "{from:?}: {:?}", out.actions); } From 48272d3932ae354fb4df4ad4e4c8e6d76f64123e Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 16:42:36 +0000 Subject: [PATCH 095/160] docking: the host stand-in needs no expect, and the label count drops with New Window --- crates/ui/src/docking/native_tests.rs | 1 + crates/ui/src/docking/testing.rs | 13 +++++++++---- crates/ui/src/docking/trial_tests.rs | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 380aadb1..ae7dc857 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -58,6 +58,7 @@ fn driver(layout: Layout) -> DockDriver { DockDriver::new(layout, Content::default()).with_style(|s| s.command_press_floats = true).with_native_host(ORIGIN) } +#[allow(clippy::expect_used)] fn window(d: &DockDriver, panel: u32) -> &Floating { d.layout.floating.iter().find(|w| w.contains(&panel)).expect("floating") } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 4876b624..f0444f49 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -46,9 +46,11 @@ pub struct DockDriver { pub float_bounds: Option, /// Passed to [`DockArea::monitors`]. pub monitors: Vec, - /// The stand-in for OS windows, from [`Self::with_native_host`]. + /// The stand-in for OS windows, in use after [`Self::with_native_host`]. #[cfg(feature = "native-windows")] - pub host: Option>>, + host: Option>>, + #[cfg(feature = "native-windows")] + no_host: std::cell::RefCell, style: Option, shapes: Vec, time: f64, @@ -74,6 +76,8 @@ impl> Do monitors: Vec::new(), #[cfg(feature = "native-windows")] host: None, + #[cfg(feature = "native-windows")] + no_host: std::cell::RefCell::new(NativeHost::new(Pos2::ZERO)), style: None, shapes: Vec::new(), time: 0.0, @@ -158,10 +162,11 @@ impl> Do self } - /// The stand-in for OS windows. Panics without [`Self::with_native_host`]. + /// The stand-in for OS windows: without [`Self::with_native_host`], one with no windows + /// that nothing uses. #[cfg(feature = "native-windows")] pub fn host(&self) -> std::cell::RefMut<'_, NativeHost> { - self.host.as_ref().expect("with_native_host").borrow_mut() + self.host.as_deref().unwrap_or(&self.no_host).borrow_mut() } /// The viewport of the floating window with this [`super::Floating::id`]. diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index d16cf98d..a8fe97da 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -195,7 +195,7 @@ fn localized_covers_every_label_and_contains_takes_a_borrowed_panel() { }); full.textures_delta.clear(); let values: Vec<&str> = debug.split("_label: \"").skip(1).filter_map(|rest| rest.split('"').next()).collect(); - assert_eq!(values.len(), 25, "{debug}"); + assert_eq!(values.len(), 24, "{debug}"); assert!(values.iter().all(|v| v.starts_with("dock.")), "a label without a key: {values:?}"); let unique: std::collections::HashSet<_> = values.iter().collect(); assert_eq!(unique.len(), values.len()); From 141ddc92f653beeff342b889ab0ac0fcb9d86f3f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:24:19 +0000 Subject: [PATCH 096/160] docking: which window lies under a desktop point --- crates/ui/src/docking/desktop.rs | 69 ++++++++++++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 2 + 2 files changed, 71 insertions(+) create mode 100644 crates/ui/src/docking/desktop.rs diff --git a/crates/ui/src/docking/desktop.rs b/crates/ui/src/docking/desktop.rs new file mode 100644 index 00000000..2047a641 --- /dev/null +++ b/crates/ui/src/docking/desktop.rs @@ -0,0 +1,69 @@ +//! Which of the application's windows lies under a point on the desktop (feature +//! `native-windows`). Points and rects are in desktop logical points. + +use egui::{Pos2, Rect}; + +/// Where a desktop point falls. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Over { + /// The floating native window with this [`super::Floating::id`], at this point in its own + /// coordinates. + Window(u64, Pos2), + /// The main window, at this point in its coordinates. + Main(Pos2), + /// None of the application's windows. + Desktop, +} + +/// The window under `point`: the first of `windows` (floating native windows by id and desktop +/// rect, front first) that holds it, then `main` (the main window's content). The window +/// `dragged` follows the pointer by its bar and is never its own target. +pub(super) fn window_at(point: Pos2, windows: &[(u64, Rect)], main: Option, dragged: Option) -> Over { + if !point.is_finite() { + return Over::Desktop; + } + if let Some((id, rect)) = windows.iter().find(|(id, rect)| Some(*id) != dragged && rect.contains(point)) { + return Over::Window(*id, point - rect.min.to_vec2()); + } + match main { + Some(main) if main.contains(point) => Over::Main(point - main.min.to_vec2()), + _ => Over::Desktop, + } +} + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + + use super::*; + + fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect { + Rect::from_min_size(pos2(x, y), vec2(w, h)) + } + + #[test] + fn the_front_window_wins_then_the_main_window_then_the_desktop() { + let main = Some(rect(100.0, 50.0, 800.0, 600.0)); + // Window 2 lies over window 1, and both over the main window. + let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))]; + assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, None), Over::Window(2, pos2(50.0, 50.0))); + assert_eq!(window_at(pos2(260.0, 90.0), &windows, main, None), Over::Window(1, pos2(10.0, 10.0))); + assert_eq!(window_at(pos2(120.0, 60.0), &windows, main, None), Over::Main(pos2(20.0, 10.0))); + assert_eq!(window_at(pos2(20.0, 20.0), &windows, main, None), Over::Desktop); + // A floating window outside the main window. + let windows = [(3, rect(1000.0, 0.0, 100.0, 100.0))]; + assert_eq!(window_at(pos2(1050.0, 40.0), &windows, main, None), Over::Window(3, pos2(50.0, 40.0))); + // Unknown main window, a point that is not one. + assert_eq!(window_at(pos2(120.0, 60.0), &[], None, None), Over::Desktop); + assert_eq!(window_at(pos2(f32::NAN, 60.0), &windows, main, None), Over::Desktop); + } + + #[test] + fn the_dragged_window_is_looked_through() { + let main = Some(rect(0.0, 0.0, 800.0, 600.0)); + let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))]; + assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, Some(2)), Over::Window(1, pos2(100.0, 70.0))); + assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, Some(1)), Over::Window(2, pos2(50.0, 50.0))); + assert_eq!(window_at(pos2(480.0, 280.0), &windows, main, Some(2)), Over::Main(pos2(480.0, 280.0))); + } +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 7c1f4585..6fefc004 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -8,6 +8,8 @@ pub mod chrome; #[cfg(feature = "serde")] mod command; +#[cfg(feature = "native-windows")] +mod desktop; mod dock_model; mod hits; mod labels; From 84b42eacc917cc737f9f2071c4ab1179e542cd91 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:24:25 +0000 Subject: [PATCH 097/160] docking: tabs, groups and windows drag between OS windows The window where a drag starts owns it and writes its pointer with its desktop origin. The window under that point shows the preview with its own targets, and the release docks there, or floats a new window over the desktop. Leaving a window is no release with native viewports. Dragging the only tab or group of a window moves it, and its bar docks the whole window. --- crates/ui/src/docking/native_tests.rs | 226 ++++++++++++- crates/ui/src/docking/render/cross.rs | 430 ++++++++++++++++++++++++ crates/ui/src/docking/render/frame.rs | 6 + crates/ui/src/docking/render/mod.rs | 5 + crates/ui/src/docking/render/native.rs | 199 ++++++++--- crates/ui/src/docking/render/preview.rs | 110 ++++-- crates/ui/src/docking/surface.rs | 6 + crates/ui/src/docking/testing.rs | 24 ++ 8 files changed, 923 insertions(+), 83 deletions(-) create mode 100644 crates/ui/src/docking/render/cross.rs diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index ae7dc857..4accdac0 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1,7 +1,7 @@ //! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's //! stand-in for OS windows. -use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, pos2, vec2}; +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; use super::render::floats_shown; use super::testing::DockDriver; @@ -239,8 +239,12 @@ fn dragging_the_bar_moves_the_os_window() { }) .collect(); assert_eq!(moves.last(), Some(&pos2(450.0, 140.0)), "{moves:?}"); - d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); - assert_eq!(window(&d, 5).native, Some([450.0, 140.0, 240.0, 300.0])); + // Past the main window's right edge, the desktop: released there, it stays. + d.window_step(id, vec![Event::PointerMoved(pos2(540.0, 9.0))]); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert!(out.actions.iter().all(|a| matches!(a, Action::SetNative { .. } | Action::MoveFloating { .. })), "{:?}", out.actions); + d.step(vec![]); + assert_eq!(window(&d, 5).native, Some([950.0, 140.0, 240.0, 300.0])); // Without a reported position (Wayland) the window system moves it. let mut d = driver(layout(true)); @@ -330,3 +334,219 @@ fn a_native_window_saves_its_id_place_and_monitor() { // An in-window window writes no id, as older builds did. assert!(!serde_json::to_string(&Floating::single(vec![1u32], 0, [0.0, 0.0, 9.0, 9.0])).unwrap().contains("id")); } + +/// Height of the bar at the top of a native window. +const BAR: f32 = 18.0; +/// A tab row's middle in a native window, below the bar. +const TAB_ROW: f32 = BAR + 13.0; + +/// Window `[5, 6]` of [`layout`] and window `[7]` below it, apart on the desktop. +fn two_windows() -> Layout { + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [20.0, 420.0, 240.0, 150.0])); + layout +} + +fn in_root(d: &DockDriver, panel: u32) -> bool { + d.layout.contains(&panel) && !d.layout.floating.iter().any(|w| w.contains(&panel)) +} + +#[test] +fn a_tab_dragged_past_the_main_windows_edge_floats_at_the_desktop_point() { + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + // Left of the main window, which starts at x = 100 on the desktop. + let out = pos2(-60.0, 120.0); + d.hold(tab, out); + // The pointer leaves the window: with native windows that is no release. + let gone = d.leave(); + assert!(gone.actions.is_empty(), "{:?}", gone.actions); + let released = d.release(out); + assert!(matches!(released.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", released.actions); + d.step(vec![]); + // The point grabbed (where the drag began, past the nudge) stays under the pointer: the + // tab's group starts at the window's top-left, below the bar. + let (desktop, grab) = (ORIGIN + out.to_vec2(), tab + vec2(8.0, 6.0)); + let min = pos2(desktop.x - grab.x, desktop.y - grab.y - BAR); + let native = window(&d, 1).native.unwrap(); + assert_eq!(pos2(native[0], native[1]), min); + assert_eq!(os_window(&d, 1).unwrap().0.min, min); +} + +#[test] +fn a_tab_dragged_out_and_back_docks_in_the_main_window() { + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(-60.0, 120.0)); + d.leave(); + // Back over the middle of frame [3, 4]. + let back = pos2(600.0, 300.0); + d.move_to(back); + let out = d.release(back); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 3 | 4, .. }]), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); +} + +#[test] +fn a_tab_from_a_native_window_docks_in_a_frame_of_the_main_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + // Frame [1, 2] at x = 100 in the main window, on the desktop. + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), local); + let out = d.window_release(id, local); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 1 | 2, .. }]), "{:?}", out.actions); + assert!(in_root(&d, 5)); + assert!(window(&d, 6).contains(&6)); +} + +#[test] +fn a_tab_from_a_native_window_joins_another_native_windows_tab_row() { + let mut d = driver(two_windows()); + let (from, to) = (window(&d, 5).id, window(&d, 7).id); + let row = d.host().windows[&d.viewport(to)].rect.min + vec2(200.0, TAB_ROW); + let local = d.window_local(from, row).unwrap(); + d.window_hold(from, pos2(15.0, TAB_ROW), local); + let out = d.window_release(from, local); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 7, placement: Placement::Tab { .. } }]), "{:?}", out.actions); + assert!(window(&d, 7).contains(&5)); +} + +#[test] +fn dragging_the_bar_onto_the_main_window_docks_the_whole_window() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + // The pointer over the tab row of frame [3, 4], which starts at x = 400 in the main window. + // A moving window joins a group there; it splits only groups of other floating windows. + let local = d.window_local(id, ORIGIN + vec2(450.0, 13.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert!(out.actions.iter().any(|a| matches!(a, Action::MoveWindow { panel: 5, to: Target::At { anchor: 3 | 4, .. } })), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); +} + +#[test] +fn dragging_the_only_tab_of_a_native_window_moves_the_window() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let mut d = driver(layout); + let id = window(&d, 7).id; + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(1000.0, TAB_ROW)); + let out = d.window_release(id, pos2(15.0, TAB_ROW)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Float { .. } | Action::Move { .. })), "{:?}", out.actions); + d.step(vec![]); + assert_eq!(d.layout.floating.len(), 1); + assert_eq!(window(&d, 7).id, id, "the same window"); + // It followed the pointer onto the desktop, the pressed point under it: the nudge moved it + // by (8, 6), then the pointer went 1000 - 15 points right of the press. + assert_eq!(window(&d, 7).native.map(|r| [r[0], r[1]]), Some([408.0 + 985.0, 136.0])); +} + +#[test] +fn leaving_the_window_ends_a_drag_only_with_embedded_viewports() { + // Native viewports: the release still arrives, so leaving is no drop. + let mut d = driver(layout(false)); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(790.0, 300.0)); + let out = d.leave(); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert!(super::is_dragging::(&d.ctx, d.id)); + d.step(vec![]); + assert!(super::is_dragging::(&d.ctx, d.id)); + + // Embedded: leaving floats the panel at the edge, as before. + let mut d = DockDriver::new(layout(false), Content::default()); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(790.0, 300.0)); + let out = d.leave(); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); +} + +#[test] +fn escape_cancels_a_drag_outside_the_window() { + let mut d = driver(layout(false)); + let before = d.layout.clone(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + d.hold(tab, pos2(-60.0, 120.0)); + d.leave(); + d.key(Key::Escape); + let out = d.release(pos2(-60.0, 120.0)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, before); +} + +/// Shapes drawn in `color`: a drop preview's outline, or a fill. +fn accented(shapes: &[ClippedShape], color: Color32) -> usize { + shapes + .iter() + .filter(|clipped| match &clipped.shape { + egui::Shape::Rect(rect) => rect.stroke.color == color || rect.fill == color, + egui::Shape::Path(path) => path.fill == color.gamma_multiply(0.25), + _ => false, + }) + .count() +} + +#[test] +fn the_preview_is_drawn_in_the_window_under_the_pointer() { + let accent = Color32::from_rgb(250, 10, 200); + let mut d = DockDriver::new(two_windows(), Content::default()) + .with_style(move |s| { + s.accent = accent; + s.float_hint = false; + }) + .with_native_host(ORIGIN); + let to = window(&d, 7).id; + let viewport = d.viewport(to); + let (main_before, window_before) = (accented(d.shapes(), accent), accented(&d.host().windows[&viewport].shapes, accent)); + // Tab 1 of the main window over window [7]'s body. + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.move_to(body); + assert!(accented(&d.host().windows[&viewport].shapes, accent) > window_before, "drawn in the destination"); + assert_eq!(accented(d.shapes(), accent), main_before, "not in the source"); + let out = d.release(body); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 7, .. }]), "{:?}", out.actions); +} + +#[test] +fn a_command_drag_floats_even_over_another_window() { + let mut d = driver(two_windows()); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.modifiers = Modifiers::COMMAND; + d.hold(tab, over); + let out = d.release(over); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 1, .. }]), "{:?}", out.actions); + d.step(vec![]); + let native = window(&d, 1).native.unwrap(); + let desktop = ORIGIN + over.to_vec2(); + assert_eq!(pos2(native[0], native[1]), pos2(desktop.x - tab.x - 8.0, desktop.y - tab.y - 6.0 - BAR)); +} + +#[test] +fn without_window_positions_a_drag_out_of_a_native_window_floats() { + let mut d = driver(two_windows()); + d.host().no_position = true; + d.settle(); + let id = window(&d, 5).id; + // Over window [7] on the desktop, which cannot be known: a new window. + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(-200.0, 400.0)); + let out = d.window_release(id, pos2(-200.0, 400.0)); + assert!(matches!(out.actions.as_slice(), [Action::Float { panel: 5, .. }]), "{:?}", out.actions); + // The only tab left moves its window, by handing the move to the window system. A press + // where the last one was, this soon, would be a double-click. + let id = window(&d, 6).id; + for _ in 0..40 { + d.step(vec![]); + } + d.window_hold(id, pos2(15.0, TAB_ROW), pos2(120.0, 200.0)); + assert!(d.host().commands.iter().any(|(v, c)| *v == d.viewport(id) && *c == ViewportCommand::StartDrag)); + let out = d.window_release(id, pos2(120.0, 200.0)); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Float { .. } | Action::Move { .. })), "{:?}", out.actions); +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs new file mode 100644 index 00000000..96b7261e --- /dev/null +++ b/crates/ui/src/docking/render/cross.rs @@ -0,0 +1,430 @@ +//! Drags across OS windows (feature `native-windows`, where viewports are native). +//! +//! The window where a drag started owns it: the window system sends it the pointer's moves +//! and the release while the button is held, inside the window or not. Each frame the owner +//! writes the pointer, with its own desktop origin, under the area's `"drag-pointer"`. The +//! context's data is shared by every viewport. The window under the desktop point (floating +//! windows first, front first, then the main window) shows the drop preview in its own +//! frame, with the targets it drew. The release is resolved at the end of the main window's +//! frame, after every native window has drawn. + +use std::{cell::Cell, fmt::Debug, hash::Hash}; + +use egui::{Context, Key, Pos2, Rect, Ui, Vec2, vec2}; + +use super::floating::WindowHit; +use super::native::{BAR_HEIGHT, WindowDrag}; +use super::preview::DragInput; +use super::{Renderer, clamp_float, dock::group_action}; +use crate::docking::desktop::{Over, window_at}; +use crate::docking::targets::DockDrop; +use crate::docking::{Action, Drag, DropTarget, Floating, GroupDrag, GroupGesture, Target, cancel_drag}; + +/// Key of [`DragPointer`] in the context's data, under the area's id. +pub(super) const POINTER: &str = "drag-pointer"; +/// Key of whether a target was offered last frame, for the source window's undock hint. +const OFFERED: &str = "drag-offered"; + +/// The pointer of a drag, as the window that owns the drag last saw it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) struct DragPointer { + /// [`Floating::id`] of the owning native window; `None` for the main window. + pub owner: Option, + /// Desktop position of the owner's (0, 0). `None` where the window system reports no + /// positions (Wayland). + pub origin: Option, + /// In the owner's coordinates. Kept while egui reports no position. + pub local: Option, + pub down: bool, + pub released: bool, + pub escape: bool, + /// Ctrl or Cmd held: the drop floats. + pub command: bool, + /// The window a bar drag moves. It follows the pointer and is never its own target. + pub window: Option, +} + +impl DragPointer { + fn desktop(&self) -> Option { + Some(self.origin? + self.local?.to_vec2()) + } +} + +/// A native window's drop targets from its frame, in its own coordinates. +pub(in crate::docking) struct Surface

{ + pub window: u64, + /// Desktop position of the window's (0, 0), where known. + pub origin: Option, + /// The window's content, in its coordinates. + pub bounds: Rect, + pub targets: Vec>, + pub dock_targets: Vec>, + pub windows: Vec>, +} + +/// Where a drag's pointer is. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Dest { + Main(Pos2), + Window(u64, Pos2), + /// Over no window of the application: the desktop point, where known. + Desktop(Option), +} + +/// What one frame of a dock area knows of its OS windows. +pub(in crate::docking) struct Cross

{ + /// Viewports are native: drags cross windows. + pub on: bool, + /// One of the application's windows has focus. + pub focused: bool, + /// The main window's content on the desktop, where known. + pub main: Option, + /// Shown native windows' desktop rects, front first. + pub windows: Vec<(u64, Rect)>, + /// Targets of the native windows drawn this frame. + pub surfaces: Vec>, + /// The drag's pointer, as its owner wrote it this frame. + pub pointer: Option, + /// A bar drag released this frame. + pub release: Option<(WindowDrag, DragPointer)>, + /// Drawing for a window other than the drag's source: the undock hint is the source's. + pub quiet: bool, + /// A target was offered while `quiet`. + pub offered: Cell, +} + +impl

Default for Cross

{ + fn default() -> Self { + Self { + on: false, + focused: true, + main: None, + windows: Vec::new(), + surfaces: Vec::new(), + pointer: None, + release: None, + quiet: false, + offered: Cell::new(false), + } + } +} + +impl Renderer<'_, P> { + /// Write the pointer of the drag this window owns. `origin` is the window's desktop + /// origin for this frame's input. + pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option, origin: Option, window: Option) -> DragPointer { + let key = self.id.with(POINTER); + let kept = ctx.data(|d| d.get_temp::(key)).filter(|p| p.owner == owner).and_then(|p| p.local); + let pointer = ctx.input(|i| DragPointer { + owner, + origin, + local: i.pointer.latest_pos().or(i.pointer.interact_pos()).filter(|p| p.is_finite()).or(kept), + down: i.pointer.primary_down(), + released: i.pointer.primary_released(), + escape: i.key_pressed(Key::Escape), + command: i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd, + window, + }); + ctx.data_mut(|d| d.insert_temp(key, pointer)); + self.cross.pointer = Some(pointer); + pointer + } + + /// Where the drag's pointer is. Without desktop positions only the owner's own window is + /// known, and a point outside it is on the desktop. `current` is a window drawing now. + fn destination(&self, pointer: &DragPointer, current: Option<&Surface

>) -> Option { + if let (Some(point), Some(main)) = (pointer.desktop(), self.cross.main) { + return Some(match window_at(point, &self.cross.windows, Some(main), pointer.window) { + Over::Main(local) => Dest::Main(local), + Over::Window(id, local) => Dest::Window(id, local), + Over::Desktop => Dest::Desktop(Some(point)), + }); + } + let local = pointer.local?; + let Some(owner) = pointer.owner else { return Some(Dest::Main(local)) }; + let bounds = current.into_iter().chain(&self.cross.surfaces).find(|s| s.window == owner).map(|s| s.bounds); + Some(if bounds.is_some_and(|b| b.contains(local)) { Dest::Window(owner, local) } else { Dest::Desktop(None) }) + } + + /// Run `f` with a native window's targets and bounds in place of the main window's. + /// `quiet` leaves the undock hint to the drag's source window. + fn within(&mut self, surface: &mut Surface

, quiet: bool, f: impl FnOnce(&mut Self) -> R) -> R { + std::mem::swap(&mut self.targets, &mut surface.targets); + std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets); + std::mem::swap(&mut self.frame.windows, &mut surface.windows); + let bounds = std::mem::replace(&mut self.float_bounds, surface.bounds); + let blockers = std::mem::take(&mut self.blockers); + let was = std::mem::replace(&mut self.cross.quiet, quiet); + let result = f(self); + std::mem::swap(&mut self.targets, &mut surface.targets); + std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets); + std::mem::swap(&mut self.frame.windows, &mut surface.windows); + (self.float_bounds, self.blockers, self.cross.quiet) = (bounds, blockers, was); + result + } + + /// [`Self::within`] the native window `window` drawn this frame. + fn within_window(&mut self, window: u64, quiet: bool, f: impl FnOnce(&mut Self) -> R) -> Option { + let index = self.cross.surfaces.iter().position(|s| s.window == window)?; + let mut surface = self.cross.surfaces.swap_remove(index); + let result = self.within(&mut surface, quiet, f); + self.cross.surfaces.push(surface); + Some(result) + } + + /// The offset from the owner's coordinates to those of a window at `to`. + fn shift(&self, pointer: &DragPointer, to: Option) -> Vec2 { + match (pointer.origin, to) { + (Some(from), Some(to)) => from - to, + _ => Vec2::ZERO, + } + } + + fn origin_of(&self, window: u64) -> Option { + self.cross.surfaces.iter().find(|s| s.window == window).and_then(|s| s.origin) + } + + /// A tab drag at the end of the main window's frame. Over the main window it returns the + /// drag in the main window's coordinates and the input to finish it with; elsewhere it + /// docks in another window or floats a new one on release. + pub(super) fn cross_tab(&mut self, ctx: &Context, drag: &Drag

, input: DragInput) -> Option<(Drag

, DragInput)> { + self.cross.quiet = false; + let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else { + return Some((drag.clone(), input)); + }; + let input = DragInput { escape: input.escape || pointer.escape, ..self.input_of(&pointer, pointer.local) }; + if input.escape || !input.focused || (!input.down && !input.released) { + return Some((drag.clone(), input)); + } + let float_only = pointer.command || drag.undock; + let floats_out = float_only && pointer.released && pointer.desktop().is_some(); + match self.destination(&pointer, None)? { + Dest::Main(local) if !floats_out => { + let shift = self.shift(&pointer, self.cross.main.map(|m| m.min)); + self.cross.quiet = pointer.owner.is_some(); + let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; + return Some((drag, DragInput { pointer: Some(local), ..input })); + } + _ if !pointer.released => self.source_hint(ctx, &pointer), + Dest::Window(window, local) if !float_only => { + let shift = self.shift(&pointer, self.origin_of(window)); + let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; + let action = self.within_window(window, true, |r| { + let proposal = r.tab_proposal(&drag.panel, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?; + Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) + }); + self.push_checked(action.flatten(), &drag.panel); + } + _ if !self.permissions(&drag.panel).floatable => {} + _ => { + let size = self.float_size(&drag.panel, drag.source.size()); + let grab = drag.grab.min(vec2(size.x, self.style.tab_height)) + vec2(0.0, BAR_HEIGHT); + let rect = self.desktop_float(&pointer, size, grab); + let action = Action::Float { panel: drag.panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] }; + self.push_checked(Some(action), &drag.panel); + } + } + if pointer.released { + cancel_drag::

(ctx, self.id); + } + None + } + + /// A group or column drag at the end of the main window's frame, like [`Self::cross_tab`]. + pub(super) fn cross_group(&mut self, ui: &Ui, drag: &GroupDrag

, input: DragInput) -> Option<(GroupDrag

, DragInput)> { + self.cross.quiet = false; + let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else { + return Some((drag.clone(), input)); + }; + if drag.gesture == GroupGesture::Window { + return Some((drag.clone(), input)); + } + let input = self.input_of(&pointer, pointer.local); + if input.escape || !input.focused || (!input.down && !input.released) { + return Some((drag.clone(), input)); + } + let floats_out = pointer.command && pointer.released && pointer.desktop().is_some(); + let ctx = ui.ctx(); + match self.destination(&pointer, None)? { + Dest::Main(local) if !floats_out => { + let shift = self.shift(&pointer, self.cross.main.map(|m| m.min)); + self.cross.quiet = pointer.owner.is_some(); + let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; + return Some((drag, DragInput { pointer: Some(local), ..input })); + } + _ if !pointer.released => self.source_hint(ctx, &pointer), + Dest::Window(window, local) if !pointer.command => { + let shift = self.shift(&pointer, self.origin_of(window)); + let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; + let action = self.within_window(window, true, |r| { + let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?; + Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement })) + }); + self.push_checked(action.flatten(), &drag.panel); + } + _ => { + let size = self.group_float_size(&drag.members, drag.source.size()); + let grab = (drag.origin - drag.source.min).clamp(Vec2::ZERO, size) + vec2(0.0, BAR_HEIGHT); + let rect = self.desktop_float(&pointer, size, grab); + let action = group_action(drag, Target::Float { rect: Some([rect.left(), rect.top(), rect.width(), rect.height()]) }); + self.push_checked(Some(action), &drag.panel); + } + } + if pointer.released { + cancel_drag::

(ctx, self.id); + } + None + } + + /// A bar drag of a native window, at the end of the main window's frame: the preview + /// over the main window, and the release. + pub(in crate::docking) fn finish_window_drag(&mut self, ui: &Ui) { + self.cross.quiet = false; + if let Some((drag, pointer)) = self.cross.release.take() { + self.drop_native_window(drag.window, &pointer); + return; + } + let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return }; + let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return }; + if let Some(Dest::Main(local)) = self.destination(&pointer, None) { + self.cross.quiet = true; + self.paint_group_drag(ui, &drag, local, pointer.command); + self.cross.quiet = false; + } + } + + /// Release of a bar drag: over a target in another window the whole window docks or + /// joins there. Over the desktop, or with Ctrl/Cmd held, it stays where it was moved. + fn drop_native_window(&mut self, window: u64, pointer: &DragPointer) { + let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && self.style.title_bar_docks) else { return }; + match self.destination(pointer, None) { + Some(Dest::Main(local)) => self.drop_window(&drag, local, false), + Some(Dest::Window(target, local)) => { + let action = self.within_window(target, true, |r| { + let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop)?; + Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement })) + }); + if let Some(action) = action.flatten() { + self.output.actions.push(action); + } + } + _ => {} + } + } + + /// A native window's bar drag as a whole-window group drag. + fn window_group_drag(&self, window: u64) -> Option> { + let window: &Floating

= self.layout.floating.iter().find(|w| w.id == window)?; + let members: Vec

= window.panels().cloned().collect(); + let panel = members.first()?.clone(); + Some(GroupDrag { panel, original: window.rect, gesture: GroupGesture::Window, members, origin: Pos2::ZERO, source: Rect::NOTHING }) + } + + /// At the end of a native window's frame: the drop preview when the drag's pointer is + /// over this window, or the undock hint when this window is the drag's source and a target + /// elsewhere was offered last frame. + pub(super) fn paint_cross(&mut self, ui: &Ui, surface: &mut Surface

) { + let ctx = ui.ctx(); + let Some(pointer) = ctx.data(|d| d.get_temp::(self.id.with(POINTER))).filter(|p| !p.released && p.down) else { return }; + let source = pointer.owner == Some(surface.window); + match self.destination(&pointer, Some(&*surface)) { + Some(Dest::Window(window, local)) if window == surface.window => { + let shift = self.shift(&pointer, surface.origin); + self.within(surface, !source, |r| r.paint_drop(ui, &pointer, local, shift)); + } + _ if source && ctx.data(|d| d.get_temp::(self.id.with(OFFERED))).unwrap_or(false) => { + let Some(local) = pointer.local else { return }; + self.within(surface, false, |r| r.paint_float_hint(ctx, local)); + } + _ => {} + } + } + + /// The preview of the drag at `local`, in the window drawing now. `shift` takes the + /// owner's coordinates to this window's. + fn paint_drop(&mut self, ui: &Ui, pointer: &DragPointer, local: Pos2, shift: Vec2) { + let ctx = ui.ctx(); + if let Some(window) = pointer.window { + if let Some(drag) = self.window_group_drag(window) { + self.paint_group_drag(ui, &drag, local, pointer.command); + } + return; + } + if let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) { + if pointer.command || drag.undock { + return; + } + let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag }; + if let Some(proposal) = self.tab_proposal(&drag.panel, local) { + let painter = self.preview_painter(ctx); + self.paint_tab_proposal(ctx, &painter, &drag, &proposal, local); + } + } else if let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("group-drag"))).filter(|d| d.gesture != GroupGesture::Window) + { + let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag }; + self.paint_group_drag(ui, &drag, local, pointer.command); + } + } + + /// The undock hint in the main window, when it is the source and another window offered a + /// target this frame. + fn source_hint(&self, ctx: &Context, pointer: &DragPointer) { + if pointer.owner.is_none() + && self.cross.offered.get() + && let Some(local) = pointer.local + { + self.paint_float_hint(ctx, local); + } + } + + /// End of the main window's frame: remember whether a target was offered for the source + /// window's hint, and forget the pointer of a finished drag. + pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) { + let id = self.id; + let active = ctx.data(|d| { + d.get_temp::>(id.with("drag")).is_some() + || d.get_temp::>(id.with("group-drag")).is_some() + || d.get_temp::(id.with("window-drag")).is_some() + }); + ctx.data_mut(|d| { + d.insert_temp(id.with(OFFERED), self.cross.offered.get()); + if !active { + d.remove::(id.with(POINTER)); + } + }); + } + + /// The drag's input as its owner saw it, at `pointer` in the coordinates of the window + /// finishing it. Leaving a window does not end the drag. + fn input_of(&self, pointer: &DragPointer, at: Option) -> DragInput { + DragInput { + pointer: at, + down: pointer.down, + released: pointer.released, + focused: self.cross.focused, + gone: false, + escape: pointer.escape, + command: pointer.command, + touch_ended: false, + } + } + + /// The in-window rect of a window floated out at the desktop pointer with its top-left + /// `grab` from it. The new window opens at the main window's origin plus this rect. Where + /// positions are unknown the window system places it. + fn desktop_float(&self, pointer: &DragPointer, size: Vec2, grab: Vec2) -> Rect { + match (pointer.desktop(), self.cross.main) { + (Some(point), Some(main)) => Rect::from_min_size(point - grab - main.min.to_vec2(), size), + _ => clamp_float(Rect::from_min_size(self.float_bounds.min, size), self.float_bounds), + } + } + + /// Push `action` when the model accepts it, focusing `panel`. + fn push_checked(&mut self, action: Option>, panel: &P) { + let Some(action) = action else { return }; + if self.layout.clone().apply_with_permissions(action.clone(), |p| self.permissions(p)).is_ok() { + self.output.actions.push(action); + self.output.focused = Some(panel.clone()); + } + } +} diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index 8fdbd529..f0096df1 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -198,6 +198,12 @@ impl Renderer<'_, P> { if !self.style.float_hint || self.style.float_hint_label.is_empty() { return; } + // A window other than the drag's source: the source shows the hint. + #[cfg(feature = "native-windows")] + if self.cross.quiet { + self.cross.offered.set(true); + return; + } let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; let painter = self.preview_painter(ctx); let galley = painter.layout_no_wrap(self.style.float_hint_label.replace("{key}", key), self.style.font.clone(), self.style.text); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index f86abb64..023461bf 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -6,6 +6,8 @@ use crate::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis}; use super::{Action, DockContent, DockOutput, DockStyle, DropTarget, Layout, Node, PanelLimits, Permissions, dock_model::Place}; +#[cfg(feature = "native-windows")] +mod cross; mod dock; mod floating; mod frame; @@ -69,6 +71,9 @@ pub(super) struct Renderer<'a, P> { /// [`crate::docking::DockArea::monitors`]. #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] pub(super) monitors: Vec, + /// The other OS windows a drag may cross into. + #[cfg(feature = "native-windows")] + pub(super) cross: cross::Cross

, } pub(super) struct DrawnDivider { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 5dbc770c..eade8e53 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -2,16 +2,18 @@ //! viewports, every floating window whose panels allow it is drawn with //! `show_viewport_immediate`, always on top of other windows and hidden while none of the //! application's windows has focus. Where egui embeds viewports (wasm, tests) windows stay -//! in-window at their in-window rect. Tabs and groups do not drag between OS windows. +//! in-window at their in-window rect. Tabs, groups and whole windows drag between OS windows +//! (see `cross.rs`). use std::{fmt::Debug, hash::Hash}; use egui::{Context, Event, Id, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, WindowLevel, pos2, vec2}; use super::Renderer; +use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; use crate::docking::placement::{monitor_of, restore, sanitized}; -use crate::docking::{Action, Drag, Floating, GroupDrag}; +use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node}; /// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; @@ -31,6 +33,8 @@ pub(in crate::docking) struct WindowDrag { /// The window's outer position on the desktop: reported when the drag began, then as last /// commanded. The reported position lags and jitters while the window moves. pub origin: Pos2, + /// Where the window was when the drag began, for Escape. + pub start: Pos2, } /// The viewport of the native window `window` of the dock area `area`. The `n`th window with @@ -56,11 +60,18 @@ fn placed(outer: Option, inner: Option) -> Option<[f32; 4]> { } /// What a native window's frame reports back to the main window. -#[derive(Default)] -struct Report { +struct Report

{ closed: bool, placed: Option<[f32; 4]>, monitor_size: Option, + /// Its drop targets, for drags from other windows. + surface: Option>, +} + +impl

Default for Report

{ + fn default() -> Self { + Self { closed: false, placed: None, monitor_size: None, surface: None } + } } impl Renderer<'_, P> { @@ -69,9 +80,11 @@ impl Renderer<'_, P> { let native = !ctx.embed_viewports(); let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); let visibility = if native { self.float_visibility(ctx) } else { (true, false) }; + self.cross.on = native; let mut shown = Vec::new(); let mut in_window = Vec::new(); let mut ids = Vec::new(); + let mut natives = Vec::new(); for window in windows { let allowed = window.panels().all(|p| self.content.allow_native(p) && self.permissions(p).floatable); let Some(first) = window.panels().next().cloned().filter(|_| native && allowed) else { @@ -87,7 +100,21 @@ impl Renderer<'_, P> { }); let n = ids.iter().filter(|id| **id == window.id).count(); ids.push(window.id); - let viewport = viewport_id(self.id, window.id, n); + natives.push((window, rect, viewport_id(self.id, window.id, n))); + } + if native { + self.map_desktop(ctx, &natives, visibility.0); + // The main window owns drags of the panels it draws. + let id = self.id; + let dragged = ctx.data(|d| { + d.get_temp::>(id.with("drag")).map(|d| d.panel).or(d.get_temp::>(id.with("group-drag")).map(|d| d.panel)) + }); + if dragged.is_some_and(|panel| !natives.iter().any(|(w, ..)| w.contains(&panel))) { + let origin = self.cross.main.map(|m| m.min); + self.record_pointer(ctx, None, origin, None); + } + } + for (window, rect, viewport) in natives { if let Some(key) = self.native_window(ctx, window, rect, viewport, visibility) { shown.push(key); } @@ -104,6 +131,39 @@ impl Renderer<'_, P> { in_window } + /// The main window and the shown native windows on the desktop, front first: the window + /// focused most recently first. A window whose position is unknown is left out. + fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool) { + let key = self.id.with("native-order"); + let mut order = ctx.data(|d| d.get_temp::>(key)).unwrap_or_default(); + order.retain(|id| natives.iter().any(|(w, ..)| w.id == *id)); + let moving = ctx.data(|d| d.get_temp::(self.id.with("window-drag"))); + let mut windows = Vec::new(); + for (window, rect, viewport) in natives { + let info = ctx.input(|i| i.raw.viewports.get(viewport).cloned()); + // A new window opens in front. + if info.as_ref().is_some_and(|i| i.focused == Some(true)) || !order.contains(&window.id) { + order.retain(|id| *id != window.id); + order.insert(0, window.id); + } + let [x, y, w, h] = *rect; + let placed = match info { + None => Some(Rect::from_min_size(pos2(x, y), vec2(w, h))), + Some(info) => info.outer_rect.zip(info.inner_rect).map(|(outer, inner)| { + let min = moving.filter(|m| m.window == window.id).map_or(outer.min, |m| m.origin); + Rect::from_min_size(min, inner.size()) + }), + }; + if let Some(placed) = placed.filter(|r| r.is_finite() && visible) { + windows.push((window.id, placed)); + } + } + windows.sort_by_key(|(id, _)| order.iter().position(|o| o == id)); + ctx.data_mut(|d| d.insert_temp(key, order)); + self.cross.windows = windows; + self.cross.main = ctx.input(|i| i.viewport().outer_rect.and(i.viewport().inner_rect)).filter(|r| r.is_finite()); + } + /// Whether floating windows show this frame, and whether that changed since the last. fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { let (focused, now) = ctx.input(|i| { @@ -112,6 +172,7 @@ impl Renderer<'_, P> { let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); (focused, i.time) }); + self.cross.focused = focused; let key = self.id.with("native-visibility"); let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); let last = if focused { now } else { last }; @@ -154,7 +215,7 @@ impl Renderer<'_, P> { if visible && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } - let (targets, windows) = (self.targets.len(), self.frame.windows.len()); + let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, self.style.floating_background); @@ -170,24 +231,29 @@ impl Renderer<'_, P> { if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { return Report::default(); } + let mut surface = Surface { + window: window.id, + origin: None, + bounds: full, + targets: self.targets.split_off(targets), + dock_targets: self.dock_targets.split_off(docks), + windows: self.frame.windows.split_off(windows), + }; + surface.origin = self.window_gestures(ui, window); + self.paint_cross(ui, &mut surface); self.forward_events(ui); ui.input(|i| Report { closed: i.viewport().close_requested(), placed: placed(i.viewport().outer_rect, i.viewport().inner_rect), monitor_size: i.viewport().monitor_size, + surface: Some(surface), }) }); - // Targets in another OS window are in its coordinates, and no drag crosses windows. + // Targets in another OS window are in its coordinates. self.targets.truncate(targets); + self.dock_targets.truncate(docks); self.frame.windows.truncate(windows); - ctx.data_mut(|d| { - if d.get_temp::>(self.id.with("drag")).is_some_and(|drag| window.contains(&drag.panel)) { - d.remove::>(self.id.with("drag")); - } - if d.get_temp::>(self.id.with("group-drag")).is_some_and(|drag| window.contains(&drag.panel)) { - d.remove::>(self.id.with("group-drag")); - } - }); + self.cross.surfaces.extend(report.surface); if report.closed { // Like the bar's ×: its panels close and reopen from the application's menu. self.output.actions.push(Action::CloseWindow { panel: first }); @@ -233,46 +299,87 @@ impl Renderer<'_, P> { { self.output.actions.push(Action::CloseWindow { panel: first }); } - if movable { - self.drag_window(ui, &response.bar, window.id); + if movable && response.bar.drag_started() { + self.start_window_drag(ui, window.id); } } - /// Move the window with its bar: by `OuterPosition` from the position last commanded, or, - /// where the window system reports no position (Wayland), by handing the move to it. - fn drag_window(&mut self, ui: &Ui, bar: &egui::Response, window: u64) { + /// After a native window's tree drew: a drag of its only tab, or of its only group, moves + /// the window as its bar does. Then the window follows a bar drag, and writes the pointer + /// of a drag it owns. Returns the window's desktop origin, where known. + fn window_gestures(&mut self, ui: &Ui, window: &Floating

) -> Option { + let ctx = ui.ctx(); + let (drag_key, group_key) = (self.id.with("drag"), self.id.with("group-drag")); + let tab = ctx.data(|d| d.get_temp::>(drag_key)).filter(|d| window.contains(&d.panel)); + let group = ctx.data(|d| d.get_temp::>(group_key)).filter(|d| window.contains(&d.panel)); + let lone = window.panels().count() == 1 && tab.is_some(); + let whole = matches!(window.root, Node::Tabs { .. }) && group.as_ref().is_some_and(|g| g.gesture == GroupGesture::Group); + if (lone || whole) && window.panels().all(|p| self.permissions(p).movable) { + ctx.data_mut(|d| { + d.remove::>(drag_key); + d.remove::>(group_key); + }); + self.start_window_drag(ui, window.id); + } + let outer = ui.input(|i| i.viewport().outer_rect).map(|r| r.min).filter(|p| p.is_finite()); + if let Some(origin) = self.follow_window_drag(ui, window.id) { + return Some(origin); + } + let owns = ctx.data(|d| d.get_temp::>(drag_key).map(|d| d.panel).or(d.get_temp::>(group_key).map(|d| d.panel))); + if owns.is_some_and(|panel| window.contains(&panel)) { + self.record_pointer(ctx, Some(window.id), outer, None); + } + outer + } + + /// Start moving the window with the pointer, from the position the window system reports, + /// or, where it reports none (Wayland), by handing the move to it. + fn start_window_drag(&mut self, ui: &Ui, window: u64) { + let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); + match (ui.input(|i| i.viewport().outer_rect), press) { + (Some(outer), Some(press)) if outer.min.is_finite() => { + let drag = WindowDrag { window, grab: press.to_vec2(), origin: outer.min, start: outer.min }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-drag"), drag)); + } + _ => ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag), + } + } + + /// Move the window by `OuterPosition` from the position last commanded, while the button + /// is held. Escape puts it back; losing focus leaves it. Returns the desktop origin of + /// this frame's input while the window is moving. + fn follow_window_drag(&mut self, ui: &Ui, window: u64) -> Option { let key = self.id.with("window-drag"); - let pointer = ui.input(|i| i.pointer.latest_pos()); - if bar.drag_started() { - let press = ui.input(|i| i.pointer.press_origin()).or(pointer); - match (ui.input(|i| i.viewport().outer_rect), press) { - (Some(outer), Some(press)) if outer.min.is_finite() => { - let drag = WindowDrag { window, grab: press.to_vec2(), origin: outer.min }; - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); - } - _ => ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag), + let mut drag = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window)?; + let origin = drag.origin; + let pointer = self.record_pointer(ui.ctx(), Some(window), Some(origin), Some(window)); + if pointer.escape || !self.cross.focused { + if pointer.escape { + ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.start)); } - } - let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return }; - if bar.dragged() - && let Some(pointer) = pointer - { - // The pointer on the desktop is the commanded origin plus the local pointer. - let step = pointer.to_vec2() - drag.grab; - if step != Vec2::ZERO { - drag.origin += step; - ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.origin)); - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); - } - } - if bar.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { ui.ctx().data_mut(|d| d.remove::(key)); - self.end_window_drag(drag); + return Some(origin); } + if !pointer.down { + ui.ctx().data_mut(|d| d.remove::(key)); + self.end_window_drag(drag, pointer); + return Some(origin); + } + // The pointer on the desktop is the commanded origin plus the local pointer. + let step = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()).map_or(Vec2::ZERO, |p| p.to_vec2() - drag.grab); + if step != Vec2::ZERO { + drag.origin += step; + ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.origin)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } + Some(origin) } - /// Release of a bar drag. The window already sits where it was dragged. - fn end_window_drag(&mut self, _drag: WindowDrag) {} + /// Release of a bar drag. The window already sits where it was dragged; over a target in + /// another window it docks there once every window has drawn (`cross.rs`). + fn end_window_drag(&mut self, drag: WindowDrag, pointer: DragPointer) { + self.cross.release = Some((drag, pointer)); + } /// Keys, text and clipboard events the window's widgets left over, for the application's /// shortcuts. Nothing while a text field of the window has focus. diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 96fc1120..22c759e1 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -20,6 +20,39 @@ const DOCKED_FLOAT_HEIGHT: f32 = 320.0; /// Least height of a floating window or flyout fitted to a short panel's content. pub(super) const MIN_FITTED_HEIGHT: f32 = 120.0; +/// What finishing a drag reads of the pointer, in the coordinates of the window finishing it. +#[derive(Clone, Copy, Debug)] +pub(super) struct DragInput { + pub pointer: Option, + pub down: bool, + pub released: bool, + pub focused: bool, + /// The pointer left the window. + pub gone: bool, + pub escape: bool, + /// Ctrl or Cmd held. + pub command: bool, + /// A touch ended this frame. + pub touch_ended: bool, +} + +impl DragInput { + /// This window's own input. + fn of(ctx: &Context) -> Self { + let command = float_only(ctx); + ctx.input(|i| Self { + pointer: i.pointer.interact_pos().filter(|p| p.is_finite()), + down: i.pointer.primary_down(), + released: i.pointer.primary_released(), + focused: i.focused, + gone: !i.pointer.has_pointer(), + escape: i.key_pressed(Key::Escape), + command, + touch_ended: i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. })), + }) + } +} + impl Renderer<'_, P> { pub(in crate::docking) fn finish_group_drag(&mut self, ui: &Ui) { let ctx = ui.ctx(); @@ -29,9 +62,10 @@ impl Renderer<'_, P> { ctx.data_mut(|d| d.remove::>(key)); return; } - let (escape, focused, down, released, pointer) = - ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos())); - let pointer = pointer.filter(|p| p.is_finite()); + let input = DragInput::of(ctx); + #[cfg(feature = "native-windows")] + let Some((drag, input)) = self.cross_group(ui, &drag, input) else { return }; + let DragInput { escape, focused, down, released, pointer, .. } = input; let ghost = pointer.map(|pointer| self.group_ghost(&drag, pointer)); if !escape && focused @@ -46,10 +80,10 @@ impl Renderer<'_, P> { return; } // A button release outside the window may never reach egui. - let has_pointer = ctx.input(|i| i.pointer.has_pointer()); + let has_pointer = !input.gone; // Touch-up also removes the pointer, but still completes a drop. - let touch_ended = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::End, .. }))); - let float_only = float_only(ctx); + let touch_ended = input.touch_ended; + let float_only = input.command; if escape || !focused || !down || !has_pointer { cancel_drag::

(ctx, self.id); if escape || !focused { @@ -185,7 +219,7 @@ impl Renderer<'_, P> { /// The ghost at the pointer (a window moving live is its own ghost) under one destination /// preview. The ghost has a translucent fill, a 2 pt outline and the members' labels. - fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { + pub(super) fn paint_group_drag(&mut self, ui: &Ui, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { let ctx = ui.ctx(); let painter = self.preview_painter(ctx); if drag.gesture != GroupGesture::Window { @@ -232,13 +266,16 @@ impl Renderer<'_, P> { pub(in crate::docking) fn finish_drag(&mut self, ctx: &Context) { let Some(drag) = ctx.data(|d| d.get_temp::>(self.id.with("drag"))) else { return }; - let (pointer, down, released, focused, gone) = - ctx.input(|i| (i.pointer.interact_pos(), i.pointer.primary_down(), i.pointer.primary_released(), i.focused, !i.pointer.has_pointer())); + let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); + let input = DragInput { escape, ..DragInput::of(ctx) }; + #[cfg(feature = "native-windows")] + let Some((drag, input)) = self.cross_tab(ctx, &drag, input) else { return }; + let DragInput { pointer, down, released, focused, gone, escape, .. } = input; // Leaving the window is the drop: on X11 and Windows the release outside never reaches // egui, so the drag would otherwise stay on the pointer. `interact_pos` still holds the // last position this frame, and the panel floats there rather than docking at the edge. + // With native viewports the release does arrive, and `gone` is false. let released = released || gone; - let escape = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)); if escape || !focused || !self.layout.contains(&drag.panel) || (!down && !released) { cancel_drag::

(ctx, self.id); return; @@ -255,7 +292,7 @@ impl Renderer<'_, P> { } return; } - let float_only = float_only(ctx) || drag.undock; + let float_only = input.command || drag.undock; let outside = gone || !ctx.content_rect().contains(pointer); let empty_drop = self.style.float_on_empty_drop && (!drag.source.contains(pointer) || self.layout.floating.iter().any(|g| g.contains(&drag.panel))); @@ -281,17 +318,7 @@ impl Renderer<'_, P> { } return; } - let proposal = if float_only { - None - } else { - self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| { - let panel = &drag.panel; - self.resolve_roomy(panel, pointer, &self.targets, |to| match to { - Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to => Action::MovePanel { panel: panel.clone(), to }, - }) - }) - }; + let proposal = if float_only { None } else { self.tab_proposal(&drag.panel, pointer) }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !outside) { @@ -317,23 +344,38 @@ impl Renderer<'_, P> { } let painter = self.preview_painter(ctx); if let Some(proposal) = proposal { - // Reordering inside the source strip shows only the insertion line. - let same_group = proposal.insertion.is_some() - && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { - drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, true); - } - if let Some(marker) = proposal.insertion { - painter.rect_filled(marker, 0.0, self.style.accent); - } - if !proposal.noop { - self.paint_float_hint(ctx, pointer); - } + self.paint_tab_proposal(ctx, &painter, &drag, &proposal, pointer); } else if let Some(rect) = floating_rect { drop_preview(&painter, rect, self.style.accent, false); } } + /// The group target a single tab drag would take at `pointer`. + pub(super) fn tab_proposal(&self, panel: &P, pointer: Pos2) -> Option> { + self.extra_proposal(std::slice::from_ref(panel), pointer, false).or_else(|| { + self.resolve_roomy(panel, pointer, &self.targets, |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }) + }) + } + + /// The preview of a tab drag's group target, and the undock hint. + pub(super) fn paint_tab_proposal(&self, ctx: &Context, painter: &egui::Painter, drag: &Drag

, proposal: &DropProposal

, pointer: Pos2) { + // Reordering inside the source strip shows only the insertion line. + let same_group = proposal.insertion.is_some() + && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !self.paint_zone(painter, proposal, pointer) { + drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, true); + } + if let Some(marker) = proposal.insertion { + painter.rect_filled(marker, 0.0, self.style.accent); + } + if !proposal.noop { + self.paint_float_hint(ctx, pointer); + } + } + /// With [`crate::docking::DockStyle::side_edge_zones`], a drop on a group shows the zone /// under the pointer (a side's trapezoid, or the center rectangle with the tab strip) /// filled, and the rect the drop gives what moves outlined. False when it painted nothing. diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 40749353..5855062b 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -851,6 +851,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { dividers: Vec::new(), tree: None, monitors: self.monitors, + #[cfg(feature = "native-windows")] + cross: Default::default(), }; if let Some(root) = &view.root { renderer.node(ui, root, rect, &mut Vec::new()); @@ -858,8 +860,12 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { } renderer.flyout(ui.ctx()); renderer.floating(ui.ctx(), &view.floating); + #[cfg(feature = "native-windows")] + renderer.finish_window_drag(ui); renderer.finish_group_drag(ui); renderer.finish_drag(ui.ctx()); + #[cfg(feature = "native-windows")] + renderer.end_cross(ui.ctx()); renderer.report_flyout(ui.ctx()); super::hits::record(ui.ctx(), self.id, layout, &renderer.owned_interactions); record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index f0444f49..a6c025ab 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -193,6 +193,30 @@ impl> Do self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, false)]) } + /// In a native window, press at `from`, move past the drag threshold and on to `to`, in + /// its coordinates at each frame, without releasing. The window system keeps sending the + /// pointer to the window where it was pressed, inside it or not. + #[cfg(feature = "native-windows")] + pub fn window_hold(&mut self, window: u64, from: Pos2, to: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(from), self.button(from, true)]); + self.window_step(window, vec![Event::PointerMoved(from + NUDGE)]); + self.window_step(window, vec![Event::PointerMoved(to)]) + } + + /// Release at `pos` in a native window, in its coordinates. + #[cfg(feature = "native-windows")] + pub fn window_release(&mut self, window: u64, pos: Pos2) -> DockOutput

{ + self.window_step(window, vec![Event::PointerMoved(pos), self.button(pos, false)]) + } + + /// The desktop point `point` in the coordinates of the native window of `window`, as the + /// host placed it. + #[cfg(feature = "native-windows")] + pub fn window_local(&self, window: u64, point: Pos2) -> Option { + let viewport = self.viewport(window); + self.host().windows.get(&viewport).map(|w| point - w.rect.min.to_vec2()) + } + /// Render one frame and apply its actions, all or none, with the content's permissions. A /// rejected batch is reported in the output's `error`. pub fn step(&mut self, events: Vec) -> DockOutput

{ From 619c93ea29e31570ba34a4600421106ebf94a266 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:24:25 +0000 Subject: [PATCH 098/160] docking: document drags between OS windows --- docs/ui-docking.md | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/docs/ui-docking.md b/docs/ui-docking.md index e4e1cf11..8c2cd93a 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -351,8 +351,34 @@ viewport's `inner_rect.min` plus its rect). A window with a panel whose events that reach a native window and that none of its widgets used come back in `DockOutput::forwarded_events`; the app feeds them to the shortcut handling it runs on the main window. A window whose text field has focus forwards nothing. -- **Drags.** Tabs and groups do not drag between OS windows yet: a drag that starts in a native - window is cancelled, and targets in another window are ignored. +- **Drags.** Tabs, groups and whole windows drag between the app's OS windows. + - *Ownership.* The window where the button went down owns the drag: the window system sends it + the pointer's moves and the release while the button is held, inside the window or not. So + the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release, + Escape, or when none of the app's windows has focus. + - *Hit-testing.* The owner adds its desktop origin to the pointer (a native window's origin is + the one last commanded while it moves, else the reported `outer_rect`). The window under that + desktop point is a floating native window first, most recently focused first, then the main + window. A window moving by its bar is never its own target. A point in no window is on the + desktop. + - *Targets and previews.* Each window draws its drop targets in its own coordinates, and the + window under the pointer draws the drop preview (zone, dock line, ghost) in its own frame. + The source window shows only the "Hold {key} to undock" hint. The release is resolved at the + end of the main window's frame, after every native window has drawn. + - *Drops.* Over a window, a tab or group docks or joins there with the usual targets: dock lines + and frame zones in the main window, frame zones and tab rows in floating windows. Over the + desktop, it floats into a new OS window placed so the grabbed tab stays under the pointer + (the window's top-left is the pointer minus the grab offset, less the bar's height). A + Ctrl/Cmd-drag floats there wherever it is released. + - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only + group moves the window. Released over a target in another window, the whole window docks or + joins there (`Action::MoveWindow`, as for the in-window title bar with + `DockStyle::title_bar_docks`); over the desktop, or with Ctrl/Cmd held, it stays where it was + moved. Escape puts it back. + - *Wayland.* Where `outer_rect` is `None`, desktop positions are unknown, so drops across + windows cannot be resolved. A drag works inside its own window, and a release outside it + floats a new window that the compositor places. Moving a window hands the move to the + compositor, which never docks it. On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the page at their in-window rect, `native` and the monitor fields are kept but unused, and From b46b9dd5c1ed165e8d88db0324907eb54d18373f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:31:11 +0000 Subject: [PATCH 099/160] docking: a native window's bar docks it at the main window's frame edges The window docks as its groups at a frame's edge zones, as a group drag would; a body's center still takes no whole window. Tests for Escape during a bar drag and for the window focused last being in front. --- crates/ui/src/docking/native_tests.rs | 89 +++++++++++++++++++++++++ crates/ui/src/docking/render/cross.rs | 13 +++- crates/ui/src/docking/render/preview.rs | 14 +++- 3 files changed, 111 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 4accdac0..85e32489 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -550,3 +550,92 @@ fn without_window_positions_a_drag_out_of_a_native_window_floats() { let out = d.window_release(id, pos2(120.0, 200.0)); assert!(!out.actions.iter().any(|a| matches!(a, Action::Float { .. } | Action::Move { .. })), "{:?}", out.actions); } + +/// Drag window `panel`'s bar until the pointer is at `to` in the main window, and release. +fn drag_bar_to(d: &mut DockDriver, panel: u32, to: Pos2) -> super::DockOutput { + let id = window(d, panel).id; + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let Some(local) = d.window_local(id, ORIGIN + to.to_vec2()) else { return Default::default() }; + d.window_step(id, vec![Event::PointerMoved(local)]); + d.window_step(id, vec![button(pos2(40.0, 9.0), false)]) +} + +fn window_move(out: &super::DockOutput) -> Option<&Target> { + out.actions.iter().find_map(|a| match a { + Action::MoveWindow { to, .. } => Some(to), + _ => None, + }) +} + +#[test] +fn a_native_windows_bar_docks_it_at_frame_edges_and_dock_lines_but_not_a_body_center() { + // The right edge zone of frame [3, 4], clear of the new-dock band along the window's edge. + let mut d = driver(layout(true)); + let out = drag_bar_to(&mut d, 5, pos2(740.0, 300.0)); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Right) })), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); + + // The middle of the frame is no target for a whole window. + let mut d = driver(layout(true)); + let out = drag_bar_to(&mut d, 5, pos2(600.0, 300.0)); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(window(&d, 5).contains(&6)); + + // The seam between the dock's two groups, which meet at y = 307. + let dock = Node::Dock { + side: super::Side::Start, + columns: vec![super::Column::new(vec![super::Group::new(vec![2], 0), super::Group::new(vec![3], 0)], 240.0)], + }; + let mut docked = layout(true); + docked.root = + Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(dock), second: Box::new(tabs(&[1])) }); + let mut d = driver(docked); + let out = drag_bar_to(&mut d, 5, pos2(120.0, 309.0)); + assert_eq!(window_move(&out), Some(&Target::At { anchor: 2, placement: Placement::Group { below: true, height: None } }), "{:?}", out.actions); + assert!(d.layout.floating.is_empty()); + assert!(in_root(&d, 5) && in_root(&d, 6)); +} + +#[test] +fn escape_during_a_bar_drag_puts_the_window_back() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(140.0, 59.0))]); + assert_ne!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0), "moved"); + let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + d.window_step(id, vec![escape]); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0)); + // The release that follows docks nothing, wherever it is. + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + d.step(vec![]); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([400.0, 130.0])); +} + +#[test] +fn the_window_focused_last_is_in_front_where_windows_overlap() { + // Window [7] overlaps window [5, 6] on the desktop, at (550, 280) among others. + let mut layout = layout(true); + layout.floating.push(Floating::single(vec![7], 0, [350.0, 150.0, 240.0, 150.0])); + let overlap = pos2(550.0, 280.0) - ORIGIN.to_vec2(); + for (front, anchors) in [(7, vec![7]), (5, vec![5, 6]), (7, vec![7])] { + let mut d = driver(layout.clone()); + let viewport = d.viewport(window(&d, front).id); + d.host().focus = Some(viewport); + d.step(vec![]); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, overlap); + let anchor = out.actions.iter().find_map(|a| match a { + Action::Move { panel: 1, anchor, .. } => Some(*anchor), + _ => None, + }); + assert!(anchor.is_some_and(|a| anchors.contains(&a)), "front {front}: {:?}", out.actions); + } +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 96b7261e..28478727 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -89,6 +89,8 @@ pub(in crate::docking) struct Cross

{ pub release: Option<(WindowDrag, DragPointer)>, /// Drawing for a window other than the drag's source: the undock hint is the source's. pub quiet: bool, + /// Resolving a native window's bar drag over the main window: frame edge zones count. + pub bar: bool, /// A target was offered while `quiet`. pub offered: Cell, } @@ -104,6 +106,7 @@ impl

Default for Cross

{ pointer: None, release: None, quiet: false, + bar: false, offered: Cell::new(false), } } @@ -287,9 +290,9 @@ impl Renderer<'_, P> { let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return }; let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return }; if let Some(Dest::Main(local)) = self.destination(&pointer, None) { - self.cross.quiet = true; + (self.cross.quiet, self.cross.bar) = (true, true); self.paint_group_drag(ui, &drag, local, pointer.command); - self.cross.quiet = false; + (self.cross.quiet, self.cross.bar) = (false, false); } } @@ -298,7 +301,11 @@ impl Renderer<'_, P> { fn drop_native_window(&mut self, window: u64, pointer: &DragPointer) { let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && self.style.title_bar_docks) else { return }; match self.destination(pointer, None) { - Some(Dest::Main(local)) => self.drop_window(&drag, local, false), + Some(Dest::Main(local)) => { + self.cross.bar = true; + self.drop_window(&drag, local, false); + self.cross.bar = false; + } Some(Dest::Window(target, local)) => { let action = self.within_window(target, true, |r| { let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop)?; diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 22c759e1..33b04a20 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -136,9 +136,19 @@ impl Renderer<'_, P> { }); } let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?; - // A window moving live splits only groups of other floating windows. + // A window moving live splits only groups of other floating windows. A native + // window's bar also takes the main window's frame edges, but not a body's center. let into_window = matches!(self.place(&proposal.anchor), Some(Place::Floating { .. })); - if window && !into_window && matches!(proposal.placement, Placement::Split(_)) { + #[cfg(feature = "native-windows")] + let edges = self.cross.bar; + #[cfg(not(feature = "native-windows"))] + let edges = false; + let split = match proposal.placement { + Placement::Split(Zone::Center) => true, + Placement::Split(_) => !edges, + _ => false, + }; + if window && !into_window && split { return None; } valid(proposal) From 783793cbbc596aebf0cb440024e92331453dfd9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:31:11 +0000 Subject: [PATCH 100/160] docking: document where a native window's bar docks it --- docs/ui-docking.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 8c2cd93a..5e938740 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -372,9 +372,11 @@ viewport's `inner_rect.min` plus its rect). A window with a panel whose Ctrl/Cmd-drag floats there wherever it is released. - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only group moves the window. Released over a target in another window, the whole window docks or - joins there (`Action::MoveWindow`, as for the in-window title bar with - `DockStyle::title_bar_docks`); over the desktop, or with Ctrl/Cmd held, it stays where it was - moved. Escape puts it back. + joins there as its groups (`Action::MoveWindow`, with `DockStyle::title_bar_docks`): in the + main window a dock line, a frame's tab row or edge zone, or the layout's outer edge; in a + floating window a frame zone or tab row. A frame body's center takes no whole window. Over + the desktop, or with Ctrl/Cmd held, it stays where it was moved. Escape puts it back. An + in-window floating window's title bar still splits only groups of other floating windows. - *Wayland.* Where `outer_rect` is `None`, desktop positions are unknown, so drops across windows cannot be resolved. A drag works inside its own window, and a release outside it floats a new window that the compositor places. Moving a window hands the move to the From 137845d6f4c137169777ec95e79649bf5806e947 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:42:37 +0000 Subject: [PATCH 101/160] docking: clamping a native window onto a fractional monitor origin cannot panic --- crates/ui/src/docking/placement.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/docking/placement.rs b/crates/ui/src/docking/placement.rs index b0aed6eb..f5be8561 100644 --- a/crates/ui/src/docking/placement.rs +++ b/crates/ui/src/docking/placement.rs @@ -47,7 +47,8 @@ fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] { let [x, y, w, h] = rect; let [ax, ay, aw, ah] = area; let (w, h) = (w.min(aw), h.min(ah)); - sanitized([x.clamp(ax, ax + aw - w), y.clamp(ay, ay + ah - h), w, h]) + // `ax + aw - w` can round below `ax` when `w == aw`, so no `clamp`, which panics then. + sanitized([x.min(ax + aw - w).max(ax), y.min(ay + ah - h).max(ay), w, h]) } /// Where a native window from a saved layout opens. @@ -177,6 +178,16 @@ mod tests { assert_eq!(restore([f32::NAN, 1e9, 10.0, 400.0], None, &[], None, None), [0.0, 100_000.0, 160.0, 400.0]); } + #[test] + fn clamping_onto_a_fractional_origin_does_not_panic() { + let area = [910.6667, 0.333_33, 1280.0, 720.0]; + let [x, y, w, h] = clamp_onto([0.0, 2000.0, 1280.0, 900.0], area); + assert_eq!([w, h], [1280.0, 720.0]); + assert!((x - area[0]).abs() < 0.01 && (y - area[1]).abs() < 0.01, "{x} {y}"); + let [x, ..] = clamp_onto([5000.0, 0.0, 2000.0, 100.0], area); + assert!((x - area[0]).abs() < 0.01, "{x}"); + } + #[test] fn a_window_without_a_saved_monitor_stays_on_screen() { let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)]; From e19b1b684dd110bd6cec03b1b3a868e672e66d56 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:48:12 +0000 Subject: [PATCH 102/160] docking: a restored native window opens where its bar can be grabbed --- crates/ui/src/docking/placement.rs | 60 +++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/placement.rs b/crates/ui/src/docking/placement.rs index f5be8561..82856c8d 100644 --- a/crates/ui/src/docking/placement.rs +++ b/crates/ui/src/docking/placement.rs @@ -9,6 +9,11 @@ use super::Monitor; const MAX_SIZE: f32 = 16_384.0; const MAX_POSITION: f32 = 100_000.0; const MIN_SIZE: [f32; 2] = [160.0, 100.0]; +/// Height of the bar at the top of a native window. +pub(super) const BAR_HEIGHT: f32 = 18.0; +/// How much of a window's bar must lie on one monitor for the window to be reachable: wide +/// enough to grab, and half its height. +const REACHABLE: [f32; 2] = [40.0, BAR_HEIGHT / 2.0]; /// A rect safe to hand to the windowing system: finite, its size and position bounded. /// Hostile workspace files and bad monitor reports included. @@ -42,6 +47,26 @@ pub(super) fn monitor_of(monitors: &[Monitor], rect: [f32; 4]) -> Option<&Monito .or_else(|| monitors().min_by(|a, b| to_rect(a.rect).distance_sq_to_pos(center).total_cmp(&to_rect(b.rect).distance_sq_to_pos(center)))) } +/// Whether enough of the bar along the top of `rect` lies on one of `monitors` to grab it. +fn reachable(rect: [f32; 4], monitors: &[Monitor]) -> bool { + let [x, y, w, _] = rect; + let bar = to_rect([x, y, w, BAR_HEIGHT]); + monitors.iter().filter(|m| usable(m)).any(|m| { + let on = bar.intersect(to_rect(m.rect)); + on.width() >= REACHABLE[0].min(w) && on.height() >= REACHABLE[1] + }) +} + +/// `rect` made safe, and moved wholly onto the nearest of `monitors` when too little of its +/// bar lies on any to grab it. Without a monitor list, only made safe. +pub(super) fn kept_reachable(rect: [f32; 4], monitors: &[Monitor]) -> [f32; 4] { + let rect = sanitized(rect); + if !monitors.iter().any(usable) || reachable(rect, monitors) { + return rect; + } + monitor_of(monitors, rect).map_or(rect, |m| clamp_onto(rect, m.rect)) +} + /// `rect` moved, and shrunk if it must be, to lie wholly on `area`. fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] { let [x, y, w, h] = rect; @@ -54,7 +79,8 @@ fn clamp_onto(rect: [f32; 4], area: [f32; 4]) -> [f32; 4] { /// Where a native window from a saved layout opens. /// /// - Its saved monitor (`monitor`), found in `monitors` by name or else by rect, is connected: -/// the saved rect, moved in proportion if that monitor's rect changed. +/// the saved rect, moved in proportion if that monitor's rect changed, and onto it if too +/// little of its bar lies on a monitor to grab it. /// - It is not: the main window's monitor, at the same offset from its origin. /// - No monitor list: the main window's monitor as egui reports it, by its size alone. Its /// origin is taken as the multiple of that size nearest below the main window's center, @@ -77,7 +103,13 @@ pub(super) fn restore(saved: [f32; 4], monitor: Option<&Monitor>, monitors: &[Mo }); let [x, y, w, h] = saved; match (connected, monitor) { - (Some((old, now)), _) if old.rect == now.rect => saved, + (Some((old, now)), _) if old.rect == now.rect => { + if reachable(saved, monitors) { + saved + } else { + clamp_onto(saved, now.rect) + } + } (Some((old, now)), _) => { let [ox, oy, ow, oh] = old.rect; let [nx, ny, nw, nh] = now.rect; @@ -91,10 +123,9 @@ pub(super) fn restore(saved: [f32; 4], monitor: Option<&Monitor>, monitors: &[Mo let (dx, dy) = if usable(old) { (x - ox, y - oy) } else { (0.0, 0.0) }; clamp_onto([tx + dx, ty + dy, w, h], target.rect) } - // No saved monitor: the window stays where its center is on a monitor. + // No saved monitor: the window stays where its bar can be grabbed. (None, None) => { - let center = to_rect(saved).center(); - if monitors.iter().any(|m| usable(m) && to_rect(m.rect).contains(center)) { + if reachable(saved, monitors) { return saved; } main_monitor.map_or(saved, |target| clamp_onto(saved, target.rect)) @@ -129,6 +160,25 @@ mod tests { // Partly off its monitor, as the user left it. let edge = [4300.0, 300.0, 400.0, 500.0]; assert_eq!(restore(edge, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), edge); + // Wholly off it, or with only its body on it: back onto it. + let off = [5000.0, 300.0, 400.0, 500.0]; + assert_eq!(restore(off, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [4080.0, 300.0, 400.0, 500.0]); + let above = [2400.0, -10.0, 400.0, 500.0]; + assert_eq!(restore(above, Some(&monitors[1]), &monitors, main_at(100.0, 100.0), None), [2400.0, 0.0, 400.0, 500.0]); + } + + #[test] + fn a_window_is_kept_where_its_bar_can_be_grabbed() { + let monitors = [monitor("left", LEFT, 1.0), monitor("right", RIGHT, 2.0)]; + // Across the seam, or mostly off screen with enough of its bar on one. + for rect in [[1800.0, 100.0, 400.0, 300.0], [-360.0, 1071.0, 400.0, 300.0]] { + assert_eq!(kept_reachable(rect, &monitors), rect); + } + assert_eq!(kept_reachable([-370.0, 100.0, 400.0, 300.0], &monitors), [0.0, 100.0, 400.0, 300.0]); + assert_eq!(kept_reachable([500.0, 1075.0, 400.0, 300.0], &monitors), [500.0, 780.0, 400.0, 300.0]); + assert_eq!(kept_reachable([9000.0, 100.0, 400.0, 300.0], &monitors), [4080.0, 100.0, 400.0, 300.0]); + // No monitor list: only made safe. + assert_eq!(kept_reachable([9000.0, f32::NAN, 400.0, 300.0], &[]), [9000.0, 0.0, 400.0, 300.0]); } #[test] From 0ac8f80f649f12a2cd6cef5085e60fe9f6b3bd37 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:55:03 +0000 Subject: [PATCH 103/160] docking: a loaded layout's monitor and monitor size are validated --- crates/ui/src/docking/model.rs | 14 ++++++++++---- crates/ui/src/docking/model_tests.rs | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 145bc888..a5c5c97f 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -540,6 +540,11 @@ pub(super) fn valid_rect(rect: [f32; 4]) -> bool { rect.iter().all(|v| v.is_finite() && v.abs() <= MAX_EXTENT) && rect.get(2).is_some_and(|v| *v > 0.0) && rect.get(3).is_some_and(|v| *v > 0.0) } +/// A floating window's saved monitor and monitor size, each where given. +fn valid_monitor(monitor: Option<&Monitor>, monitor_size: Option<[f32; 2]>) -> bool { + monitor.is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])) +} + pub(super) fn valid_width(width: f32) -> bool { width.is_finite() && width > 0.0 && width <= MAX_EXTENT } @@ -615,7 +620,10 @@ impl Layout

{ } } for floating in &self.floating { - if !valid_rect(floating.rect) || floating.native.is_some_and(|r| !valid_rect(r)) { + if !valid_rect(floating.rect) + || floating.native.is_some_and(|r| !valid_rect(r)) + || !valid_monitor(floating.monitor.as_ref(), floating.monitor_size) + { return Err(DockError::InvalidGeometry); } let mut visit = vec![(&floating.root, 1usize)]; @@ -1180,9 +1188,7 @@ impl Layout

{ Ok(()) } Action::SetMonitor { panel, monitor, monitor_size } => { - let valid = monitor.as_ref().is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) - && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])); - if !valid { + if !valid_monitor(monitor.as_ref(), monitor_size) { return Err(DockError::InvalidGeometry); } let window = self.floating.iter_mut().find(|g| g.contains(&panel)).ok_or(DockError::InvalidPlacement)?; diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 11f3fcde..792d64da 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -275,6 +275,12 @@ fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad let mut native = docked(); native.floating[0].native = Some([0.0, 0.0, -1.0, 10.0]); assert_eq!(native.validate(), Err(DockError::InvalidGeometry)); + let mut monitor = docked(); + monitor.floating[0].monitor = Some(super::Monitor { name: None, rect: [0.0, 0.0, 1920.0, 1080.0], scale: f32::NAN }); + assert_eq!(monitor.validate(), Err(DockError::InvalidGeometry)); + let mut monitor_size = docked(); + monitor_size.floating[0].monitor_size = Some([f32::NAN, 1080.0]); + assert_eq!(monitor_size.validate(), Err(DockError::InvalidGeometry)); let mut layout = docked(); assert_eq!(layout.apply(Action::ResizeColumn { panel: 2, width: f32::NAN }), Err(DockError::InvalidGeometry)); assert_eq!(layout.apply(Action::ResizeColumn { panel: 11, width: 50.0 }), Err(DockError::InvalidPlacement)); From 98e05997a032bf5269263b431e0296d91769a81f Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 17:55:03 +0000 Subject: [PATCH 104/160] docking: native windows place by their content and follow the layout - A window made this session opens at its computed rect; only windows of a loaded layout are restored onto a connected monitor. - The content (inner rect) is each native window's desktop origin; only OuterPosition converts to the outer position. - A bar drag places the window from its reported position, so late moves do not overshoot. The layout follows once the drag ends, and a window left with its bar off every monitor goes back onto the nearest. - A changed Floating::native (undo, reset, reload) moves the OS window instead of being overwritten by its next report. - Dragging a native window's only tab or group docks it whatever title_bar_docks says. - Escape that cancels a drag is consumed; nothing is forwarded while a widget of the window has focus. - A bar drag whose window goes away ends. - An embedded fallback keeps its drop targets; floating windows get no taskbar entry. - The test host can give windows a frame inset and land moves a frame late. --- crates/ui/src/docking/floating_tests.rs | 7 +- crates/ui/src/docking/native_tests.rs | 294 ++++++++++++++++++++++- crates/ui/src/docking/render/cross.rs | 28 ++- crates/ui/src/docking/render/floating.rs | 12 +- crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 272 ++++++++++++++------- crates/ui/src/docking/render/preview.rs | 2 +- crates/ui/src/docking/testing.rs | 42 +++- 8 files changed, 558 insertions(+), 101 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 8e20d699..1806c429 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -537,9 +537,12 @@ fn native_window_close_closes_its_panels() { d.ctx.set_embed_viewports(false); d.content.native = true; d.layout = layout; - let out = d.step(vec![]); + d.step(vec![]); assert!(d.response(Id::new(AREA).with(("move-group", Some(&5u32)))).is_none(), "drawn in its own OS window"); - assert!(out.actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 905.0, 250.0, 300.0]) }), "{:?}", out.actions); + // Where the window system put its content, once a report that late is no longer the + // opening move landing. + let reported = (0..60).any(|_| d.step(vec![]).actions.contains(&Action::SetNative { panel: 5, rect: Some([1210.0, 935.0, 250.0, 300.0]) })); + assert!(reported, "{:?}", d.layout.floating[0].native); close.set(true); let out = d.step(vec![]); diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 85e32489..74caf8ac 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::floats_shown; +use super::render::{DragPointer, WindowDrag, floats_shown}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -17,11 +17,26 @@ struct Content { fixed: Vec, /// Panels that stay in-window. in_window: Vec, + /// The panel with a text field, what it holds, and whether it takes focus next frame. + text: Option, + typed: String, + focus_text: bool, + /// The panel with a button that takes focus. + focused_button: Option, } impl DockContent for Content { fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { ui.label(format!("panel {panel}")); + if self.text == Some(*panel) { + let field = ui.text_edit_singleline(&mut self.typed); + if std::mem::take(&mut self.focus_text) { + field.request_focus(); + } + } + if self.focused_button == Some(*panel) { + ui.button("OK").request_focus(); + } } fn logical_label(&self, panel: &u32) -> String { @@ -639,3 +654,280 @@ fn the_window_focused_last_is_in_front_where_windows_overlap() { assert!(anchor.is_some_and(|a| anchors.contains(&a)), "front {front}: {:?}", out.actions); } } + +/// Every `OuterPosition` sent to `viewport`, in order. +fn moves(d: &DockDriver, viewport: egui::ViewportId) -> Vec { + d.host() + .commands + .iter() + .filter_map(|(v, c)| match c { + ViewportCommand::OuterPosition(pos) if *v == viewport => Some(*pos), + _ => None, + }) + .collect() +} + +fn escape() -> Event { + Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn a_window_made_this_session_opens_where_it_was_put() { + // egui reports the monitor's size but no monitor list. A tab dropped on the desktop right + // of the main window's monitor opens there. + let size = Some(vec2(1920.0, 1080.0)); + let mut d = driver(layout(false)); + d.host().monitor_size = size; + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = pos2(2400.0, 120.0); + d.hold(tab, out); + d.release(out); + d.settle(); + let desktop = ORIGIN + out.to_vec2(); + let min = pos2(desktop.x - tab.x - 8.0, desktop.y - tab.y - 6.0 - BAR); + let native = window(&d, 1).native.unwrap(); + assert_eq!(pos2(native[0], native[1]), min); + assert_eq!(os_window(&d, 1).unwrap().0.min, min); + + // Undock Panel, to a place off that monitor. + let mut d = driver(layout(false)); + d.host().monitor_size = size; + d.layout.apply(Action::Float { panel: 2, rect: [2400.0, 100.0, 300.0, 200.0] }).unwrap(); + d.settle(); + assert_eq!(window(&d, 2).native, Some([2500.0, 150.0, 300.0, 200.0])); + + // A loaded layout's window still opens on the main window's monitor. + let mut saved = layout(true); + saved.floating[0].native = Some([2500.0, 100.0, 240.0, 300.0]); + let mut d = driver(Layout::default()); + d.host().monitor_size = size; + d.layout = saved; + d.settle(); + assert_eq!(window(&d, 5).native, Some([1680.0, 100.0, 240.0, 300.0])); + assert_eq!(os_window(&d, 5).unwrap().0.min, pos2(1680.0, 100.0)); +} + +#[test] +fn a_changed_layout_moves_the_os_window() { + let mut d = driver(layout(true)); + let viewport = d.viewport(id_of(&d)); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + // An undo of a move, a reset, or a layout reloaded with the same ids. + d.layout.floating[0].native = Some([700.0, 200.0, 260.0, 320.0]); + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::SetNative { .. })), "{:?}", out.actions); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect, Rect::from_min_size(pos2(700.0, 200.0), vec2(260.0, 320.0))); + assert_eq!(window(&d, 5).native, Some([700.0, 200.0, 260.0, 320.0])); + + // Off every monitor: onto the nearest, and the layout follows. + d.monitors = vec![Monitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }]; + d.layout.floating[0].native = Some([5000.0, 200.0, 260.0, 320.0]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(1660.0, 200.0)); + assert_eq!(window(&d, 5).native, Some([1660.0, 200.0, 260.0, 320.0])); + + // A bar drag that leaves too little of the bar on a monitor ends with the window on it. + let id = id_of(&d); + let grab = pos2(40.0, 9.0); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + let far = d.window_local(id, pos2(1950.0, 300.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(far)]); + let far = d.window_local(id, pos2(1950.0, 300.0)).unwrap(); + d.window_step(id, vec![Event::PointerMoved(far), button(far, false)]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(1660.0, 291.0)); + assert_eq!(window(&d, 5).native, Some([1660.0, 291.0, 260.0, 320.0])); +} + +#[test] +fn a_bar_drag_follows_the_pointer_when_moves_land_late() { + let mut d = driver(layout(true)); + d.host().lag = true; + let id = id_of(&d); + let viewport = d.viewport(id); + let grab = pos2(40.0, 9.0); + let mut pointer = d.host().windows[&viewport].rect.min + grab.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + // On to the desktop right of the main window, where the release docks nothing. + for _ in 0..6 { + pointer += vec2(90.0, -20.0); + // The window system reports the pointer relative to where the window is now. + let local = d.window_local(id, pointer).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + assert_eq!(moves(&d, viewport).last(), Some(&(pointer - grab.to_vec2())), "no overshoot"); + } + let local = d.window_local(id, pointer).unwrap(); + d.window_step(id, vec![button(local, false)]); + d.settle(); + let to = pointer - grab.to_vec2(); + assert_eq!(d.host().windows[&viewport].rect.min, to); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([to.x, to.y])); +} + +#[test] +fn a_native_windows_content_is_its_origin_where_its_frame_differs() { + // A title bar's height between the window's outer position and its content. + let mut d = DockDriver::new(Layout::default(), Content::default()).with_native_host(ORIGIN); + d.host().frame = vec2(0.0, 28.0); + d.layout = layout(true); + d.settle(); + // It opened with its frame there, then moved so its content is. + let id = id_of(&d); + let viewport = d.viewport(id); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0)); + assert_eq!(window(&d, 5).native, Some([400.0, 130.0, 240.0, 300.0])); + + // Its bar moves it by its outer position, here onto the desktop right of the main window. + let grab = pos2(40.0, 9.0); + d.window_step(id, vec![Event::PointerMoved(grab), button(grab, true)]); + d.window_step(id, vec![Event::PointerMoved(grab + vec2(520.0, 20.0))]); + assert_eq!(moves(&d, viewport).last(), Some(&pos2(920.0, 122.0))); + d.window_step(id, vec![button(grab, false)]); + d.settle(); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(920.0, 150.0)); + assert_eq!(window(&d, 5).native.map(|r| [r[0], r[1]]), Some([920.0, 150.0])); + + // Its tab drops where the pointer is on the desktop: the main window's tab row of [3, 4]. + let row = d.window_local(id, ORIGIN + vec2(450.0, 13.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), row); + let out = d.window_release(id, row); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 3 | 4, placement: Placement::Tab { .. } }]), "{:?}", out.actions); +} + +#[test] +fn the_only_tab_of_a_native_window_docks_it_with_title_bar_docks_off() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let driver = || DockDriver::new(layout.clone(), Content::default()).with_style(|s| s.title_bar_docks = false).with_native_host(ORIGIN); + let mut d = driver(); + let id = window(&d, 7).id; + let row = ORIGIN + vec2(450.0, 13.0); + let from = pos2(15.0, TAB_ROW); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + d.window_step(id, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + for _ in 0..2 { + let local = d.window_local(id, row).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + } + let local = d.window_local(id, row).unwrap(); + let out = d.window_step(id, vec![button(local, false)]); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, .. })), "{:?}", out.actions); + assert!(in_root(&d, 7)); + + // Its bar keeps to `title_bar_docks`. + let mut d = driver(); + let out = drag_bar_to(&mut d, 7, pos2(450.0, 13.0)); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + assert!(!in_root(&d, 7)); +} + +#[test] +fn a_native_window_forwards_no_keys_while_one_of_its_widgets_has_focus() { + let mut d = driver(layout(true)); + (d.content.text, d.content.focus_text) = (Some(5), true); + d.settle(); + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id_of(&d), vec![key, Event::Text("s".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + assert_eq!(d.content.typed, "s"); + + // A focused button's Enter is its own. + let mut d = driver(layout(true)); + d.content.focused_button = Some(5); + d.settle(); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let out = d.window_step(id_of(&d), vec![enter]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); +} + +#[test] +fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { + // Escape cancels it, and is no shortcut. + let mut d = driver(layout(true)); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + let out = d.window_step(id, vec![escape()]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(window(&d, 5).contains(&6)); + + // Escape during a bar drag is no shortcut either. + let mut d = driver(layout(true)); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id_of(&d), vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let out = d.window_step(id_of(&d), vec![escape()]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + + // Another application takes focus: cancelled. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + d.host().focus = None; + d.window_step(id, vec![Event::PointerMoved(over)]); + d.host().focus = Some(d.viewport(id)); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(!super::is_dragging::(&d.ctx, d.id)); +} + +#[test] +fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { + let mut d = driver(two_windows()); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.host().focus = Some(viewport); + d.step(vec![Event::WindowFocused(false), Event::PointerMoved(body)]); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(body); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 7, .. }]), "{:?}", out.actions); +} + +#[test] +fn a_drag_ends_when_its_native_window_or_panel_goes() { + let pointer = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("drag-pointer"))); + let moving = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))); + + // The window closes during a tab drag it owns. + let mut d = driver(layout(true)); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + let viewport = d.viewport(id); + d.host().windows.get_mut(&viewport).unwrap().close = true; + d.step(vec![]); + d.settle(); + assert!(!d.layout.contains(&5)); + assert!(!super::is_dragging::(&d.ctx, d.id)); + assert_eq!(pointer(&d), None); + + // The dragged panel is closed by the application. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + d.layout.apply(Action::Close { panel: 5 }).unwrap(); + let out = d.window_release(id, over); + assert!(out.error.is_none() && !out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + d.settle(); + assert!(!super::is_dragging::(&d.ctx, d.id)); + assert_eq!(pointer(&d), None); + + // The window closes during its own bar drag. + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(moving(&d).is_some()); + d.layout.apply(Action::CloseWindow { panel: 5 }).unwrap(); + d.settle(); + assert_eq!(moving(&d), None); + assert_eq!(pointer(&d), None); + // The main window's drags work as before. + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let out = d.drag(tab, pos2(600.0, 300.0)); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 3 | 4, .. }]), "{:?}", out.actions); +} diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 28478727..cfd08313 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -42,6 +42,9 @@ pub(in crate::docking) struct DragPointer { pub command: bool, /// The window a bar drag moves. It follows the pointer and is never its own target. pub window: Option, + /// That drag began on the window's only tab or group: it docks whatever + /// [`crate::docking::DockStyle::title_bar_docks`] says. + pub docks: bool, } impl DragPointer { @@ -93,6 +96,10 @@ pub(in crate::docking) struct Cross

{ pub bar: bool, /// A target was offered while `quiet`. pub offered: Cell, + /// Where the native window drawing now was told to move its content this frame. + pub moved: Option, + /// Resolving a window move that docks whatever `title_bar_docks` says. + pub docks: bool, } impl

Default for Cross

{ @@ -108,14 +115,16 @@ impl

Default for Cross

{ quiet: false, bar: false, offered: Cell::new(false), + moved: None, + docks: false, } } } impl Renderer<'_, P> { /// Write the pointer of the drag this window owns. `origin` is the window's desktop - /// origin for this frame's input. - pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option, origin: Option, window: Option) -> DragPointer { + /// origin for this frame's input; `window` the bar drag it is. + pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option, origin: Option, window: Option<&WindowDrag>) -> DragPointer { let key = self.id.with(POINTER); let kept = ctx.data(|d| d.get_temp::(key)).filter(|p| p.owner == owner).and_then(|p| p.local); let pointer = ctx.input(|i| DragPointer { @@ -126,7 +135,8 @@ impl Renderer<'_, P> { released: i.pointer.primary_released(), escape: i.key_pressed(Key::Escape), command: i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd, - window, + window: window.map(|w| w.window), + docks: window.is_some_and(|w| w.docks), }); ctx.data_mut(|d| d.insert_temp(key, pointer)); self.cross.pointer = Some(pointer); @@ -290,21 +300,21 @@ impl Renderer<'_, P> { let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return }; let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return }; if let Some(Dest::Main(local)) = self.destination(&pointer, None) { - (self.cross.quiet, self.cross.bar) = (true, true); + (self.cross.quiet, self.cross.bar, self.cross.docks) = (true, true, pointer.docks); self.paint_group_drag(ui, &drag, local, pointer.command); - (self.cross.quiet, self.cross.bar) = (false, false); + (self.cross.quiet, self.cross.bar, self.cross.docks) = (false, false, false); } } /// Release of a bar drag: over a target in another window the whole window docks or /// joins there. Over the desktop, or with Ctrl/Cmd held, it stays where it was moved. fn drop_native_window(&mut self, window: u64, pointer: &DragPointer) { - let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && self.style.title_bar_docks) else { return }; + let Some(drag) = self.window_group_drag(window).filter(|_| !pointer.command && (pointer.docks || self.style.title_bar_docks)) else { return }; match self.destination(pointer, None) { Some(Dest::Main(local)) => { - self.cross.bar = true; + (self.cross.bar, self.cross.docks) = (true, pointer.docks); self.drop_window(&drag, local, false); - self.cross.bar = false; + (self.cross.bar, self.cross.docks) = (false, false); } Some(Dest::Window(target, local)) => { let action = self.within_window(target, true, |r| { @@ -353,7 +363,9 @@ impl Renderer<'_, P> { let ctx = ui.ctx(); if let Some(window) = pointer.window { if let Some(drag) = self.window_group_drag(window) { + self.cross.docks = pointer.docks; self.paint_group_drag(ui, &drag, local, pointer.command); + self.cross.docks = false; } return; } diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 72ec389c..21eaf447 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -220,11 +220,21 @@ impl Renderer<'_, P> { matches!(self.place(panel), Some(crate::docking::dock_model::Place::Floating { path, .. }) if path.is_empty()) } + /// Whether moving a whole window docks it: [`crate::docking::DockStyle::title_bar_docks`], + /// or a native window moved by its only tab or group. + pub(super) fn title_bar_docks(&self) -> bool { + #[cfg(feature = "native-windows")] + if self.cross.docks { + return true; + } + self.style.title_bar_docks + } + /// Release of a title bar drag. The window already sits where it was dragged; a dock or /// group target docks or merges every group in it, else hosts get the release in /// `group_drop`. pub(super) fn drop_window(&mut self, drag: &GroupDrag

, pointer: Pos2, float_only: bool) { - let float_only = float_only || !self.style.title_bar_docks; + let float_only = float_only || !self.title_bar_docks(); if !float_only && let Some((action, _)) = self.dock_group_proposal(drag, pointer) { self.output.actions.extend(action); return; diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 023461bf..823e3086 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -15,7 +15,7 @@ mod iconic; #[cfg(feature = "native-windows")] mod native; #[cfg(all(test, feature = "native-windows"))] -pub(in crate::docking) use native::floats_shown; +pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown}; /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index eade8e53..09711bee 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -7,21 +7,25 @@ use std::{fmt::Debug, hash::Hash}; -use egui::{Context, Event, Id, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, WindowLevel, pos2, vec2}; +use egui::{ + Context, Event, Id, Key, Modifiers, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, + vec2, +}; use super::Renderer; use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; -use crate::docking::placement::{monitor_of, restore, sanitized}; +pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; +use crate::docking::placement::{kept_reachable, monitor_of, restore, sanitized}; use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node}; /// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; -/// Height of the bar drawn at the top of a native window. -pub(in crate::docking) const BAR_HEIGHT: f32 = 18.0; /// How long floating windows stay after the application's last window lost focus, so focus /// moving between our own windows does not hide them. const HIDE_DELAY: f64 = 0.25; +/// How long reports of a native window's place may lag a move the dock area commanded. +const MOVE_WAIT: f64 = 0.5; /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -30,11 +34,40 @@ pub(in crate::docking) struct WindowDrag { pub window: u64, /// Where the pointer pressed, in the window's local points. pub grab: Vec2, - /// The window's outer position on the desktop: reported when the drag began, then as last - /// commanded. The reported position lags and jitters while the window moves. - pub origin: Pos2, - /// Where the window was when the drag began, for Escape. + /// Desktop position of the window's content as last commanded. + pub to: Pos2, + /// Desktop position of the window's content when the drag began, for Escape. pub start: Pos2, + /// The drag began on the window's only tab or group, so it docks whatever + /// [`crate::docking::DockStyle::title_bar_docks`] says. + pub docks: bool, +} + +/// What the dock area knows of a shown native window's place, in the context's data under +/// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content. +#[derive(Clone, Copy, Debug)] +struct Place { + /// Where it opened. The builder keeps it, so egui sends no move of its own. + spawn: [f32; 4], + /// As last reported or commanded. [`Floating::native`] follows it; a `native` that differs + /// was changed by the application (undo, reset, a reloaded layout), and the window moves. + known: [f32; 4], + /// Until when reports that differ from `known` are a commanded move that has not landed. + pending: Option, + /// The window system put the frame, not the content, where it opened: corrected once. + corrected: bool, +} + +/// Whether two rects differ by more than rounding. +fn differs(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).any(|(a, b)| (a - b).abs() > 0.5) +} + +/// Desktop position of a viewport's content, and its offset from the window's outer +/// position, where the window system reports both. +fn content_of(info: &ViewportInfo) -> Option<(Pos2, Vec2)> { + let (outer, inner) = (info.outer_rect?, info.inner_rect?); + (outer.min.is_finite() && inner.min.is_finite()).then(|| (inner.min, inner.min - outer.min)) } /// The viewport of the native window `window` of the dock area `area`. The `n`th window with @@ -52,17 +85,20 @@ pub(in crate::docking) fn floats_shown(focused: bool, now: f64, last_focused: f6 focused || now - last_focused < HIDE_DELAY } -/// The desktop rect a native viewport reports: outer position, inner size. None when the -/// backend reports nothing usable. -fn placed(outer: Option, inner: Option) -> Option<[f32; 4]> { - let (outer, inner) = (outer?, inner?); - (outer.min.is_finite() && inner.size().is_finite()).then(|| sanitized([outer.min.x, outer.min.y, inner.width(), inner.height()])) +/// The desktop rect of a native viewport's content. None when the backend reports no +/// positions. +fn placed(info: &ViewportInfo) -> Option<[f32; 4]> { + let (min, _) = content_of(info)?; + let size = info.inner_rect?.size(); + size.is_finite().then(|| sanitized([min.x, min.y, size.x, size.y])) } /// What a native window's frame reports back to the main window. struct Report

{ closed: bool, placed: Option<[f32; 4]>, + /// From the window's outer position to its content. + frame: Vec2, monitor_size: Option, /// Its drop targets, for drags from other windows. surface: Option>, @@ -70,7 +106,7 @@ struct Report

{ impl

Default for Report

{ fn default() -> Self { - Self { closed: false, placed: None, monitor_size: None, surface: None } + Self { closed: false, placed: None, frame: Vec2::ZERO, monitor_size: None, surface: None } } } @@ -87,22 +123,25 @@ impl Renderer<'_, P> { let mut natives = Vec::new(); for window in windows { let allowed = window.panels().all(|p| self.content.allow_native(p) && self.permissions(p).floatable); - let Some(first) = window.panels().next().cloned().filter(|_| native && allowed) else { + if !native || !allowed || window.panels().next().is_none() { in_window.push(window); continue; - }; + } // Every float is native: one still in-window opens over its place in the app window. let rect = window.native.unwrap_or_else(|| { let [x, y, w, h] = window.rect; - let rect = sanitized([origin.x + x, origin.y + y, w, h]); - self.output.actions.push(Action::SetNative { panel: first, rect: Some(rect) }); - rect + sanitized([origin.x + x, origin.y + y, w, h]) }); let n = ids.iter().filter(|id| **id == window.id).count(); ids.push(window.id); natives.push((window, rect, viewport_id(self.id, window.id, n))); } if native { + // A window closed or docked during its own bar drag ends the drag. + let key = self.id.with("window-drag"); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { + ctx.data_mut(|d| d.remove::(key)); + } self.map_desktop(ctx, &natives, visibility.0); // The main window owns drags of the panels it draws. let id = self.id; @@ -124,7 +163,7 @@ impl Renderer<'_, P> { let previous = ctx.data_mut(|d| d.remove_temp::>(key)).unwrap_or_default(); ctx.data_mut(|d| { for id in previous.iter().filter(|id| !shown.contains(id)) { - d.remove::<[f32; 4]>(*id); + d.remove::(*id); } d.insert_temp(key, shown); }); @@ -137,7 +176,6 @@ impl Renderer<'_, P> { let key = self.id.with("native-order"); let mut order = ctx.data(|d| d.get_temp::>(key)).unwrap_or_default(); order.retain(|id| natives.iter().any(|(w, ..)| w.id == *id)); - let moving = ctx.data(|d| d.get_temp::(self.id.with("window-drag"))); let mut windows = Vec::new(); for (window, rect, viewport) in natives { let info = ctx.input(|i| i.raw.viewports.get(viewport).cloned()); @@ -149,10 +187,7 @@ impl Renderer<'_, P> { let [x, y, w, h] = *rect; let placed = match info { None => Some(Rect::from_min_size(pos2(x, y), vec2(w, h))), - Some(info) => info.outer_rect.zip(info.inner_rect).map(|(outer, inner)| { - let min = moving.filter(|m| m.window == window.id).map_or(outer.min, |m| m.origin); - Rect::from_min_size(min, inner.size()) - }), + Some(info) => placed(&info).map(|[x, y, w, h]| Rect::from_min_size(pos2(x, y), vec2(w, h))), }; if let Some(placed) = placed.filter(|r| r.is_finite() && visible) { windows.push((window.id, placed)); @@ -184,32 +219,61 @@ impl Renderer<'_, P> { (visible, visible != was) } - /// One floating window in its own viewport. Returns the key of its spawn rect. + /// One floating window in its own viewport. Returns the key of its [`Place`]. fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visibility: (bool, bool)) -> Option { let (visible, toggled) = visibility; let first = window.panels().next()?.clone(); - // The window opens at its saved place, moved onto a connected monitor; after that the - // window system owns its position and size, which are read back below. - let spawn_key = self.id.with(("native-spawn", viewport)); - let spawned = ctx.data(|d| d.get_temp::<[f32; 4]>(spawn_key)); - let spawn = spawned.unwrap_or_else(|| { - let (main, size) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); - restore(rect, window.monitor.as_ref(), &self.monitors, main, size) - }); - ctx.data_mut(|d| d.insert_temp(spawn_key, spawn)); + let key = self.id.with(("native-place", viewport)); + let now = ctx.input(|i| i.time); + let drag_key = self.id.with("window-drag"); + let moving = |ctx: &Context| ctx.data(|d| d.get_temp::(drag_key)).is_some_and(|d| d.window == window.id); + let info = ctx.input(|i| i.raw.viewports.get(&viewport).cloned()); + let frame = info.as_ref().and_then(content_of).map_or(Vec2::ZERO, |(_, frame)| frame); + let mut place = match ctx.data(|d| d.get_temp::(key)) { + Some(mut place) => { + // The application changed the window's place (undo, reset, a reloaded layout): + // the OS window moves there, kept where its bar can be grabbed. + if !moving(ctx) + && let Some(native) = window.native.filter(|native| differs(*native, place.known)) + { + let target = kept_reachable(native, &self.monitors); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(target[0], target[1]) - frame)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(vec2(target[2], target[3]))); + (place.known, place.pending) = (target, Some(now + MOVE_WAIT)); + } + place + } + None => { + // A window of a loaded layout opens at its saved place, moved onto a connected + // monitor. One made this session opens where it was put. + let seen_key = self.id.with("native-seen"); + let mut seen = ctx.data(|d| d.get_temp::>(seen_key)).unwrap_or_default(); + let spawn = if window.native.is_some() && !seen.contains(&window.id) { + let (main, size) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size)); + restore(rect, window.monitor.as_ref(), &self.monitors, main, size) + } else { + sanitized(rect) + }; + seen.insert(window.id); + ctx.data_mut(|d| d.insert_temp(seen_key, seen)); + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false } + } + }; + let spawn = place.spawn; let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() .with_title(title) .with_position([spawn[0], spawn[1]]) .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) - .with_window_level(WindowLevel::AlwaysOnTop); + .with_window_level(WindowLevel::AlwaysOnTop) + .with_taskbar(false); // The bar replaces the OS title bar. macOS keeps its frame for edge resizing. #[cfg(target_os = "macos")] let builder = builder.with_titlebar_shown(false).with_title_shown(false).with_fullsize_content_view(true).with_titlebar_buttons_shown(false); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); - if toggled || (spawned.is_none() && !visible) { + if toggled || (info.is_none() && !visible) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); } if visible && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { @@ -244,32 +308,57 @@ impl Renderer<'_, P> { self.forward_events(ui); ui.input(|i| Report { closed: i.viewport().close_requested(), - placed: placed(i.viewport().outer_rect, i.viewport().inner_rect), + placed: placed(i.viewport()), + frame: content_of(i.viewport()).map_or(Vec2::ZERO, |(_, frame)| frame), monitor_size: i.viewport().monitor_size, surface: Some(surface), }) }); - // Targets in another OS window are in its coordinates. - self.targets.truncate(targets); - self.dock_targets.truncate(docks); - self.frame.windows.truncate(windows); + // Targets in another OS window are in its coordinates. An embedded fallback drew its + // targets in the main window's. + if report.surface.is_some() { + self.targets.truncate(targets); + self.dock_targets.truncate(docks); + self.frame.windows.truncate(windows); + } self.cross.surfaces.extend(report.surface); + if let Some(to) = self.cross.moved.take() { + (place.known[0], place.known[1], place.pending) = (to.x, to.y, Some(now + MOVE_WAIT)); + } + let moving = moving(ctx); + if let Some(placed) = report.placed.filter(|_| !moving) { + let waiting = place.pending.is_some_and(|until| now < until); + if !differs(placed, place.known) { + place.pending = None; + } else if waiting { + // The window system put the window's frame where its content should be. + let outer = pos2(placed[0], placed[1]) - report.frame; + if !place.corrected && report.frame.length() > 0.5 && outer.distance(pos2(place.known[0], place.known[1])) < 0.5 { + place.corrected = true; + ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(place.known[0], place.known[1]) - report.frame)); + } + } else { + (place.known, place.pending) = (placed, None); + } + } + ctx.data_mut(|d| d.insert_temp(key, place)); if report.closed { // Like the bar's ×: its panels close and reopen from the application's menu. self.output.actions.push(Action::CloseWindow { panel: first }); - return Some(spawn_key); + return Some(key); } - let now = report.placed.unwrap_or(rect); - if let Some(placed) = report.placed.filter(|placed| placed.iter().zip(rect).any(|(a, b)| (a - b).abs() > 0.5)) { - self.output.actions.push(Action::SetNative { panel: first.clone(), rect: Some(placed) }); + // The layout follows the window's place, once a bar drag is over. + let known = place.known; + if !moving && window.native.is_none_or(|native| differs(native, known)) { + self.output.actions.push(Action::SetNative { panel: first.clone(), rect: Some(known) }); // The in-window rect follows, for builds that draw the window in-window. if window.panels().all(|p| self.permissions(p).movable) { let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); - let [x, y, w, h] = placed; + let [x, y, w, h] = known; self.output.actions.push(Action::MoveFloating { panel: first.clone(), rect: [x - origin.x, y - origin.y, w, h] }); } } - let monitor = monitor_of(&self.monitors, now).cloned(); + let monitor = monitor_of(&self.monitors, known).cloned(); let size = match monitor { Some(_) => None, None => report.monitor_size.filter(|s| s.is_finite() && s.x > 0.0 && s.y > 0.0).map(|s| [s.x, s.y]), @@ -277,7 +366,7 @@ impl Renderer<'_, P> { if (monitor.is_some() || size.is_some()) && (monitor != window.monitor || size != window.monitor_size) { self.output.actions.push(Action::SetMonitor { panel: first, monitor, monitor_size: size }); } - Some(spawn_key) + Some(key) } /// The bar at the top of a native window: its title and ×. Dragging it moves the window. @@ -300,13 +389,13 @@ impl Renderer<'_, P> { self.output.actions.push(Action::CloseWindow { panel: first }); } if movable && response.bar.drag_started() { - self.start_window_drag(ui, window.id); + self.start_window_drag(ui, window.id, false); } } /// After a native window's tree drew: a drag of its only tab, or of its only group, moves /// the window as its bar does. Then the window follows a bar drag, and writes the pointer - /// of a drag it owns. Returns the window's desktop origin, where known. + /// of a drag it owns. Returns the desktop position of the window's content, where known. fn window_gestures(&mut self, ui: &Ui, window: &Floating

) -> Option { let ctx = ui.ctx(); let (drag_key, group_key) = (self.id.with("drag"), self.id.with("group-drag")); @@ -319,60 +408,79 @@ impl Renderer<'_, P> { d.remove::>(drag_key); d.remove::>(group_key); }); - self.start_window_drag(ui, window.id); + self.start_window_drag(ui, window.id, true); } - let outer = ui.input(|i| i.viewport().outer_rect).map(|r| r.min).filter(|p| p.is_finite()); - if let Some(origin) = self.follow_window_drag(ui, window.id) { - return Some(origin); + let origin = ui.input(|i| content_of(i.viewport())).map(|(origin, _)| origin); + if self.follow_window_drag(ui, window.id) { + return origin; } let owns = ctx.data(|d| d.get_temp::>(drag_key).map(|d| d.panel).or(d.get_temp::>(group_key).map(|d| d.panel))); - if owns.is_some_and(|panel| window.contains(&panel)) { - self.record_pointer(ctx, Some(window.id), outer, None); + if owns.is_some_and(|panel| window.contains(&panel)) && self.record_pointer(ctx, Some(window.id), origin, None).escape { + // The drag's cancel, not a shortcut. + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); } - outer + origin } /// Start moving the window with the pointer, from the position the window system reports, - /// or, where it reports none (Wayland), by handing the move to it. - fn start_window_drag(&mut self, ui: &Ui, window: u64) { + /// or, where it reports none (Wayland), by handing the move to it. `docks`: the drag began + /// on the window's only tab or group. + fn start_window_drag(&mut self, ui: &Ui, window: u64, docks: bool) { let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); - match (ui.input(|i| i.viewport().outer_rect), press) { - (Some(outer), Some(press)) if outer.min.is_finite() => { - let drag = WindowDrag { window, grab: press.to_vec2(), origin: outer.min, start: outer.min }; + match (ui.input(|i| content_of(i.viewport())), press) { + (Some((origin, _)), Some(press)) => { + let drag = WindowDrag { window, grab: press.to_vec2(), to: origin, start: origin, docks }; ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-drag"), drag)); } _ => ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag), } } - /// Move the window by `OuterPosition` from the position last commanded, while the button - /// is held. Escape puts it back; losing focus leaves it. Returns the desktop origin of - /// this frame's input while the window is moving. - fn follow_window_drag(&mut self, ui: &Ui, window: u64) -> Option { + /// Move the window by `OuterPosition` while the button is held, so the pressed point stays + /// under the pointer. Escape puts it back; losing focus leaves it. Returns whether the + /// window is moving. + fn follow_window_drag(&mut self, ui: &Ui, window: u64) -> bool { let key = self.id.with("window-drag"); - let mut drag = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window)?; - let origin = drag.origin; - let pointer = self.record_pointer(ui.ctx(), Some(window), Some(origin), Some(window)); + let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return false }; + let content = ui.input(|i| content_of(i.viewport())); + let pointer = self.record_pointer(ui.ctx(), Some(window), content.map(|(origin, _)| origin), Some(&drag)); + let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); + let move_to = |r: &mut Self, to: Pos2| { + ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); + r.cross.moved = Some(to); + }; if pointer.escape || !self.cross.focused { if pointer.escape { - ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.start)); + move_to(self, drag.start); + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); } ui.ctx().data_mut(|d| d.remove::(key)); - return Some(origin); + return true; } if !pointer.down { ui.ctx().data_mut(|d| d.remove::(key)); + // Left with too little of its bar on a monitor to grab it: back onto the nearest. + if let Some(size) = ui.input(|i| i.viewport().inner_rect.map(|r| r.size())) { + let [x, y, ..] = kept_reachable([drag.to.x, drag.to.y, size.x, size.y], &self.monitors); + if pos2(x, y).distance(drag.to) > 0.5 { + move_to(self, pos2(x, y)); + } + } self.end_window_drag(drag, pointer); - return Some(origin); + return true; } - // The pointer on the desktop is the commanded origin plus the local pointer. - let step = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()).map_or(Vec2::ZERO, |p| p.to_vec2() - drag.grab); - if step != Vec2::ZERO { - drag.origin += step; - ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(drag.origin)); - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + // This frame's pointer is relative to where the window was when the window system sent + // it, which is where it reports the window now: a move commanded earlier may not have + // landed yet. + if let (Some((origin, _)), Some(local)) = (content, ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite())) { + let to = origin + (local.to_vec2() - drag.grab); + if to.distance(drag.to) > 0.0 { + drag.to = to; + move_to(self, to); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } } - Some(origin) + true } /// Release of a bar drag. The window already sits where it was dragged; over a target in @@ -382,9 +490,9 @@ impl Renderer<'_, P> { } /// Keys, text and clipboard events the window's widgets left over, for the application's - /// shortcuts. Nothing while a text field of the window has focus. + /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. fn forward_events(&mut self, ui: &Ui) { - if ui.ctx().text_edit_focused() { + if ui.memory(|m| m.focused().is_some()) { return; } let events = ui.input(|i| { diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 33b04a20..9562a4ae 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -250,7 +250,7 @@ impl Renderer<'_, P> { x += advance; } } - if float_only || (drag.gesture == GroupGesture::Window && !self.style.title_bar_docks) { + if float_only || (drag.gesture == GroupGesture::Window && !self.title_bar_docks()) { return; } if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index a6c025ab..5ec3239e 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -116,6 +116,7 @@ impl> Do if let Some(host) = &self.host { let mut host = host.borrow_mut(); (host.time, host.screen) = (self.time, self.screen); + host.frames += 1; input.viewports = host.infos(); input.focused = host.focus == Some(egui::ViewportId::ROOT); } @@ -142,7 +143,9 @@ impl> Do full.textures_delta.clear(); #[cfg(feature = "native-windows")] if let Some(host) = &self.host { - host.borrow_mut().apply(&full); + let mut host = host.borrow_mut(); + host.apply(&full); + host.land(); } self.shapes = full.shapes; output @@ -347,9 +350,19 @@ pub struct NativeHost { pub focus: Option, /// Windows report no position, as on Wayland. pub no_position: bool, + /// From a native window's outer position to its content, as a title bar puts it. The + /// window opens with its outer position where it was asked to, and `OuterPosition` moves + /// that. + pub frame: egui::Vec2, + /// `OuterPosition` lands a frame late, at the end of the frame after the command. That + /// frame still reports the old place, and gets pointer events relative to it. + pub lag: bool, pub windows: std::collections::HashMap, /// Every command sent to a native window, in order. pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, + /// Moves waiting to land under `lag`: window, content position, frame sent. + late: Vec<(egui::ViewportId, Pos2, usize)>, + frames: usize, screen: Rect, time: f64, } @@ -358,7 +371,8 @@ pub struct NativeHost { #[cfg(feature = "native-windows")] #[derive(Debug)] pub struct HostWindow { - /// Desktop rect of its content: where it opened, then moved by `OuterPosition`. + /// Desktop rect of its content: where it opened, then moved by `OuterPosition` and + /// `InnerSize`. pub rect: Rect, pub visible: bool, /// Frames it drew. @@ -379,8 +393,12 @@ impl NativeHost { monitor_size: None, focus: Some(egui::ViewportId::ROOT), no_position: false, + frame: egui::Vec2::ZERO, + lag: false, windows: Default::default(), commands: Vec::new(), + late: Vec::new(), + frames: 0, screen: Rect::NOTHING, time: 0.0, } @@ -390,7 +408,7 @@ impl NativeHost { fn infos(&self) -> egui::ViewportIdMap { let info = |id: egui::ViewportId, rect: Rect| egui::ViewportInfo { inner_rect: Some(rect), - outer_rect: (!self.no_position).then_some(rect), + outer_rect: (!self.no_position).then(|| if id == egui::ViewportId::ROOT { rect } else { Rect::from_min_max(rect.min - self.frame, rect.max) }), focused: Some(self.focus == Some(id)), monitor_size: self.monitor_size, ..Default::default() @@ -407,7 +425,7 @@ impl NativeHost { let input = { let mut host = host.borrow_mut(); let builder = &viewport.builder; - let rect = Rect::from_min_size(builder.position.unwrap_or_default(), builder.inner_size.unwrap_or(vec2(300.0, 200.0))); + let rect = Rect::from_min_size(builder.position.unwrap_or_default() + host.frame, builder.inner_size.unwrap_or(vec2(300.0, 200.0))); let visible = builder.visible.unwrap_or(true); host.windows.entry(id).or_insert_with(|| HostWindow { rect, visible, frames: 0, events: Vec::new(), close: false, shapes: Vec::new() }); let mut viewports = host.infos(); @@ -438,6 +456,18 @@ impl NativeHost { } } + /// End a frame: moves sent in an earlier one land. + fn land(&mut self) { + let frames = self.frames; + let (landed, late) = std::mem::take(&mut self.late).into_iter().partition::, _>(|(.., sent)| *sent < frames); + self.late = late; + for (id, pos, _) in landed { + if let Some(window) = self.windows.get_mut(&id) { + window.rect = Rect::from_min_size(pos, window.rect.size()); + } + } + } + /// Carry out the commands a frame sent to native windows. fn apply(&mut self, full: &egui::FullOutput) { for (id, output) in &full.viewport_output { @@ -448,7 +478,9 @@ impl NativeHost { self.commands.push((*id, command.clone())); let Some(window) = self.windows.get_mut(id) else { continue }; match command { - egui::ViewportCommand::OuterPosition(pos) => window.rect = Rect::from_min_size(*pos, window.rect.size()), + egui::ViewportCommand::OuterPosition(pos) if self.lag => self.late.push((*id, *pos + self.frame, self.frames)), + egui::ViewportCommand::OuterPosition(pos) => window.rect = Rect::from_min_size(*pos + self.frame, window.rect.size()), + egui::ViewportCommand::InnerSize(size) => window.rect = Rect::from_min_size(window.rect.min, *size), egui::ViewportCommand::Visible(visible) => window.visible = *visible, _ => {} } From 37b6a0dc2593304b7eb157672f3b7c562647d930 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 18:05:09 +0000 Subject: [PATCH 105/160] docking: native windows move only on a pointer move of their frame - A bar drag reads this frame's pointer moves only. egui keeps the last pointer position, measured against where the window was then; reused after the window moved, it crept the window on. - Escape pressed in any of the application's windows cancels a drag a native window owns. - DockContent::allow_native defaults to true: the feature is the opt-in. - DockOutput::forwarded_modifiers tells Cmd/Ctrl+C, X and V, which egui-winit sends as Copy, Cut and Paste, from those keys. --- crates/ui/src/docking/native_tests.rs | 25 +++++++++++++++- crates/ui/src/docking/render/cross.rs | 15 +++++++++- crates/ui/src/docking/render/native.rs | 41 +++++++++++++++++--------- crates/ui/src/docking/surface.rs | 13 +++++--- crates/ui/src/docking/testing.rs | 9 ++++-- 5 files changed, 80 insertions(+), 23 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 74caf8ac..530f46bf 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -313,8 +313,11 @@ fn unused_keys_in_a_native_window_reach_the_app() { let mut d = driver(layout(true)); let id = window(&d, 5).id; let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + d.modifiers = Modifiers::COMMAND; let out = d.window_step(id, vec![key.clone(), Event::Text("s".into()), Event::Copy, Event::PointerMoved(pos2(50.0, 50.0))]); + d.modifiers = Modifiers::NONE; assert_eq!(out.forwarded_events, vec![key, Event::Text("s".into()), Event::Copy]); + assert_eq!(out.forwarded_modifiers, Some(Modifiers::COMMAND)); // Keys of the main window stay its own. let out = d.key(Key::A); assert!(out.forwarded_events.is_empty()); @@ -757,6 +760,13 @@ fn a_bar_drag_follows_the_pointer_when_moves_land_late() { d.window_step(id, vec![Event::PointerMoved(local)]); assert_eq!(moves(&d, viewport).last(), Some(&(pointer - grab.to_vec2())), "no overshoot"); } + // Frames with no pointer move leave it, though the last move has landed since. + let sent = moves(&d, viewport).len(); + for _ in 0..3 { + d.step(vec![]); + } + assert_eq!(moves(&d, viewport).len(), sent, "no creep"); + assert_eq!(d.host().windows[&viewport].rect.min, pointer - grab.to_vec2()); let local = d.window_local(id, pointer).unwrap(); d.window_step(id, vec![button(local, false)]); d.settle(); @@ -861,7 +871,20 @@ fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { let out = d.window_step(id_of(&d), vec![escape()]); assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); - // Another application takes focus: cancelled. + // Escape pressed in the main window, which has keyboard focus, cancels a drag the native + // window owns. + let mut d = driver(layout(true)); + let id = id_of(&d); + let viewport = d.viewport(id); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(140.0, 59.0))]); + d.key(Key::Escape); + assert_eq!(d.host().windows[&viewport].rect.min, pos2(400.0, 130.0), "back where it was"); + let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); + assert_eq!(window_move(&out), None, "{:?}", out.actions); + + // Another application takes focus: cancelled. let mut d = driver(layout(true)); let id = id_of(&d); d.window_hold(id, pos2(15.0, TAB_ROW), over); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index cfd08313..5d36c47f 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -24,6 +24,9 @@ use crate::docking::{Action, Drag, DropTarget, Floating, GroupDrag, GroupGesture pub(super) const POINTER: &str = "drag-pointer"; /// Key of whether a target was offered last frame, for the source window's undock hint. const OFFERED: &str = "drag-offered"; +/// Key of an Escape that reached one of the application's windows other than the drag's +/// owner. Keys go to the window with keyboard focus, which need not be the one dragging. +const ESCAPE: &str = "drag-escape"; /// The pointer of a drag, as the window that owns the drag last saw it. #[derive(Clone, Copy, Debug, PartialEq)] @@ -127,13 +130,14 @@ impl Renderer<'_, P> { pub(super) fn record_pointer(&mut self, ctx: &Context, owner: Option, origin: Option, window: Option<&WindowDrag>) -> DragPointer { let key = self.id.with(POINTER); let kept = ctx.data(|d| d.get_temp::(key)).filter(|p| p.owner == owner).and_then(|p| p.local); + let elsewhere = ctx.data_mut(|d| d.remove_temp::(self.id.with(ESCAPE))).unwrap_or(false); let pointer = ctx.input(|i| DragPointer { owner, origin, local: i.pointer.latest_pos().or(i.pointer.interact_pos()).filter(|p| p.is_finite()).or(kept), down: i.pointer.primary_down(), released: i.pointer.primary_released(), - escape: i.key_pressed(Key::Escape), + escape: elsewhere || i.key_pressed(Key::Escape), command: i.modifiers.command || i.modifiers.ctrl || i.modifiers.mac_cmd, window: window.map(|w| w.window), docks: window.is_some_and(|w| w.docks), @@ -143,6 +147,15 @@ impl Renderer<'_, P> { pointer } + /// In a window that does not own the drag a native window owns (`window`, `None` for the + /// main window): an Escape pressed here cancels the drag, and is no shortcut. + pub(super) fn escape_elsewhere(&self, ctx: &Context, window: Option) { + let owner = ctx.data(|d| d.get_temp::(self.id.with(POINTER))).filter(|p| p.down).map(|p| p.owner); + if owner.is_some_and(|owner| owner.is_some() && owner != window) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + ctx.data_mut(|d| d.insert_temp(self.id.with(ESCAPE), true)); + } + } + /// Where the drag's pointer is. Without desktop positions only the owner's own window is /// known, and a point outside it is on the desktop. `current` is a window drawing now. fn destination(&self, pointer: &DragPointer, current: Option<&Surface

>) -> Option { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 09711bee..e632472f 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -137,6 +137,7 @@ impl Renderer<'_, P> { natives.push((window, rect, viewport_id(self.id, window.id, n))); } if native { + self.escape_elsewhere(ctx, None); // A window closed or docked during its own bar drag ends the drag. let key = self.id.with("window-drag"); if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { @@ -410,6 +411,7 @@ impl Renderer<'_, P> { }); self.start_window_drag(ui, window.id, true); } + self.escape_elsewhere(ctx, Some(window.id)); let origin = ui.input(|i| content_of(i.viewport())).map(|(origin, _)| origin); if self.follow_window_drag(ui, window.id) { return origin; @@ -443,8 +445,25 @@ impl Renderer<'_, P> { let key = self.id.with("window-drag"); let Some(mut drag) = ui.ctx().data(|d| d.get_temp::(key)).filter(|d| d.window == window) else { return false }; let content = ui.input(|i| content_of(i.viewport())); - let pointer = self.record_pointer(ui.ctx(), Some(window), content.map(|(origin, _)| origin), Some(&drag)); let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); + // A pointer move of this frame is relative to where the window was when the window + // system sent it, which is where it reports the window now: a move commanded earlier + // may not have landed yet. Without one the window stays, since the last position egui + // keeps (button events included) was measured against an older place. + let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); + let mut step = None; + if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { + let to = origin + (local.to_vec2() - drag.grab); + if to.distance(drag.to) > 0.0 { + (drag.to, step) = (to, Some(to)); + ui.ctx().data_mut(|d| d.insert_temp(key, drag)); + } + } + // The pointer on the desktop is the pressed point of the window where it was last + // put, so the origin is that place, less the local pointer. + let local = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite()); + let origin = local.map(|local| drag.to + drag.grab - local.to_vec2()).or(content.map(|(origin, _)| origin)); + let pointer = self.record_pointer(ui.ctx(), Some(window), origin, Some(&drag)); let move_to = |r: &mut Self, to: Pos2| { ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); r.cross.moved = Some(to); @@ -457,6 +476,9 @@ impl Renderer<'_, P> { ui.ctx().data_mut(|d| d.remove::(key)); return true; } + if let Some(to) = step { + move_to(self, to); + } if !pointer.down { ui.ctx().data_mut(|d| d.remove::(key)); // Left with too little of its bar on a monitor to grab it: back onto the nearest. @@ -467,18 +489,6 @@ impl Renderer<'_, P> { } } self.end_window_drag(drag, pointer); - return true; - } - // This frame's pointer is relative to where the window was when the window system sent - // it, which is where it reports the window now: a move commanded earlier may not have - // landed yet. - if let (Some((origin, _)), Some(local)) = (content, ui.input(|i| i.pointer.latest_pos()).filter(|p| p.is_finite())) { - let to = origin + (local.to_vec2() - drag.grab); - if to.distance(drag.to) > 0.0 { - drag.to = to; - move_to(self, to); - ui.ctx().data_mut(|d| d.insert_temp(key, drag)); - } } true } @@ -502,7 +512,10 @@ impl Renderer<'_, P> { .cloned() .collect::>() }); - self.output.forwarded_events.extend(events); + if !events.is_empty() { + self.output.forwarded_modifiers = Some(ui.input(|i| i.modifiers)); + self.output.forwarded_events.extend(events); + } } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 5855062b..59e23b54 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -241,13 +241,17 @@ pub struct DockOutput

{ /// floating window in its own OS window and that none of its widgets used (feature /// `native-windows`). egui hands each OS window only its own input, so an application /// passes these to the shortcut handling it runs on the main window's input. A window - /// whose text field has focus forwards nothing. + /// where a widget has keyboard focus forwards nothing. pub forwarded_events: Vec, + /// The modifiers held in the native window that forwarded `forwarded_events`, where it + /// forwarded any. egui-winit turns Cmd/Ctrl+C, X and V into `Copy`, `Cut` and `Paste` + /// without the key, so these tell them from the keyboard's own Copy, Cut and Paste keys. + pub forwarded_modifiers: Option, } impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new(), forwarded_events: Vec::new() } + Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new(), forwarded_events: Vec::new(), forwarded_modifiers: None } } } @@ -557,9 +561,10 @@ pub trait DockContent

{ fn paint_icon(&mut self, _painter: &egui::Painter, _rect: Rect, _panel: &P, _color: Color32) -> bool { false } - /// Whether the panel may move to a native OS window (feature `native-windows`). + /// Whether the panel's floating window may be its own OS window (feature + /// `native-windows`). A panel that returns false keeps its window in-window. fn allow_native(&self, _panel: &P) -> bool { - false + true } /// Host-owned drop resolution for targets the renderer cannot know about. Returning /// feedback consumes the gesture, including no-op targets; the renderer then emits no move. diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 5ec3239e..10508407 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -115,7 +115,7 @@ impl> Do #[cfg(feature = "native-windows")] if let Some(host) = &self.host { let mut host = host.borrow_mut(); - (host.time, host.screen) = (self.time, self.screen); + (host.time, host.screen, host.modifiers) = (self.time, self.screen, self.modifiers); host.frames += 1; input.viewports = host.infos(); input.focused = host.focus == Some(egui::ViewportId::ROOT); @@ -363,6 +363,8 @@ pub struct NativeHost { /// Moves waiting to land under `lag`: window, content position, frame sent. late: Vec<(egui::ViewportId, Pos2, usize)>, frames: usize, + /// The driver's modifiers: the keyboard's, whichever window has focus. + modifiers: Modifiers, screen: Rect, time: f64, } @@ -399,6 +401,7 @@ impl NativeHost { commands: Vec::new(), late: Vec::new(), frames: 0, + modifiers: Modifiers::NONE, screen: Rect::NOTHING, time: 0.0, } @@ -429,7 +432,7 @@ impl NativeHost { let visible = builder.visible.unwrap_or(true); host.windows.entry(id).or_insert_with(|| HostWindow { rect, visible, frames: 0, events: Vec::new(), close: false, shapes: Vec::new() }); let mut viewports = host.infos(); - let (time, focused) = (host.time, host.focus == Some(id)); + let (time, focused, modifiers) = (host.time, host.focus == Some(id), host.modifiers); let Some(window) = host.windows.get_mut(&id) else { return }; window.frames += 1; if std::mem::take(&mut window.close) @@ -441,7 +444,7 @@ impl NativeHost { viewport_id: id, viewports, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, window.rect.size())), - events: std::mem::take(&mut window.events), + events: std::iter::once(Event::ModifiersChanged(modifiers)).chain(std::mem::take(&mut window.events)).collect(), focused, time: Some(time), ..Default::default() From e2b452482a80291d43841a22bc668bd8a6c4a389 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 18:05:09 +0000 Subject: [PATCH 106/160] docking: an example with native floating windows --- Cargo.lock | 108 +++++++++++++++++++++++++++ crates/ui/Cargo.toml | 5 ++ crates/ui/examples/docking_native.rs | 53 +++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 crates/ui/examples/docking_native.rs diff --git a/Cargo.lock b/Cargo.lock index ea00f1fb..e9ae6cc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -557,6 +563,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -874,6 +889,7 @@ dependencies = [ name = "craft-ui" version = "0.1.0" dependencies = [ + "eframe", "egui", "egui_extras", "egui_kittest", @@ -1121,6 +1137,9 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", "image", "js-sys", "log", @@ -1216,6 +1235,20 @@ dependencies = [ "resvg", ] +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + [[package]] name = "egui_kittest" version = "0.36.2" @@ -1730,6 +1763,62 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -5999,6 +6088,7 @@ dependencies = [ "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", + "bytemuck", "calloop 0.13.0", "cfg_aliases", "concurrent-queue", @@ -6014,6 +6104,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", @@ -6026,6 +6117,8 @@ dependencies = [ "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -6084,13 +6177,28 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x11rb" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", "gethostname", + "libc", + "libloading", + "once_cell", "rustix 1.1.5", "x11rb-protocol", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index b5fc3bbd..554ed27f 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -26,4 +26,9 @@ egui = { workspace = true, features = ["default_fonts"] } serde_json = "1" egui_extras = { version = "0.36", default-features = false, features = ["svg"] } egui_kittest = { version = "0.36", features = ["wgpu"] } +eframe = { version = "0.36", default-features = false, features = ["wgpu", "x11", "default_fonts"] } image = { version = "0.25", default-features = false, features = ["png"] } + +[[example]] +name = "docking_native" +required-features = ["native-windows"] diff --git a/crates/ui/examples/docking_native.rs b/crates/ui/examples/docking_native.rs new file mode 100644 index 00000000..6a6940ca --- /dev/null +++ b/crates/ui/examples/docking_native.rs @@ -0,0 +1,53 @@ +//! Docking with floating panels in their own OS windows. Run with +//! `cargo run -p craft-ui --example docking_native --features native-windows`. +//! +//! Prints every action it applies and every native window's place, for checking moves by +//! hand or under a headless X server. + +use craft_ui::docking::{DockArea, DockContent, DockStyle, Floating, Layout, Node}; +use craft_ui::layout::{SplitAxis, SplitSize}; + +struct Panels; + +impl DockContent for Panels { + fn body(&mut self, ui: &mut egui::Ui, panel: &u32) { + ui.label(format!("Body of panel {panel}")); + } + + fn logical_label(&self, panel: &u32) -> String { + format!("Panel {panel}") + } +} + +struct App { + layout: Layout, +} + +impl eframe::App for App { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let style = DockStyle::from_ui(ui); + let out = DockArea::new(egui::Id::new("dock")).show_content(ui, &self.layout, &style, &mut Panels); + if !out.actions.is_empty() { + println!("actions {:?}", out.actions); + if let Err(error) = self.layout.apply_all(out.actions, |_| Default::default()) { + println!("rejected {error:?}"); + } + for window in &self.layout.floating { + println!("window {} panels {:?} native {:?}", window.id, window.panels().collect::>(), window.native); + } + } + if !out.forwarded_events.is_empty() { + println!("forwarded {:?} {:?}", out.forwarded_events, out.forwarded_modifiers); + } + } +} + +fn main() -> eframe::Result { + let tabs = |panels: &[u32]| Node::Tabs { panels: panels.to_vec(), active: 0 }; + let layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1, 2])), second: Box::new(tabs(&[3, 4])) }), + floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 320.0, 260.0]), Floating::single(vec![7], 0, [700.0, 300.0, 260.0, 200.0])], + }; + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_position([0.0, 0.0]), ..Default::default() }; + eframe::run_native("Docking", options, Box::new(|_| Ok(Box::new(App { layout })))) +} From 909410258b03b7b72f49560b95df742e9f1d6867 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 18:06:10 +0000 Subject: [PATCH 107/160] docking: document native window placement, moves and mixed scale factors --- docs/ui-docking.md | 74 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 5e938740..0bf95b0a 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -319,7 +319,9 @@ eframe on desktop), every floating window opens in its own OS window: a Ctrl/Cmd Panel and Undock Panel Group, every other drop that floats, and the floating windows of a restored layout. A window still in-window opens over its place in the app window (the main viewport's `inner_rect.min` plus its rect). A window with a panel whose -`DockContent::allow_native` is false, or that is not floatable, stays in-window. +`DockContent::allow_native` returns false (it returns true unless the app overrides it), or +that is not floatable, stays in-window. Floating windows get no taskbar or Alt-Tab entry +(`with_taskbar(false)`). - **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change, including when its first panel moves out. `Floating::new` and loading draw fresh ids; a layout @@ -336,28 +338,52 @@ viewport's `inner_rect.min` plus its rect). A window with a panel whose window cannot be placed, the bar hands the move to the compositor. - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. -- **Place.** The window's desktop rect is in `Floating::native`, and the in-window `rect` - follows it for builds that draw the window in-window. `Floating::monitor` is the monitor +- **Place.** `Floating::native` is the desktop rect of the window's content (egui's + `inner_rect`), and the in-window `rect` follows it for builds that draw the window in-window. + The content is also the window's origin for drags; only `ViewportCommand::OuterPosition` + takes the outer position, which the renderer works out from the reported outer and inner + rects. Where the window system puts a new window's frame, not its content, at the place asked + for, the window is moved once so its content is there. When the app changes `native` (undo, + a workspace reset, a reloaded layout whose ids match), the OS window moves there; a report + that differs from `native` while a commanded move has not landed (up to 0.5 s) is ignored. `Floating::monitor` is the monitor holding the window's center, from the list the app passes to `DockArea::monitors` (each a `Monitor { name, rect, scale }` in desktop logical points). egui 0.36 reports no monitors, so an app fills the list from its backend when it can; without one, `Floating::monitor_size` keeps the monitor size egui reports. -- **Restore.** A restored window opens on its saved monitor when one with that name (or else that - rect) is connected, moved in proportion if the monitor's rect changed. Otherwise it opens on - the main window's monitor at the same offset from that monitor's origin, wholly on screen. - Without a monitor list, it is clamped onto the main window's monitor, taken from - `monitor_size` and the main window's position. Hostile rects are made safe first. +- **Restore.** Only the windows of a loaded layout (a `native` the session has not shown yet) + are restored. A window made this session (a drag out, Undock, a Ctrl/Cmd drop) opens at the + rect it was given, only made safe, wherever that is on the desktop. A restored window opens on + its saved monitor when one with that name (or else that rect) is connected, moved in + proportion if the monitor's rect changed, and moved onto it when too little of its bar lies on + a monitor to grab (40 by 9 points). Otherwise it opens on the main window's monitor at the + same offset from that monitor's origin, wholly on screen. Without a monitor list, it is + clamped onto the main window's monitor, taken from `monitor_size` and the main window's + position. Hostile rects are made safe first. With a monitor list, a window whose `native` the + app changes, or whose bar drag ends with too little of the bar on a monitor, goes onto the + nearest monitor too. +- **Mixed scale factors.** Rects are stored in egui points as the window reports them, and + `Monitor::scale` is kept but not used. On macOS every window shares one point space, so this + is exact. On Windows egui-winit divides each window's physical position by that window's own + scale factor, so with monitors at different scales (say 100 % and 150 %) the points of windows + on different monitors are in different units. Across such monitors, a restored or + app-changed place can land off by the ratio of the scales, and a drop across windows on + different monitors can resolve against the wrong point. Same-scale setups are unaffected. - **Shortcuts.** egui gives each OS window only its own input. Key, text, copy, cut and paste events that reach a native window and that none of its widgets used come back in - `DockOutput::forwarded_events`; the app feeds them to the shortcut handling it runs on the main - window. A window whose text field has focus forwards nothing. + `DockOutput::forwarded_events`, with the modifiers held in that window in + `DockOutput::forwarded_modifiers` (egui-winit sends Cmd/Ctrl+C, X and V as `Copy`, `Cut` and + `Paste` without the key). The app feeds them to the shortcut handling it runs on the main + window. A window where any widget has keyboard focus forwards nothing, and an Escape that + cancels a drag is not forwarded. - **Drags.** Tabs, groups and whole windows drag between the app's OS windows. - *Ownership.* The window where the button went down owns the drag: the window system sends it the pointer's moves and the release while the button is held, inside the window or not. So the pointer leaving a window (`PointerGone`) is not a release. A drag ends on release, - Escape, or when none of the app's windows has focus. - - *Hit-testing.* The owner adds its desktop origin to the pointer (a native window's origin is - the one last commanded while it moves, else the reported `outer_rect`). The window under that + Escape (pressed in any of the app's windows, since keys go to the one with keyboard focus), + when none of the app's windows has focus, or when its window closes or its panel goes. + - *Hit-testing.* The owner adds its desktop origin to the pointer: a native window's reported + `inner_rect.min`, or while it moves by its bar, the place it was last put less the pointer's + offset from the pressed point. The window under that desktop point is a floating native window first, most recently focused first, then the main window. A window moving by its bar is never its own target. A point in no window is on the desktop. @@ -371,11 +397,19 @@ viewport's `inner_rect.min` plus its rect). A window with a panel whose (the window's top-left is the pointer minus the grab offset, less the bar's height). A Ctrl/Cmd-drag floats there wherever it is released. - *Moving a window.* Dragging the bar, the only tab of a window, or the drag area of its only - group moves the window. Released over a target in another window, the whole window docks or - joins there as its groups (`Action::MoveWindow`, with `DockStyle::title_bar_docks`): in the + group moves the window, so the pressed point stays under the pointer. Each move is worked + out from a pointer move of that frame and the window's reported place, which the pointer's + position is relative to; a move commanded earlier may land late. A frame with no pointer + move leaves the window. X11 sends no move whose position in the window repeats the last + one, so while the pointer keeps exact pace with the window the window can trail it by one + move until the next. Released over a target in another window, the whole window docks or + joins there as its groups (`Action::MoveWindow`). The bar docks only with + `DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way + back in with that flag off. Targets: in the main window a dock line, a frame's tab row or edge zone, or the layout's outer edge; in a floating window a frame zone or tab row. A frame body's center takes no whole window. Over - the desktop, or with Ctrl/Cmd held, it stays where it was moved. Escape puts it back. An + the desktop, or with Ctrl/Cmd held, it stays where it was moved, and the layout takes its + place once, at the release. Escape puts it back. An in-window floating window's title bar still splits only groups of other floating windows. - *Wayland.* Where `outer_rect` is `None`, desktop positions are unknown, so drops across windows cannot be resolved. A drag works inside its own window, and a release outside it @@ -429,7 +463,13 @@ It also has `press`, `release`, `click`, `hold`, `key`, `focus`, `hits`, and che previews (`filled`, `outlined`). With `native-windows`, `with_native_host(origin)` stands in for OS windows: the main window's content sits at `origin` on the desktop, and each native window runs its own frame with its own input and desktop rect (`host()`, `window_step`, -`window_click`). +`window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame +between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`), +and report no positions (`no_position`, as on Wayland). + +`crates/ui/examples/docking_native.rs` (`cargo run -p craft-ui --example docking_native +--features native-windows`) shows a dock with two native floating windows and prints every +action and window place, for checking moves on a real window system. craft-ui's own tests in `crates/ui/tests/docking_workflows.rs`: From 46a49971c71ce25abd829844aa1a9a61d5e858dd Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 18:06:20 +0000 Subject: [PATCH 108/160] docking: format --- crates/ui/examples/docking_native.rs | 12 ++++++++++-- crates/ui/src/docking/model.rs | 3 ++- crates/ui/src/docking/native_tests.rs | 2 +- crates/ui/src/docking/render/native.rs | 4 ++-- crates/ui/src/docking/surface.rs | 11 ++++++++++- crates/ui/src/docking/testing.rs | 3 ++- 6 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ui/examples/docking_native.rs b/crates/ui/examples/docking_native.rs index 6a6940ca..2f4e56b9 100644 --- a/crates/ui/examples/docking_native.rs +++ b/crates/ui/examples/docking_native.rs @@ -45,9 +45,17 @@ impl eframe::App for App { fn main() -> eframe::Result { let tabs = |panels: &[u32]| Node::Tabs { panels: panels.to_vec(), active: 0 }; let layout = Layout { - root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[1, 2])), second: Box::new(tabs(&[3, 4])) }), + root: Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[1, 2])), + second: Box::new(tabs(&[3, 4])), + }), floating: vec![Floating::single(vec![5, 6], 0, [300.0, 200.0, 320.0, 260.0]), Floating::single(vec![7], 0, [700.0, 300.0, 260.0, 200.0])], }; - let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_position([0.0, 0.0]), ..Default::default() }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 700.0]).with_position([0.0, 0.0]), + ..Default::default() + }; eframe::run_native("Docking", options, Box::new(|_| Ok(Box::new(App { layout })))) } diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index a5c5c97f..d539dabe 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -542,7 +542,8 @@ pub(super) fn valid_rect(rect: [f32; 4]) -> bool { /// A floating window's saved monitor and monitor size, each where given. fn valid_monitor(monitor: Option<&Monitor>, monitor_size: Option<[f32; 2]>) -> bool { - monitor.is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])) + monitor.is_none_or(|m| valid_rect(m.rect) && m.scale.is_finite() && m.scale > 0.0) + && monitor_size.is_none_or(|[w, h]| valid_rect([0.0, 0.0, w, h])) } pub(super) fn valid_width(width: f32) -> bool { diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 530f46bf..e3add06d 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -884,7 +884,7 @@ fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { let out = d.window_step(id, vec![button(pos2(40.0, 9.0), false)]); assert_eq!(window_move(&out), None, "{:?}", out.actions); - // Another application takes focus: cancelled. + // Another application takes focus: cancelled. let mut d = driver(layout(true)); let id = id_of(&d); d.window_hold(id, pos2(15.0, TAB_ROW), over); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index e632472f..0032352b 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -8,8 +8,8 @@ use std::{fmt::Debug, hash::Hash}; use egui::{ - Context, Event, Id, Key, Modifiers, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, - vec2, + Context, Event, Id, Key, Modifiers, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, + pos2, vec2, }; use super::Renderer; diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 59e23b54..29efa62a 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -251,7 +251,16 @@ pub struct DockOutput

{ impl

Default for DockOutput

{ fn default() -> Self { - Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None, events: Vec::new(), forwarded_events: Vec::new(), forwarded_modifiers: None } + Self { + actions: Vec::new(), + error: None, + focused: None, + group_drag: None, + group_drop: None, + events: Vec::new(), + forwarded_events: Vec::new(), + forwarded_modifiers: None, + } } } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 10508407..71e175dc 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -411,7 +411,8 @@ impl NativeHost { fn infos(&self) -> egui::ViewportIdMap { let info = |id: egui::ViewportId, rect: Rect| egui::ViewportInfo { inner_rect: Some(rect), - outer_rect: (!self.no_position).then(|| if id == egui::ViewportId::ROOT { rect } else { Rect::from_min_max(rect.min - self.frame, rect.max) }), + outer_rect: (!self.no_position) + .then(|| if id == egui::ViewportId::ROOT { rect } else { Rect::from_min_max(rect.min - self.frame, rect.max) }), focused: Some(self.focus == Some(id)), monitor_size: self.monitor_size, ..Default::default() From e6c76f51517d0bff2bd479faf1c64b6e11f5afae Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 23:57:43 +0000 Subject: [PATCH 109/160] docking: drop zones are 40 pt bands below the tab strip, all outlined while dragging A frame's side zones reach DockStyle::zone_depth (40 pt) into the body below the tab strip, or a third of a smaller body, so a frame keeps its center. drop_zone, zone_shape and resolve_drop take the depth. In the Frame style a drag over a frame outlines every zone a drop there would change something in and fills the one under the pointer, without the outline of the resulting rect. --- crates/ui/src/docking/render/preview.rs | 161 +++++++++++++++++------- crates/ui/src/docking/split_tests.rs | 122 +++++++++++++++--- crates/ui/src/docking/surface.rs | 6 + crates/ui/src/docking/targets.rs | 97 ++++++++------ crates/ui/src/docking/tests.rs | 59 ++++++--- crates/ui/src/docking/trial_tests.rs | 2 +- docs/ui-docking.md | 15 ++- 7 files changed, 345 insertions(+), 117 deletions(-) diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 96fc1120..33f234cb 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -82,17 +82,25 @@ impl Renderer<'_, P> { /// moving live looks through itself and does not split frames or join a body's center. pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { let window = drag.gesture == GroupGesture::Window; - let valid = |proposal: DropProposal

| { - let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; - (proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) - }; if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { - return valid(proposal); + return self.valid_group(drag, proposal); } - let own = |t: &crate::docking::DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); - let target = - self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t)))?; - if own(target) { + let target = self.group_target(drag, pointer)?; + self.group_proposal_at(drag, pointer, target) + } + + /// The group under the pointer that a group drag addresses. A window moving live looks + /// through itself. + fn group_target(&self, drag: &GroupDrag

, pointer: Pos2) -> Option<&DropTarget

> { + let window = drag.gesture == GroupGesture::Window; + let own = |t: &DropTarget

| t.tabs.iter().any(|(p, _)| drag.members.contains(p)); + self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer) && !(window && own(t))) + } + + /// [`Self::group_proposal`] on `target` alone. + fn group_proposal_at(&self, drag: &GroupDrag

, pointer: Pos2, target: &DropTarget

) -> Option> { + let window = drag.gesture == GroupGesture::Window; + if target.tabs.iter().any(|(p, _)| drag.members.contains(p)) { return Some(DropProposal { anchor: drag.panel.clone(), placement: Placement::Tab { before: None }, @@ -107,13 +115,19 @@ impl Renderer<'_, P> { if window && !into_window && matches!(proposal.placement, Placement::Split(_)) { return None; } - valid(proposal) + self.valid_group(drag, proposal) + } + + /// `proposal` when it is a no-op or the model takes it for the whole group. + fn valid_group(&self, drag: &GroupDrag

, proposal: DropProposal

) -> Option> { + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + (proposal.noop || self.layout.clone().apply_with_permissions(group_action(drag, to), |p| self.permissions(p)).is_ok()).then_some(proposal) } /// [`resolve_drop`], where a split that would leave a group without room becomes a join /// of the target group. fn resolve_roomy(&self, panel: &P, pointer: Pos2, targets: &[DropTarget

], action: impl Fn(Target

) -> Action

) -> Option> { - let proposal = resolve_drop(panel, pointer, targets)?; + let proposal = resolve_drop(panel, pointer, targets, self.style.zone_depth)?; if !matches!(proposal.placement, Placement::Split(zone) if zone != Zone::Center) { return Some(proposal); } @@ -121,7 +135,7 @@ impl Renderer<'_, P> { return Some(proposal); } let target = targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer))?; - resolve_drop(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() })) + resolve_drop(panel, pointer, std::slice::from_ref(&DropTarget { allow_splits: false, ..target.clone() }), self.style.zone_depth) } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group @@ -216,9 +230,11 @@ impl Renderer<'_, P> { } return; } - if let Some(proposal) = self.group_proposal(drag, pointer).filter(|p| !p.noop) { + let proposal = self.group_proposal(drag, pointer).filter(|p| !p.noop); + let zones = self.paint_group_zones(&painter, drag, pointer, proposal.as_ref()); + if let Some(proposal) = proposal { let split = matches!(proposal.placement, Placement::Split(zone) | Placement::Edge(zone) if zone != Zone::Center); - if crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { + if crate::docking::targets::usable(proposal.preview) && !zones { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, split); } if let Some(marker) = proposal.insertion { @@ -281,17 +297,7 @@ impl Renderer<'_, P> { } return; } - let proposal = if float_only { - None - } else { - self.extra_proposal(std::slice::from_ref(&drag.panel), pointer, false).or_else(|| { - let panel = &drag.panel; - self.resolve_roomy(panel, pointer, &self.targets, |to| match to { - Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, - to => Action::MovePanel { panel: panel.clone(), to }, - }) - }) - }; + let proposal = if float_only { None } else { self.tab_proposal(&drag.panel, pointer) }; if released { cancel_drag::

(ctx, self.id); if pointer.distance(drag.origin) < 2.0 || proposal.as_ref().is_some_and(|proposal| proposal.noop && !outside) { @@ -316,11 +322,12 @@ impl Renderer<'_, P> { return; } let painter = self.preview_painter(ctx); + let zones = !float_only && self.paint_tab_zones(&painter, &drag.panel, pointer, proposal.as_ref()); if let Some(proposal) = proposal { // Reordering inside the source strip shows only the insertion line. let same_group = proposal.insertion.is_some() && self.targets.iter().any(|target| target.rect == proposal.preview && target.tabs.iter().any(|(panel, _)| panel == &drag.panel)); - if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !self.paint_zone(&painter, &proposal, pointer) { + if !same_group && !proposal.noop && crate::docking::targets::usable(proposal.preview) && !zones { drop_preview(&painter, proposal.preview.shrink(1.0), self.style.accent, true); } if let Some(marker) = proposal.insertion { @@ -334,26 +341,92 @@ impl Renderer<'_, P> { } } - /// With [`crate::docking::DockStyle::side_edge_zones`], a drop on a group shows the zone - /// under the pointer (a side's trapezoid, or the center rectangle with the tab strip) - /// filled, and the rect the drop gives what moves outlined. False when it painted nothing. - fn paint_zone(&self, painter: &egui::Painter, proposal: &DropProposal

, pointer: Pos2) -> bool { - let zone = match proposal.placement { - Placement::Split(zone) => zone, - Placement::Tab { .. } => Zone::Center, - _ => return false, + /// The group target a single tab drag would take at `pointer`. + pub(super) fn tab_proposal(&self, panel: &P, pointer: Pos2) -> Option> { + self.extra_proposal(std::slice::from_ref(panel), pointer, false).or_else(|| { + self.resolve_roomy(panel, pointer, &self.targets, |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }) + }) + } + + /// [`Self::paint_zones`] for a tab drag of `panel`. The center of the group holding the panel + /// is not a target: a drop there reorders or changes nothing. + pub(super) fn paint_tab_zones(&self, painter: &egui::Painter, panel: &P, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { + if !self.style.side_edge_zones || self.extra_proposal(std::slice::from_ref(panel), pointer, false).is_some() { + return false; + } + let Some(target) = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer)) else { + return false; }; - let target = self.targets.iter().rev().find(|t| crate::docking::targets::usable(t.rect) && t.rect.contains(pointer)); - let (Some(target), true) = (target, self.style.side_edge_zones) else { return false }; - let Some(points) = crate::docking::targets::zone_shape(target.rect, zone) else { return false }; - let fill = self.style.accent.gamma_multiply(0.25); - painter.add(egui::Shape::convex_polygon(points, fill, egui::Stroke::NONE)); - if zone == Zone::Center - && let Some(strip) = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|r| crate::docking::targets::usable(*r)) - { - painter.rect_filled(strip, 0.0, fill); + let own = target.tabs.iter().any(|(p, _)| p == panel); + let at = |at: Pos2| { + let move_to = |to| match to { + Target::At { anchor, placement } => Action::Move { panel: panel.clone(), anchor, placement }, + to => Action::MovePanel { panel: panel.clone(), to }, + }; + let proposal = self.resolve_roomy(panel, at, std::slice::from_ref(target), move_to)?; + let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; + let joins = matches!(proposal.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)); + (!(own && joins) && self.layout.clone().apply_with_permissions(move_to(to), |p| self.permissions(p)).is_ok()).then_some(proposal) + }; + self.paint_zones(painter, target, proposal, at) + } + + /// [`Self::paint_zones`] for a group drag. + pub(super) fn paint_group_zones(&self, painter: &egui::Painter, drag: &GroupDrag

, pointer: Pos2, proposal: Option<&DropProposal

>) -> bool { + let window = drag.gesture == GroupGesture::Window; + if !self.style.side_edge_zones || self.extra_proposal(&drag.members, pointer, window).is_some() { + return false; + } + let Some(target) = self.group_target(drag, pointer) else { return false }; + self.paint_zones(painter, target, proposal, |at| self.group_proposal_at(drag, at, target)) + } + + /// With [`crate::docking::DockStyle::side_edge_zones`], a drag over a group outlines each + /// of its drop zones that a drop would change something in (a side's trapezoid, the center + /// rectangle, the tab strip as part of the center) and fills the zone of `proposal`, the + /// drop under the pointer. `at` is the drop a point of `target` would get. False when it + /// painted nothing. + fn paint_zones( + &self, + painter: &egui::Painter, + target: &DropTarget

, + proposal: Option<&DropProposal

>, + at: impl Fn(Pos2) -> Option>, + ) -> bool { + use crate::docking::targets::{header_height, usable, zone_shape}; + let zone_of = |proposal: &DropProposal

| match proposal.placement { + _ if proposal.noop => None, + Placement::Split(zone) => Some(zone), + Placement::Tab { .. } => Some(Zone::Center), + _ => None, + }; + let header = header_height(target); + let centroid = |points: &[Pos2]| points.iter().fold(Vec2::ZERO, |sum, p| sum + p.to_vec2()) / points.len() as f32; + let takes = |zone: Zone, point: Pos2| at(point).as_ref().and_then(zone_of) == Some(zone); + let mut shapes: Vec<(Zone, Vec)> = [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom, Zone::Center] + .into_iter() + .filter_map(|zone| Some((zone, zone_shape(target.rect, header, self.style.zone_depth, zone)?))) + .filter(|(zone, points)| takes(*zone, centroid(points).to_pos2())) + .collect(); + let strip = target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip) && takes(Zone::Center, strip.center())); + if let Some(strip) = strip { + shapes.push((Zone::Center, vec![strip.left_top(), strip.right_top(), strip.right_bottom(), strip.left_bottom()])); + } + if shapes.is_empty() { + return false; + } + let hovered = proposal.and_then(zone_of); + let fill = self.style.accent.gamma_multiply(0.3); + for (_, points) in shapes.iter().filter(|(zone, _)| Some(*zone) == hovered) { + painter.add(egui::Shape::convex_polygon(points.clone(), fill, egui::Stroke::NONE)); + } + let stroke = egui::Stroke::new(1.0, self.style.accent.gamma_multiply(0.6)); + for (_, points) in shapes { + painter.add(egui::Shape::closed_line(points, stroke)); } - drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, false); true } diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 69048efd..3c7c1c45 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -23,11 +23,13 @@ const INFO: u32 = 9; const SCREEN: Vec2 = Vec2::new(1440.0, 860.0); -/// Panel bodies by their rects; `caps` are maximum widths a host asks for. +/// Panel bodies by their rects; `caps` are maximum widths a host asks for, `rules` the +/// permissions of panels that differ from the default. #[derive(Default)] struct Film { bodies: HashMap, caps: HashMap, + rules: HashMap, } impl DockContent for Film { @@ -43,6 +45,9 @@ impl DockContent for Film { None => PanelLimits::default(), } } + fn permissions(&self, panel: &u32) -> Permissions { + self.rules.get(panel).copied().unwrap_or_default() + } } type Driver = DockDriver; @@ -117,8 +122,9 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let base = film(); let timeline = group(&base, TIMELINE); let others = [PROJECT[0], TOOLS, METERS, SOURCE[0], PROGRAM, PROPERTIES[0]]; + // Halfway into each 40 pt band of the body below the tab strip. let inset = |zone| { - let (w, h) = (timeline.width() * 0.08, (timeline.height() - 26.0) * 0.08); + let (w, h) = (20.0, 20.0); match zone { Zone::Left => pos2(timeline.left() + w, timeline.center().y + 13.0), Zone::Right => pos2(timeline.right() - w, timeline.center().y + 13.0), @@ -132,8 +138,7 @@ fn a_group_is_a_picture_frame_whose_edges_split_it_in_half_and_whose_middle_join let before = sizes(&d, &others); let at = inset(zone); d.hold(tab(&d, INFO), at); - let half = zone_rect(timeline, zone).unwrap(); - assert!(d.outlined(half.shrink(1.0)), "{zone:?}: the preview outlines that half of the group"); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, zone).unwrap()), "{zone:?}: the preview fills that band"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TIMELINE, placement: Placement::Split(zone) }], "{zone:?}"); d.settle(); @@ -275,7 +280,7 @@ fn a_drop_beside_a_narrow_group_splits_that_group_only() { assert!(tools.width() > 120.0, "{tools:?}"); let at = pos2(tools.right() - 8.0, tools.center().y + 13.0); d.hold(tab(&d, INFO), at); - assert!(d.outlined(zone_rect(tools, Zone::Right).unwrap().shrink(1.0)), "the preview outlines Tools' right half"); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, Zone::Right).unwrap()), "the preview fills Tools' right band"); let out = d.release(at); assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Right) }]); d.settle(); @@ -647,35 +652,57 @@ fn a_closed_floating_window_closes_its_panels_and_each_reopens_where_it_was() { assert_eq!((&window.root, window.rect), (&tree, [700.0, 100.0, 600.0, 400.0])); } +/// The zone outlines of the last frame: closed 1 pt paths without a fill. +fn zone_outlines(d: &Driver) -> Vec> { + d.shapes() + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Path(path) if path.closed && path.fill == egui::Color32::TRANSPARENT && path.stroke.width == 1.0 => { + Some(path.points.clone()) + } + _ => None, + }) + .collect() +} + +fn same_polygon(a: &[Pos2], b: &[Pos2]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.distance(*b) <= 0.5) +} + +fn corners(rect: Rect) -> Vec { + vec![rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()] +} + #[test] -fn the_preview_fills_the_zone_under_the_pointer_and_outlines_the_result() { +fn the_preview_fills_the_zone_under_the_pointer_and_no_result_rect() { let d = film(); let timeline = group(&d, TIMELINE); let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); - // A side band: its trapezoid, and the half the panel takes. + // A side band: its trapezoid, without the half the panel takes. for (zone, at) in [(Zone::Left, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)), (Zone::Bottom, pos2(timeline.center().x, timeline.bottom() - 20.0))] { let mut d = film(); d.hold(tab(&d, INFO), at); - assert!(d.filled_polygon(&zone_shape(timeline, zone).unwrap()), "{zone:?}"); - assert!(d.outlined(zone_rect(timeline, zone).unwrap().shrink(1.0)), "{zone:?}"); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, zone).unwrap()), "{zone:?}"); + assert!(!d.outlined(zone_rect(timeline, zone).unwrap().shrink(1.0)), "{zone:?}"); d.key(egui::Key::Escape); } - // The middle and the tab row: the center rectangle and the strip, and the whole group. + // The middle and the tab row: the center rectangle and the strip. for at in [timeline.center() + vec2(0.0, 13.0), strip.center()] { let mut d = film(); d.hold(tab(&d, INFO), at); - assert!(d.filled_polygon(&zone_shape(timeline, Zone::Center).unwrap()), "{at:?}"); - assert!(d.filled(strip), "{at:?}"); - assert!(d.outlined(timeline.shrink(1.0)), "{at:?}"); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Center).unwrap()), "{at:?}"); + assert!(d.filled_polygon(&corners(strip)), "{at:?}"); + assert!(!d.outlined(timeline.shrink(1.0)), "{at:?}"); d.key(egui::Key::Escape); } // A group's drag bar shows the same. let mut d = film(); let bar = d.hit(&Hit::DragBar(PROJECT[0])).unwrap().center(); d.hold(bar, pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); - assert!(d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + assert!(d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); + assert_eq!(zone_outlines(&d).len(), 6); d.key(egui::Key::Escape); // The dock-and-canvas arrangement keeps its filled half. @@ -684,6 +711,71 @@ fn the_preview_fills_the_zone_under_the_pointer_and_outlines_the_result() { d.area = d.screen; d.settle(); d.hold(tab(&d, INFO), pos2(timeline.left() + 8.0, timeline.center().y + 13.0)); - assert!(!d.filled_polygon(&zone_shape(timeline, Zone::Left).unwrap())); + assert!(!d.filled_polygon(&zone_shape(timeline, 26.0, 40.0, Zone::Left).unwrap())); + assert!(zone_outlines(&d).is_empty()); assert!(d.filled(zone_rect(timeline, Zone::Left).unwrap().shrink(1.0))); } + +#[test] +fn a_drag_over_a_group_outlines_all_its_zones_and_fills_the_one_drop_zone_names() { + let d = film(); + let timeline = group(&d, TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + let shape = |zone| zone_shape(timeline, 26.0, 40.0, zone).unwrap(); + let all: Vec> = + [Zone::Left, Zone::Right, Zone::Top, Zone::Bottom, Zone::Center].map(shape).into_iter().chain([corners(strip)]).collect(); + let body = Rect::from_min_max(pos2(timeline.left(), strip.bottom()), timeline.max); + // Each band, near its corners too, the middle, and the tab row. + let points = [ + pos2(body.left() + 30.0, body.center().y), + pos2(body.left() + 30.0, body.top() + 34.0), + pos2(body.right() - 2.0, body.bottom() - 12.0), + pos2(body.center().x, body.top() + 5.0), + pos2(body.left() + 50.0, body.bottom() - 30.0), + body.center(), + pos2(body.left() + 45.0, body.top() + 45.0), + strip.center(), + ]; + for at in points { + let mut d = film(); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), all.len(), "{at:?}: four sides, the center and the strip"); + assert!(all.iter().all(|zone| outlines.iter().any(|o| same_polygon(o, zone))), "{at:?}: only Timeline's zones"); + let zone = drop_zone(timeline, 26.0, 40.0, at).unwrap(); + assert!(d.filled_polygon(&shape(zone)), "{at:?}: {zone:?} is filled"); + let filled = all.iter().filter(|z| d.filled_polygon(z)).count(); + assert_eq!(filled, if zone == Zone::Center { 2 } else { 1 }, "{at:?}: only {zone:?}"); + d.key(egui::Key::Escape); + } +} + +#[test] +fn zones_a_group_does_not_take_are_not_drawn() { + let timeline = group(&film(), TIMELINE); + let strip = Rect::from_min_size(timeline.min, vec2(timeline.width(), 26.0)); + let shape = |zone| zone_shape(timeline, 26.0, 40.0, zone).unwrap(); + let at = pos2(timeline.left() + 8.0, timeline.center().y + 13.0); + let content = |rules: Permissions| Film { rules: HashMap::from([(TIMELINE, rules)]), ..Film::default() }; + // No splits: the center and the strip only, and the left band under the pointer neither + // outlined nor filled. + let mut d = driver(editing(), content(Permissions { accepts_splits: false, ..Permissions::default() })); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), 2, "{outlines:?}"); + assert!(outlines.iter().any(|o| same_polygon(o, &shape(Zone::Center))) && outlines.iter().any(|o| same_polygon(o, &corners(strip)))); + assert!(!d.filled_polygon(&shape(Zone::Left))); + d.key(egui::Key::Escape); + // No tabs: the four sides only. + let mut d = driver(editing(), content(Permissions { accepts_tabs: false, ..Permissions::default() })); + d.hold(tab(&d, INFO), at); + let outlines = zone_outlines(&d); + assert_eq!(outlines.len(), 4, "{outlines:?}"); + assert!([Zone::Left, Zone::Right, Zone::Top, Zone::Bottom].into_iter().all(|z| outlines.iter().any(|o| same_polygon(o, &shape(z))))); + assert!(d.filled_polygon(&shape(Zone::Left))); + d.key(egui::Key::Escape); + // A tab over its own single-tab group: nothing to drop on. + let mut d = film(); + d.hold(tab(&d, TIMELINE), timeline.center() + vec2(0.0, 13.0)); + assert!(zone_outlines(&d).is_empty()); +} diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 2bfdb4db..1deff020 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -51,6 +51,10 @@ pub struct DockStyle { /// The outer edge band also runs along the left and right edges, where a drop adds a /// full-height column. Off: only the top and bottom edges add a full-width row. pub side_edge_zones: bool, + /// How far a group's side drop zones reach into its body, below the tab strip. A body less + /// than three times as wide or tall gets zones a third of its extent deep in that axis, so + /// it keeps a center. + pub zone_depth: f32, /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new /// dock (see [`Self::new_dock_band`]). pub new_dock_zones: bool, @@ -163,6 +167,7 @@ impl DockStyle { title_height: super::chrome::TITLE_HEIGHT, edge_band: 8.0, side_edge_zones: false, + zone_depth: 40.0, new_dock_zones: true, column_bar_height: 14.0, seam_band: 8.0, @@ -209,6 +214,7 @@ impl DockStyle { self.floating_stroke.width, self.title_height, self.edge_band, + self.zone_depth, self.column_bar_height, self.seam_band, self.new_dock_band, diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 7a816f1a..89e235e0 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -31,55 +31,80 @@ pub(super) fn usable(rect: Rect) -> bool { rect.is_finite() && rect.width() > 0.0 && rect.height() > 0.0 && rect.width() <= 1_000_000.0 && rect.height() <= 1_000_000.0 } -/// Least depth of a side zone, so a narrow frame such as a tool strip keeps side targets wide -/// enough to hit. -const MIN_SIDE_ZONE: f32 = 32.0; - -/// Tab header is the center; otherwise the nearest side zone wins. A side zone is a quarter of -/// the frame deep, at least 32 pt and at most half the frame, so a frame up to 64 pt wide has no -/// center. Outside, non-finite, empty and inverted rectangles have no target. -pub fn drop_zone(rect: Rect, tab_height: f32, pointer: Pos2) -> Option { +/// Tab header (the top `tab_height` of `rect`) is the center; otherwise the nearest side zone of +/// the body below it wins. A side zone reaches `depth` into the body, or a third of the body's +/// extent in that axis when that is less, so a body always keeps a center; a `depth` of zero +/// or less leaves only the center. Outside, non-finite, empty and inverted rectangles have no +/// target. +pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; } - if pointer.y < rect.top() + tab_height.min(rect.height()) { + let body = body(rect, tab_height); + if pointer.y < body.top() || !usable(body) { return Some(Zone::Center); } - let Vec2 { x, y } = side_depths(rect); - let distances = [ - ((pointer.x - rect.left()) / x, Zone::Left), - ((rect.right() - pointer.x) / x, Zone::Right), - ((pointer.y - rect.top()) / y, Zone::Top), - ((rect.bottom() - pointer.y) / y, Zone::Bottom), + let Vec2 { x, y } = side_depths(body, depth); + let sides = [ + (pointer.x - body.left(), x, Zone::Left), + (body.right() - pointer.x, x, Zone::Right), + (pointer.y - body.top(), y, Zone::Top), + (body.bottom() - pointer.y, y, Zone::Bottom), ]; - let (distance, zone) = distances.into_iter().min_by(|a, b| a.0.total_cmp(&b.0))?; - Some(if distance > 1.0 { Zone::Center } else { zone }) + let nearest = + sides.into_iter().filter(|(_, depth, _)| *depth > 0.0).map(|(gap, depth, zone)| (gap / depth, zone)).min_by(|a, b| a.0.total_cmp(&b.0)); + Some(match nearest { + Some((distance, zone)) if distance <= 1.0 => zone, + _ => Zone::Center, + }) } -/// How deep the side zones of a group drawn at `rect` reach in from its left and right edges -/// (`x`) and its top and bottom edges (`y`). -fn side_depths(rect: Rect) -> Vec2 { - let depth = |extent: f32| (extent * 0.25).max(MIN_SIDE_ZONE).min(extent * 0.5); - vec2(depth(rect.width()), depth(rect.height())) +/// The part of a group drawn at `rect` below its tab header. +fn body(rect: Rect, tab_height: f32) -> Rect { + Rect::from_min_max(pos2(rect.left(), rect.top() + tab_height.min(rect.height())), rect.max) } -/// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it: a side's -/// trapezoid (its outer edge the whole side, its inner edge on the center rectangle), or the -/// center rectangle. The tab strip is a grouping zone too. -pub fn zone_shape(rect: Rect, zone: Zone) -> Option> { - if !usable(rect) { +/// How deep the side zones of `body` reach in from its left and right edges (`x`) and its top +/// and bottom edges (`y`). +fn side_depths(body: Rect, depth: f32) -> Vec2 { + let depth = if depth > 0.0 { depth } else { 0.0 }; + vec2(depth.min(body.width() / 3.0), depth.min(body.height() / 3.0)) +} + +/// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it with the +/// same `tab_height` and `depth`: a side's trapezoid (its outer edge the whole side of the body, +/// its inner edge on the center rectangle), or the center rectangle. The tab header, a grouping +/// zone too, is not part of any shape. None for a side without depth. +pub fn zone_shape(rect: Rect, tab_height: f32, depth: f32, zone: Zone) -> Option> { + if !usable(rect) || !tab_height.is_finite() || tab_height < 0.0 { return None; } - let inner = rect.shrink2(side_depths(rect)); + let body = body(rect, tab_height); + let depths = side_depths(body, depth); + let flat = match zone { + Zone::Left | Zone::Right => depths.x <= 0.0, + Zone::Top | Zone::Bottom => depths.y <= 0.0, + Zone::Center => false, + }; + if !usable(body) || flat { + return None; + } + let inner = body.shrink2(depths); Some(match zone { - Zone::Left => vec![rect.left_top(), inner.left_top(), inner.left_bottom(), rect.left_bottom()], - Zone::Right => vec![inner.right_top(), rect.right_top(), rect.right_bottom(), inner.right_bottom()], - Zone::Top => vec![rect.left_top(), rect.right_top(), inner.right_top(), inner.left_top()], - Zone::Bottom => vec![inner.left_bottom(), inner.right_bottom(), rect.right_bottom(), rect.left_bottom()], + Zone::Left => vec![body.left_top(), inner.left_top(), inner.left_bottom(), body.left_bottom()], + Zone::Right => vec![inner.right_top(), body.right_top(), body.right_bottom(), inner.right_bottom()], + Zone::Top => vec![body.left_top(), body.right_top(), inner.right_top(), inner.left_top()], + Zone::Bottom => vec![inner.left_bottom(), inner.right_bottom(), body.right_bottom(), body.left_bottom()], Zone::Center => vec![inner.left_top(), inner.right_top(), inner.right_bottom(), inner.left_bottom()], }) } +/// Height of the tab header of `target` that [`resolve_drop`] treats as a grouping zone: from the +/// top of its rect to the bottom of its tab strip. +pub(super) fn header_height

(target: &DropTarget

) -> f32 { + target.tab_strip.map(|strip| strip.intersect(target.rect)).filter(|strip| usable(*strip)).map_or(0.0, |strip| strip.bottom() - target.rect.top()) +} + /// Preview of the region occupied after a split or group merge. pub fn zone_rect(rect: Rect, zone: Zone) -> Option { if !usable(rect) { @@ -126,8 +151,10 @@ pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) - /// Resolve a pointer against visible groups. The frontmost containing group occludes groups /// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source -/// group is a no-op at center. Floating groups normally set `allow_splits` to false. -pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

]) -> Option> { +/// group is a no-op at center. The body's side zones are [`drop_zone`]'s with `zone_depth` (see +/// [`crate::docking::DockStyle::zone_depth`]). Floating groups normally set `allow_splits` to +/// false. +pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

], zone_depth: f32) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; } @@ -165,7 +192,7 @@ pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarg noop, }); } - let zone = if target.allow_splits { drop_zone(target.rect, 0.0, pointer)? } else { Zone::Center }; + let zone = if target.allow_splits { drop_zone(target.rect, header_height(target), zone_depth, pointer)? } else { Zone::Center }; if zone == Zone::Center && target.accepts_tabs && target.tabs.iter().any(|(p, _)| p == panel) { return Some(DropProposal { anchor: anchor.clone(), diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 2db1da78..3f21199a 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -287,19 +287,46 @@ fn target(anchor: u32, members: &[u32], rect: Rect) -> DropTarget { fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(300.0, 200.0)); let group = target(2, &[2, 3], rect); - let placement = resolve_drop(&5, pos2(90.0, 30.0), std::slice::from_ref(&group)).unwrap(); + let placement = resolve_drop(&5, pos2(90.0, 30.0), std::slice::from_ref(&group), 40.0).unwrap(); assert_eq!(placement.placement, Placement::Tab { before: Some(3) }); assert!(placement.insertion.unwrap().is_finite()); - assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Center)); - assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group)).unwrap().placement, Placement::Split(Zone::Left)); - assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group)).unwrap().noop); - assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group)).is_some()); + assert_eq!(resolve_drop(&5, rect.center(), std::slice::from_ref(&group), 40.0).unwrap().placement, Placement::Split(Zone::Center)); + assert_eq!(resolve_drop(&5, pos2(11.0, 90.0), std::slice::from_ref(&group), 40.0).unwrap().placement, Placement::Split(Zone::Left)); + assert!(resolve_drop(&3, rect.center(), std::slice::from_ref(&group), 40.0).unwrap().noop); + assert!(resolve_drop(&3, pos2(11.0, 90.0), std::slice::from_ref(&group), 40.0).is_some()); let mut overlay = target(5, &[5], rect); overlay.accepts_tabs = false; overlay.allow_splits = false; - assert!(resolve_drop(&3, rect.center(), &[group, overlay]).is_none()); - assert!(drop_zone(Rect::NOTHING, 24.0, pos2(0.0, 0.0)).is_none()); - assert!(drop_zone(rect, 24.0, pos2(f32::NAN, 0.0)).is_none()); + assert!(resolve_drop(&3, rect.center(), &[group, overlay], 40.0).is_none()); + assert!(drop_zone(Rect::NOTHING, 24.0, 40.0, pos2(0.0, 0.0)).is_none()); + assert!(drop_zone(rect, 24.0, 40.0, pos2(f32::NAN, 0.0)).is_none()); +} + +#[test] +fn side_zones_are_40_pt_deep_or_a_third_of_a_small_body() { + let depth = DockStyle::default().zone_depth; + assert_eq!(depth, 40.0); + // The inner corner of the left trapezoid is the depth of both bands. + let inner = |rect: Rect| zone_shape(rect, 0.0, depth, Zone::Left).unwrap()[1] - rect.min; + for size in [vec2(600.0, 400.0), vec2(130.0, 130.0)] { + let rect = Rect::from_min_size(pos2(10.0, 20.0), size); + assert_eq!(inner(rect), vec2(40.0, 40.0), "{size:?}"); + let y = rect.center().y; + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 39.0, y)), Some(Zone::Left), "{size:?}"); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 41.0, y)), Some(Zone::Center), "{size:?}"); + } + // A 90 pt body keeps a 30 pt center. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(90.0, 90.0)); + assert_eq!(inner(rect), vec2(30.0, 30.0)); + assert_eq!(drop_zone(rect, 0.0, depth, rect.center()), Some(Zone::Center)); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 29.0, rect.center().y)), Some(Zone::Left)); + // The bands start below the tab header. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(600.0, 400.0)); + assert_eq!(drop_zone(rect, 24.0, depth, pos2(rect.center().x, rect.top() + 60.0)), Some(Zone::Top)); + assert_eq!(zone_shape(rect, 24.0, depth, Zone::Top).unwrap()[0], rect.left_top() + vec2(0.0, 24.0)); + // Without depth there are no sides. + assert_eq!(drop_zone(rect, 0.0, 0.0, pos2(rect.left() + 1.0, rect.center().y)), Some(Zone::Center)); + assert!(zone_shape(rect, 0.0, 0.0, Zone::Left).is_none()); } #[test] @@ -1520,7 +1547,7 @@ fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() { accepts_tabs: true, right_to_left: false, }; - let proposal = resolve_drop(&1, rect.center(), &[target]).unwrap(); + let proposal = resolve_drop(&1, rect.center(), &[target], 40.0).unwrap(); assert!(proposal.noop); assert!(proposal.insertion.is_none()); } @@ -1583,13 +1610,13 @@ fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() { }; for (panel, positions) in [(1, [10.0, 50.0]), (2, [70.0, 110.0]), (3, [130.0, 210.0])] { for x in positions { - let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target)).unwrap(); + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target), 40.0).unwrap(); assert!(proposal.noop, "{panel}/{x}"); assert!(target.tab_strip.unwrap().contains_rect(proposal.insertion.unwrap())); } } for (panel, x) in [(1, 140.0), (3, 10.0), (2, 210.0)] { - let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target)).unwrap(); + let proposal = resolve_drop(&panel, pos2(x, 12.0), std::slice::from_ref(&target), 40.0).unwrap(); assert!(!proposal.noop); assert!(proposal.insertion.is_some()); } @@ -1663,10 +1690,10 @@ fn overflow_noop_detection_uses_logical_order_including_hidden_tabs() { accepts_tabs: true, right_to_left: false, }; - assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); + assert!(resolve_drop(&1, pos2(10.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); // Before the next visible tab crosses hidden tab 2 and really changes the order. - assert!(!resolve_drop(&1, pos2(65.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); - assert!(resolve_drop(&3, pos2(130.0, 12.0), std::slice::from_ref(&target)).unwrap().noop); + assert!(!resolve_drop(&1, pos2(65.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); + assert!(resolve_drop(&3, pos2(130.0, 12.0), std::slice::from_ref(&target), 40.0).unwrap().noop); } #[test] @@ -1685,7 +1712,7 @@ fn insertion_markers_stay_inside_clipped_and_singleton_strips() { right_to_left: false, }; for x in [rect.left(), rect.center().x, rect.right()] { - let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target)).unwrap(); + let proposal = resolve_drop(&1, pos2(x, 25.0), std::slice::from_ref(&target), 40.0).unwrap(); assert!(proposal.noop); let marker = proposal.insertion.unwrap(); assert!(rect.intersect(target.tab_strip.unwrap()).contains_rect(marker)); @@ -1700,7 +1727,7 @@ fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() { let rect = Rect::from_min_max(pos2(-0.001, 0.0), pos2(0.002, 30.0)); let target = DropTarget { anchor: 1, rect, tab_strip: Some(rect), tabs: vec![(1, rect)], allow_splits: true, accepts_tabs: true, right_to_left: false }; - let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target]).unwrap(); + let proposal = resolve_drop(&1, pos2(0.0, 12.0), &[target], 40.0).unwrap(); assert!(proposal.noop); assert!(rect.contains_rect(proposal.insertion.unwrap())); } diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index d16cf98d..c6770640 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -260,7 +260,7 @@ fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() { accepts_tabs: true, right_to_left: true, }; - let drop = |x: f32| resolve_drop(&9, pos2(x, 10.0), std::slice::from_ref(&target)).unwrap(); + let drop = |x: f32| resolve_drop(&9, pos2(x, 10.0), std::slice::from_ref(&target), 40.0).unwrap(); assert_eq!((drop(90.0).placement, drop(90.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(1) }, 99.0)); assert_eq!((drop(70.0).placement, drop(70.0).insertion.unwrap().center().x), (Placement::Tab { before: Some(2) }, 60.0)); assert_eq!((drop(10.0).placement, drop(10.0).insertion.unwrap().center().x), (Placement::Tab { before: None }, 20.0)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 177c1c2d..fb6d0dee 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -210,8 +210,9 @@ A flyout fits its content the same way, without the title bar. the title bar's buttons. A resize keeps the opposite edges in place and stays within the size limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. - Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The - side zones are trapezoids along the four edges, like a picture frame: a quarter of the frame - deep, at least 32 pt and at most half the frame. A split that would leave either half without + side zones are trapezoids along the four edges of the body below the tab strip, like a picture + frame: `DockStyle::zone_depth` (40 pt) deep, or a third of the body's width or height when that + is less, so every frame keeps a center. A split that would leave either half without `DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target. - The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of @@ -225,10 +226,12 @@ A flyout fits its content the same way, without the title bar. for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it joins. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, - a filled half for a split. With `side_edge_zones`, a drop on a group instead fills the zone - under the pointer (a side's trapezoid, or the center rectangle and the tab strip; `zone_shape` - gives the outline) and outlines the rect the drop gives what moves. Escape or losing window - focus cancels a drag. + a filled half for a split. With `side_edge_zones`, a drag over a group instead outlines each of + its zones that a drop would change something in (the side trapezoids, the center rectangle and + the tab strip; `zone_shape` gives the outlines) and fills the zone under the pointer. Sides the + group does not take, by its permissions or because a split leaves no room, are not drawn; nor is + the center of the group a tab comes from. Escape or losing window focus cancels + a drag. - With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such as a panel dropped on a protected canvas. Without it, that release cancels the drag and nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release From 1546cef8d93086dbdbeaceba3cc234e538b25abb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 00:33:17 +0000 Subject: [PATCH 110/160] docking: Escape in a native window that doesn't own a drag cancels it, whichever window owns it --- crates/ui/src/docking/native_tests.rs | 17 +++++++++++++++++ crates/ui/src/docking/render/cross.rs | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 50b13455..04b478cf 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -923,6 +923,23 @@ fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { assert!(!super::is_dragging::(&d.ctx, d.id)); } +#[test] +fn escape_in_a_focused_native_window_cancels_a_main_window_drag() { + let mut d = driver(two_windows()); + let id = window(&d, 7).id; + let viewport = d.viewport(id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.host().focus = Some(viewport); + let out = d.window_step(id, vec![escape()]); + assert!(out.forwarded_events.is_empty(), "the Escape is no shortcut: {:?}", out.forwarded_events); + d.step(vec![Event::PointerMoved(body)]); + let out = d.release(body); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MovePanel { .. } | Action::Float { .. })), "{:?}", out.actions); + assert!(!super::is_dragging::(&d.ctx, d.id)); +} + #[test] fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { let mut d = driver(two_windows()); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 9435ee52..3a17f307 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -147,11 +147,11 @@ impl Renderer<'_, P> { pointer } - /// In a window that does not own the drag a native window owns (`window`, `None` for the - /// main window): an Escape pressed here cancels the drag, and is no shortcut. + /// In a window that does not own the drag (`window`, `None` for the main window): an Escape + /// pressed here cancels the drag, and is no shortcut. pub(super) fn escape_elsewhere(&self, ctx: &Context, window: Option) { let owner = ctx.data(|d| d.get_temp::(self.id.with(POINTER))).filter(|p| p.down).map(|p| p.owner); - if owner.is_some_and(|owner| owner.is_some() && owner != window) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { + if owner.is_some_and(|owner| owner != window) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { ctx.data_mut(|d| d.insert_temp(self.id.with(ESCAPE), true)); } } From b892ec92e2b9b7cbeb7a1b24ce521b6d327d9a10 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:05:03 +0000 Subject: [PATCH 111/160] docking: native window bar buttons win over the resize bands, corners are 4 pt Ls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bands along a native window's edges leave out the bar's buttons, which sit 4 pt in from the window's edge. Each corner is an L, 4 pt deep and 12 pt along both edges. macOS windows are not resizable by AppKit, whose resize areas reach over the ×; the bands resize them by InnerSize and OuterPosition, since winit has no BeginResize there. --- crates/ui/src/docking/native_tests.rs | 90 ++++++++- crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 248 ++++++++++++++++++++----- docs/ui-docking.md | 15 +- 4 files changed, 304 insertions(+), 51 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 04b478cf..e383e1f1 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1,9 +1,9 @@ //! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's //! stand-in for OS windows. -use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{DragPointer, WindowDrag, floats_shown}; +use super::render::{DragPointer, WindowDrag, floats_shown, resize_bands, resized}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -998,3 +998,89 @@ fn a_drag_ends_when_its_native_window_or_panel_goes() { let out = d.drag(tab, pos2(600.0, 300.0)); assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 3 | 4, .. }]), "{:?}", out.actions); } + +/// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop +/// and release. Returns the window's OS rect and the driver. +fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver) { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2(); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + // The pointer is where it was moved on the desktop, wherever the window is now. + let local = |d: &DockDriver, step: Vec2| d.window_local(id, press + step).unwrap_or_default(); + for step in [vec2(3.0, 3.0), by] { + let pos = local(&d, step); + d.window_step(id, vec![Event::PointerMoved(pos)]); + } + let pos = local(&d, by); + d.window_step(id, vec![Event::PointerMoved(pos), button(pos, false)]); + d.step(vec![]); + (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), d) +} + +#[test] +fn the_bars_close_button_wins_over_the_resize_bands() { + let mut d = driver(layout(true)); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + // The × of the 240 pt wide window spans 217..235 across and the whole bar down. Its top + // right is next to the window's top-right corner, but it closes the window. + let out = d.window_click(id, pos2(234.0, 1.0)); + assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); + assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::InnerSize(_))), "not resized"); + + // Beside it, at the window's edges, the bands resize the window from (400, 130), 240 by + // 300, keeping the opposite edges. + let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let (resized, d) = resize_by(pos2(238.0, 2.0), vec2(20.0, 20.0)); + assert_eq!(resized, rect(400.0, 150.0, 660.0, 430.0), "the top-right corner"); + assert_eq!(window(&d, 5).native, Some([400.0, 150.0, 260.0, 280.0]), "the layout follows"); + assert_eq!(resize_by(pos2(238.0, 15.0), vec2(20.0, 20.0)).0, rect(400.0, 130.0, 660.0, 430.0), "the right edge beside the ×"); + assert_eq!(resize_by(pos2(120.0, 1.0), vec2(20.0, -20.0)).0, rect(400.0, 110.0, 640.0, 430.0), "the top edge above the bar"); + assert_eq!(resize_by(pos2(2.0, 298.0), vec2(-20.0, 20.0)).0, rect(380.0, 130.0, 640.0, 450.0), "the bottom-left corner"); + assert_eq!(resize_by(pos2(2.0, 150.0), vec2(200.0, 0.0)).0, rect(480.0, 130.0, 640.0, 430.0), "no narrower than 160"); + // Inside a corner's L, the bar moves the window. + let (moved, _) = resize_by(pos2(10.0, 10.0), vec2(20.0, 20.0)); + assert_eq!(moved, rect(420.0, 150.0, 660.0, 450.0)); +} + +#[test] +fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let close = Rect::from_center_size(pos2(226.0, 9.0), vec2(18.0, 18.0)); + let bands = resize_bands(rect, &[close]); + let at = |p: Pos2| bands.iter().find(|(_, band)| band.contains(p)).map(|(direction, _)| *direction); + for (_, band) in &bands { + assert!(!band.intersect(close).is_positive(), "{band:?} covers the ×"); + assert!(band.width().min(band.height()) <= 4.0, "{band:?} is deeper than an edge band"); + } + // Each edge resizes along its whole length, outside the ×, from 12 pt corners. + use egui::ResizeDirection as D; + // Which of an edge's three parts `v` is in: the first corner, the edge, the last corner. + let part = |v: f32, length: f32| usize::from(v >= 12.0) + usize::from(v > length - 12.0); + for i in 0..240 { + let x = i as f32 + 0.5; + let top = [D::NorthWest, D::North, D::NorthEast][part(x, 240.0)]; + assert_eq!(at(pos2(x, 0.5)), (!close.contains(pos2(x, 0.5))).then_some(top), "top at {x}"); + assert_eq!(at(pos2(x, 299.5)), Some([D::SouthWest, D::South, D::SouthEast][part(x, 240.0)]), "bottom at {x}"); + } + for i in 0..300 { + let y = i as f32 + 0.5; + assert_eq!(at(pos2(0.5, y)), Some([D::NorthWest, D::West, D::SouthWest][part(y, 300.0)]), "left at {y}"); + assert_eq!(at(pos2(239.5, y)), Some([D::NorthEast, D::East, D::SouthEast][part(y, 300.0)]), "right at {y}"); + } + assert_eq!(at(pos2(6.0, 6.0)), None, "a corner is an L along its edges"); + assert_eq!(at(pos2(120.0, 4.5)), None, "the bar below the top band"); +} + +#[test] +fn a_manual_resize_keeps_the_opposite_edges_and_the_minimum_size() { + use egui::ResizeDirection as D; + let (start, min) = ([100.0, 50.0, 300.0, 200.0], [160.0, 100.0]); + // Right edge 400, bottom 250. + assert_eq!(resized(start, D::NorthWest, vec2(-20.0, -10.0), min), [80.0, 40.0, 320.0, 210.0]); + assert_eq!(resized(start, D::East, vec2(30.0, 99.0), min), [100.0, 50.0, 330.0, 200.0]); + assert_eq!(resized(start, D::South, vec2(30.0, -20.0), min), [100.0, 50.0, 300.0, 180.0]); + assert_eq!(resized(start, D::SouthWest, vec2(500.0, -500.0), min), [240.0, 50.0, 160.0, 100.0]); + assert_eq!(resized(start, D::NorthEast, vec2(-500.0, 500.0), min), [100.0, 150.0, 160.0, 100.0]); +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 823e3086..5cb2538e 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -15,7 +15,7 @@ mod iconic; #[cfg(feature = "native-windows")] mod native; #[cfg(all(test, feature = "native-windows"))] -pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown}; +pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown, native::resize_bands, native::resized}; /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 0032352b..70ce6a0e 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -8,8 +8,8 @@ use std::{fmt::Debug, hash::Hash}; use egui::{ - Context, Event, Id, Key, Modifiers, Pos2, Rect, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, - pos2, vec2, + Context, CursorIcon, Event, Id, Key, Modifiers, Pos2, Rect, ResizeDirection, Sense, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, + ViewportId, ViewportInfo, WindowLevel, pos2, vec2, }; use super::Renderer; @@ -26,6 +26,14 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0]; const HIDE_DELAY: f64 = 0.25; /// How long reports of a native window's place may lag a move the dock area commanded. const MOVE_WAIT: f64 = 0.5; +/// How far the resize bands reach into a native window from its edges. +const EDGE: f32 = 4.0; +/// How far a corner's grab runs along each of its two edges. +const CORNER: f32 = 12.0; +/// The window system runs a resize from our bands (`BeginResize`). winit cannot on macOS, so +/// there the window follows the pointer by `InnerSize` and `OuterPosition`. Unit tests take +/// that path on every platform, since the test host carries it out. +const SYSTEM_RESIZE: bool = !cfg!(any(target_os = "macos", test)); /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -43,6 +51,21 @@ pub(in crate::docking) struct WindowDrag { pub docks: bool, } +/// A resize of a native window from one of its bands where the window system does not run it +/// ([`SYSTEM_RESIZE`]), in the context's data under the area's `"window-resize"`. +#[derive(Clone, Copy, Debug)] +struct WindowResize { + /// [`Floating::id`] of the resized window. + window: u64, + direction: ResizeDirection, + /// Desktop rect of the window's content when the drag began. + start: [f32; 4], + /// Desktop point where the pointer pressed. + press: Pos2, + /// Desktop rect of the window's content as last commanded. + to: [f32; 4], +} + /// What the dock area knows of a shown native window's place, in the context's data under /// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content. #[derive(Clone, Copy, Debug)] @@ -138,11 +161,15 @@ impl Renderer<'_, P> { } if native { self.escape_elsewhere(ctx, None); - // A window closed or docked during its own bar drag ends the drag. - let key = self.id.with("window-drag"); - if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { + // A window closed or docked during its own bar drag or resize ends it. + let shown = |window: u64| natives.iter().any(|(w, ..)| w.id == window); + let (key, resize_key) = (self.id.with("window-drag"), self.id.with("window-resize")); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !shown(d.window)) { ctx.data_mut(|d| d.remove::(key)); } + if ctx.data(|d| d.get_temp::(resize_key)).is_some_and(|r| !shown(r.window)) { + ctx.data_mut(|d| d.remove::(resize_key)); + } self.map_desktop(ctx, &natives, visibility.0); // The main window owns drags of the panels it draws. let id = self.id; @@ -238,8 +265,7 @@ impl Renderer<'_, P> { && let Some(native) = window.native.filter(|native| differs(*native, place.known)) { let target = kept_reachable(native, &self.monitors); - ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(target[0], target[1]) - frame)); - ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(vec2(target[2], target[3]))); + place_window(ctx, viewport, target, frame); (place.known, place.pending) = (target, Some(now + MOVE_WAIT)); } place @@ -269,9 +295,16 @@ impl Renderer<'_, P> { .with_min_inner_size(MIN_SIZE) .with_window_level(WindowLevel::AlwaysOnTop) .with_taskbar(false); - // The bar replaces the OS title bar. macOS keeps its frame for edge resizing. + // The bar replaces the OS title bar. macOS keeps the frame for its shadow and rounded + // corners, but not its resizing: AppKit's resize areas reach into the content over the + // bar's × and take the pointer from it. The bands resize the window instead. #[cfg(target_os = "macos")] - let builder = builder.with_titlebar_shown(false).with_title_shown(false).with_fullsize_content_view(true).with_titlebar_buttons_shown(false); + let builder = builder + .with_titlebar_shown(false) + .with_title_shown(false) + .with_fullsize_content_view(true) + .with_titlebar_buttons_shown(false) + .with_resizable(false); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); if toggled || (info.is_none() && !visible) { @@ -284,18 +317,18 @@ impl Renderer<'_, P> { let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, self.style.floating_background); - self.native_bar(ui, window); + let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); self.floating_body(ui, window, body, full); - #[cfg(not(target_os = "macos"))] - if window.panels().all(|p| self.permissions(p).movable) { - resize_edges(ui, self.id.with(("native-edges", window.id)), full); - } - // An embedded fallback reads the main window's input: its close request, place and - // keys are not this window's. + // An embedded fallback reads the main window's input: its close request, place, + // keys and resizes are not this window's. if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { return Report::default(); } + if window.panels().all(|p| self.permissions(p).movable) { + self.resize_edges(ui, window.id, full, &buttons); + self.follow_window_resize(ui, window.id); + } let mut surface = Surface { window: window.id, origin: None, @@ -371,10 +404,16 @@ impl Renderer<'_, P> { } /// The bar at the top of a native window: its title and ×. Dragging it moves the window. - fn native_bar(&mut self, ui: &mut Ui, window: &Floating

) { - let Some(first) = window.panels().next().cloned() else { return }; + /// Returns the rects of its buttons. + fn native_bar(&mut self, ui: &mut Ui, window: &Floating

) -> Vec { + let Some(first) = window.panels().next().cloned() else { return Vec::new() }; let full = ui.max_rect(); + let style = ChromeStyle::from_dock(self.style); let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); + ui.painter().rect_filled(rect, 0.0, style.title_background); + // The buttons keep clear of the side's resize band, so the corner's grab runs down + // beside them. + let rect = if style.right_to_left { rect.with_min_x(rect.left() + EDGE) } else { rect.with_max_x(rect.right() - EDGE) }; let movable = window.panels().all(|p| self.permissions(p).movable); let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); @@ -383,15 +422,14 @@ impl Renderer<'_, P> { if closable { bar = bar.close(&close_label); } - let response = bar.show(ui, rect, &ChromeStyle::from_dock(self.style)); - if let Some(close) = response.close - && close.clicked() - { + let response = bar.show(ui, rect, &style); + if response.close.as_ref().is_some_and(|close| close.clicked()) { self.output.actions.push(Action::CloseWindow { panel: first }); } if movable && response.bar.drag_started() { self.start_window_drag(ui, window.id, false); } + response.close.iter().chain(&response.menu).map(|r| r.rect).collect() } /// After a native window's tree drew: a drag of its only tab, or of its only group, moves @@ -499,6 +537,62 @@ impl Renderer<'_, P> { self.cross.release = Some((drag, pointer)); } + /// The resize bands of a movable native window. Drawn after its content, so they take the + /// pointer at its edges; the bar's `buttons` are left out. + fn resize_edges(&mut self, ui: &Ui, window: u64, full: Rect, buttons: &[Rect]) { + let id = self.id.with(("native-edges", window)); + for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { + let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); + if response.drag_started() { + self.start_window_resize(ui, window, direction); + } + } + } + + /// Start a resize: by the window system where it runs one, else by following the pointer + /// from the place the window system reports. + fn start_window_resize(&mut self, ui: &Ui, window: u64, direction: ResizeDirection) { + let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); + let content = ui.input(|i| placed(i.viewport())); + match (SYSTEM_RESIZE, content, press) { + (false, Some(start), Some(press)) => { + let resize = WindowResize { window, direction, start, press: pos2(start[0], start[1]) + press.to_vec2(), to: start }; + ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-resize"), resize)); + } + _ => ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)), + } + } + + /// Size and move the window while the button is held, the edges opposite the grabbed band + /// in place, no smaller than [`MIN_SIZE`]. Released, a window left with too little of its + /// bar on a monitor goes back onto the nearest, as after a bar drag. + fn follow_window_resize(&mut self, ui: &Ui, window: u64) { + let key = self.id.with("window-resize"); + let Some(mut resize) = ui.ctx().data(|d| d.get_temp::(key)).filter(|r| r.window == window) else { return }; + ui.ctx().set_cursor_icon(resize_cursor(resize.direction)); + let content = ui.input(|i| content_of(i.viewport())); + let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); + // As in a bar drag: a pointer move is relative to where the window system reports the + // window now. + let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); + let mut to = resize.to; + if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { + to = resized(resize.start, resize.direction, origin + local.to_vec2() - resize.press, MIN_SIZE); + } + let over = !ui.input(|i| i.pointer.primary_down()) || !self.cross.focused; + if over { + to = kept_reachable(to, &self.monitors); + ui.ctx().data_mut(|d| d.remove::(key)); + } + if differs(to, resize.to) { + place_window(ui.ctx(), ui.ctx().viewport_id(), to, frame); + resize.to = to; + if !over { + ui.ctx().data_mut(|d| d.insert_temp(key, resize)); + } + } + } + /// Keys, text and clipboard events the window's widgets left over, for the application's /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. fn forward_events(&mut self, ui: &Ui) { @@ -519,30 +613,94 @@ impl Renderer<'_, P> { } } -/// Bands along the edges and corners of an undecorated window that resize it. -#[cfg(not(target_os = "macos"))] -fn resize_edges(ui: &Ui, id: Id, rect: Rect) { - use egui::{CursorIcon as C, ResizeDirection as D, Sense}; - const EDGE: f32 = 4.0; - let (e, c) = (EDGE, EDGE * 2.0); - let (l, r, t, b) = (rect.left(), rect.right(), rect.top(), rect.bottom()); - let zones = [ - (D::NorthWest, Rect::from_min_max(pos2(l, t), pos2(l + c, t + c)), C::ResizeNorthWest), - (D::NorthEast, Rect::from_min_max(pos2(r - c, t), pos2(r, t + c)), C::ResizeNorthEast), - (D::SouthWest, Rect::from_min_max(pos2(l, b - c), pos2(l + c, b)), C::ResizeSouthWest), - (D::SouthEast, Rect::from_min_max(pos2(r - c, b - c), pos2(r, b)), C::ResizeSouthEast), - (D::North, Rect::from_min_max(pos2(l + c, t), pos2(r - c, t + e)), C::ResizeNorth), - (D::South, Rect::from_min_max(pos2(l + c, b - e), pos2(r - c, b)), C::ResizeSouth), - (D::West, Rect::from_min_max(pos2(l, t + c), pos2(l + e, b - c)), C::ResizeWest), - (D::East, Rect::from_min_max(pos2(r - e, t + c), pos2(r, b - c)), C::ResizeEast), +/// Move and size a native window so its content is the desktop rect `to`. The size goes +/// first, so the move puts the top-left corner in place whichever corner the window system +/// keeps on a resize (AppKit's frame origin is the bottom left). +fn place_window(ctx: &Context, viewport: ViewportId, to: [f32; 4], frame: Vec2) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::InnerSize(vec2(to[2], to[3]))); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(to[0], to[1]) - frame)); +} + +/// The bands along the edges of a native window's content `rect` that resize it: an L at each +/// corner, [`EDGE`] deep and [`CORNER`] along both edges, and the edges between them. They +/// leave out the bar's `buttons`, which take their own clicks. +pub(in crate::docking) fn resize_bands(rect: Rect, buttons: &[Rect]) -> Vec<(ResizeDirection, Rect)> { + use ResizeDirection as D; + let (l, r, t, b, e, c) = (rect.left(), rect.right(), rect.top(), rect.bottom(), EDGE, CORNER); + let band = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let bands = [ + (D::NorthWest, band(l, t, l + c, t + e)), + (D::NorthWest, band(l, t, l + e, t + c)), + (D::NorthEast, band(r - c, t, r, t + e)), + (D::NorthEast, band(r - e, t, r, t + c)), + (D::SouthWest, band(l, b - e, l + c, b)), + (D::SouthWest, band(l, b - c, l + e, b)), + (D::SouthEast, band(r - c, b - e, r, b)), + (D::SouthEast, band(r - e, b - c, r, b)), + (D::North, band(l + c, t, r - c, t + e)), + (D::South, band(l + c, b - e, r - c, b)), + (D::West, band(l, t + c, l + e, b - c)), + (D::East, band(r - e, t + c, r, b - c)), ]; - for (i, (direction, zone, cursor)) in zones.into_iter().enumerate() { - if !zone.is_positive() { - continue; - } - let response = ui.interact(zone, id.with(i), Sense::drag()).on_hover_cursor(cursor); - if response.drag_started() { - ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); + let mut out = Vec::new(); + for (direction, band) in bands { + let mut pieces = vec![band]; + for button in buttons { + pieces = pieces.into_iter().flat_map(|piece| outside(piece, *button)).collect(); } + out.extend(pieces.into_iter().filter(|p| p.is_positive()).map(|p| (direction, p))); + } + out +} + +/// The parts of `rect` outside `hole`: up to four rects. +fn outside(rect: Rect, hole: Rect) -> Vec { + let overlap = rect.intersect(hole); + if !overlap.is_positive() { + return vec![rect]; + } + [ + Rect::from_min_max(rect.min, pos2(rect.right(), overlap.top())), + Rect::from_min_max(pos2(rect.left(), overlap.bottom()), rect.max), + Rect::from_min_max(pos2(rect.left(), overlap.top()), overlap.left_bottom()), + Rect::from_min_max(overlap.right_top(), pos2(rect.right(), overlap.bottom())), + ] + .into_iter() + .filter(|r| r.is_positive()) + .collect() +} + +/// The desktop rect `start` with the edges `direction` names moved by `delta`, the opposite +/// edges kept, and no smaller than `min`. +pub(in crate::docking) fn resized(start: [f32; 4], direction: ResizeDirection, delta: Vec2, min: [f32; 2]) -> [f32; 4] { + use ResizeDirection as D; + let [x, y, w, h] = start; + let (mut left, mut top, mut right, mut bottom) = (x, y, x + w, y + h); + if matches!(direction, D::West | D::NorthWest | D::SouthWest) { + left = (left + delta.x).min(right - min[0]); + } + if matches!(direction, D::East | D::NorthEast | D::SouthEast) { + right = (right + delta.x).max(left + min[0]); + } + if matches!(direction, D::North | D::NorthWest | D::NorthEast) { + top = (top + delta.y).min(bottom - min[1]); + } + if matches!(direction, D::South | D::SouthWest | D::SouthEast) { + bottom = (bottom + delta.y).max(top + min[1]); + } + [left, top, right - left, bottom - top] +} + +fn resize_cursor(direction: ResizeDirection) -> CursorIcon { + use {CursorIcon as C, ResizeDirection as D}; + match direction { + D::North => C::ResizeNorth, + D::South => C::ResizeSouth, + D::East => C::ResizeEast, + D::West => C::ResizeWest, + D::NorthEast => C::ResizeNorthEast, + D::NorthWest => C::ResizeNorthWest, + D::SouthEast => C::ResizeSouthEast, + D::SouthWest => C::ResizeSouthWest, } } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 5b963412..41541005 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -336,9 +336,18 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T 250 ms, so focus moving between the app's own windows does not flicker. - **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On - Windows and Linux the OS frame is off and the window resizes from its edges; on macOS the - frame stays with its title bar and buttons hidden, so the OS resizes it. On Wayland, where a - window cannot be placed, the bar hands the move to the compositor. + Wayland, where a window cannot be placed, the bar hands the move to the compositor. On + Windows and Linux the OS frame is off. On macOS the frame stays for its shadow and rounded + corners, with its title bar and buttons hidden, and is not resizable: AppKit's resize areas + reach well into the content and would take clicks from the ×. +- **Resize.** A movable window resizes from bands 4 pt deep along its edges. Each corner is an + L along both edges, 12 pt long and as deep as the edges. The bands leave out the bar's + buttons, which sit 4 pt in from the window's edge so the corner's grab runs down beside + them; elsewhere the top band lies over the bar's drag area. The OS runs the resize + (`ViewportCommand::BeginResize`), except on macOS, where winit cannot: there the window + follows the pointer by `InnerSize` and `OuterPosition`, keeping the opposite edges and the + 160 by 100 pt minimum, and a window released with too little of its bar on a monitor moves + back onto the nearest one, as after a bar drag. - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's From 99d8fe537ed7a78b00621d595a167ba03ed6bab1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:03:07 +0000 Subject: [PATCH 112/160] docking: a dragged divider shrinks the groups beyond it in turn A divider dragged past the minimum of the group it shrinks moves on to the next group along the axis, and so on until every group on that side is at its minimum. The line follows same-axis splits up the tree and ends at a split across the axis or beside a dock. Groups touching the divider on the other side grow, up to their maximums, as before. Stacked panels and the groups of a dock column cascade the same way. --- crates/ui/src/docking/dock_tests.rs | 10 ++ crates/ui/src/docking/render/dock.rs | 47 +++-- crates/ui/src/docking/render/floating.rs | 2 +- crates/ui/src/docking/render/mod.rs | 87 ++++++---- crates/ui/src/docking/render/sizes.rs | 210 +++++++++++++++++++---- crates/ui/src/docking/split_tests.rs | 32 +++- crates/ui/src/docking/surface.rs | 2 +- crates/ui/src/docking/tests.rs | 11 ++ docs/ui-docking.md | 11 +- 9 files changed, 318 insertions(+), 94 deletions(-) diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 654b637f..7819667f 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -415,6 +415,16 @@ fn column_and_group_dividers_resize_and_minimized_groups_keep_their_height() { assert_eq!(d.tab(4).top(), 600.0 - 26.0); } +#[test] +fn a_group_divider_dragged_past_the_lower_groups_minimum_shrinks_the_next_group() { + let mut d = Driver::new(docked(Side::Start, &[&[&[2], &[3], &[4]]])); + let divider = d.rect("group-resize", 2).center(); + d.drag(divider, divider + vec2(0.0, 500.0)); + // Groups 3 and 4 are at their minimum: a tab strip and a 40 pt body. + let heights = column(&d.layout, 0).groups.iter().map(|g| g.height).collect::>(); + assert_eq!(heights[1..], [Some(66.0), Some(66.0)], "{heights:?}"); +} + #[test] fn double_click_on_a_docked_tab_minimizes_and_clicking_another_tab_expands() { let mut d = Driver::new(docked(Side::Start, &[&[&[2, 3], &[4]]])); diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 55336dca..7ce4e88f 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -8,7 +8,7 @@ use std::{fmt::Debug, hash::Hash}; use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2}; -use super::{GroupKind, Renderer}; +use super::{GroupKind, Renderer, RowItem, row_drag, row_range}; use crate::docking::{ Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, dock_model::Unit, @@ -144,7 +144,15 @@ pub(in crate::docking) fn fit_widths(columns: &[(f32, Option)], room: f32) impl Renderer<'_, P> { /// A horizontal split with a dock child: the dock takes its columns' width on its physical /// edge, the sibling the rest. False when neither child is a dock. - pub(super) fn dock_split(&mut self, ui: &mut Ui, first: &Node

, second: &Node

, rect: Rect, path: &mut Vec) -> bool { + pub(super) fn dock_split( + &mut self, + ui: &mut Ui, + first: &Node

, + second: &Node

, + rect: Rect, + path: &mut Vec, + root: (&Node

, Rect), + ) -> bool { let (dock_first, side, columns, other) = match (first, second) { (Node::Dock { side, columns }, other) => (true, *side, columns, other), (other, Node::Dock { side, columns }) => (false, *side, columns, other), @@ -153,7 +161,7 @@ impl Renderer<'_, P> { let (dock, rest, room) = dock_rects(side, columns, other, rect, self.style, &*self.content); self.dock(ui, side, columns, dock, room); path.push(dock_first); - self.node(ui, other, rest, path); + self.node(ui, other, rest, path, root); path.pop(); true } @@ -360,16 +368,18 @@ impl Renderer<'_, P> { push(&mut self.dock_targets, DockTarget::GroupBand { anchor: last.clone(), below: true }, band, horizontal_line(top, xs)); } // Height dividers between an open group and the next open one, after the groups so - // their hit areas win at the boundary. Both groups' heights are saved. + // their hit areas win at the boundary. The heights of the groups a drag changes are + // saved: the upper group, and the lower groups that give way in turn, or the reverse. let open: Vec = (0..column.groups.len()).filter(|i| column.groups.get(*i).is_some_and(|g| !g.minimized)).collect(); - for pair in open.windows(2) { - let &[i, j] = pair else { continue }; - let (Some(upper), Some(lower), Some(rect)) = (column.groups.get(i), column.groups.get(j), rects.get(i)) else { continue }; - let (Some(a), Some(b), Some(first), Some(second)) = (heights.get(i), heights.get(j), upper.panels.first(), lower.panels.first()) else { - continue; - }; - let (lo, hi) = (self.group_min(upper), a + b - self.group_min(lower)); - let hi = hi.max(lo); + let row: Vec = open + .iter() + .filter_map(|i| Some(RowItem { size: *heights.get(*i)?, min: self.group_min(column.groups.get(*i)?), max: f32::INFINITY })) + .collect(); + for (index, pair) in open.windows(2).enumerate() { + let &[i, _] = pair else { continue }; + let (Some(upper), Some(rect), Some(a)) = (column.groups.get(i), rects.get(i), heights.get(i)) else { continue }; + let Some(first) = upper.panels.first() else { continue }; + let (lo, hi) = row_range(&row, index); let divider = Rect::from_x_y_ranges(rect.x_range(), (rect.bottom() - 1.0)..=(rect.bottom() + 1.0)); let response = ResizeHandle::new( self.id.with(("group-resize", Some(first))), @@ -378,13 +388,16 @@ impl Renderer<'_, P> { &self.style.resize_label, self.handle_style(), ) - .value(*a, lo..=hi) + .value(*a, (a + lo)..=(a + hi)) .show(ui); self.register_interaction(ui.ctx(), response.response.id); - let next = (a + response.delta).clamp(lo, hi); - if response.delta != 0.0 && next != *a { - self.output.actions.push(Action::ResizeGroup { panel: first.clone(), height: Some(next) }); - self.output.actions.push(Action::ResizeGroup { panel: second.clone(), height: Some(a + b - next) }); + if response.delta == 0.0 { + continue; + } + for ((group, item), next) in open.iter().filter_map(|g| column.groups.get(*g)).zip(&row).zip(row_drag(&row, index, response.delta)) { + if let Some(panel) = group.panels.first().filter(|_| next != item.size) { + self.output.actions.push(Action::ResizeGroup { panel: panel.clone(), height: Some(next) }); + } } } } diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 72ec389c..a5168cb6 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -116,7 +116,7 @@ impl Renderer<'_, P> { (_, true) => {} (root, false) => { let outer = self.tree.replace((key, frame)); - self.node(ui, root, body, &mut Vec::new()); + self.node(ui, root, body, &mut Vec::new(), (root, body)); self.tree = outer; } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index f4fbf37e..2f16a8d0 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -27,7 +27,7 @@ mod group; mod preview; mod sizes; -pub(in crate::docking) use sizes::{DROP_MARGIN, Drawn, Sizer}; +pub(in crate::docking) use sizes::{DROP_MARGIN, Drawn, RowItem, Sizer, row_drag, row_range}; pub(super) struct Renderer<'a, P> { pub(super) id: Id, @@ -63,8 +63,6 @@ pub(super) struct Renderer<'a, P> { pub(super) struct DrawnDivider { path: Vec, axis: SplitAxis, - /// The whole split's rect. - rect: Rect, divider: Rect, } @@ -107,23 +105,25 @@ impl Renderer<'_, P> { }); } - pub(in crate::docking) fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec) { + /// Draws `node` at view path `path` in `rect`, in the tree `root` (its root node and rect), + /// which a dragged divider resizes beyond `node`. + pub(in crate::docking) fn node(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &mut Vec, root: (&Node

, Rect)) { if !crate::docking::targets::usable(rect) { return; } self.note_edge(path, rect); match node { - Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path) => {} + Node::Split { axis: SplitAxis::Horizontal, first, second, .. } if self.dock_split(ui, first, second, rect, path, root) => {} Node::Split { axis, size, first, second } => { let sizer = self.sizer(); let Some(split) = sizer.split(*axis, *size, first, second, rect) else { return }; - let (lo, hi) = sizer.range(node, rect); + let (lo, hi) = sizer.range(root, path); let mark = self.dividers.len(); path.push(false); - self.node(ui, first, split.first, path); + self.node(ui, first, split.first, path, root); path.pop(); path.push(true); - self.node(ui, second, split.second, path); + self.node(ui, second, split.second, path, root); path.pop(); // The divider's inner-edge hit margin must win over child scrollbars, just // as accordion dividers win over the adjacent panel's content. @@ -140,12 +140,12 @@ impl Renderer<'_, P> { .show(ui); self.register_interaction(ui.ctx(), response.response.id); if response.delta != 0.0 { - let actions = self.divider_actions(node, rect, path, response.delta); + let actions = self.divider_actions(root, path, response.delta); self.output.actions.extend(actions); } - self.intersections(ui, node, rect, path, split.divider, mark); + self.intersections(ui, node, rect, path, split.divider, mark, root); if self.dividers.len() < crate::docking::model::MAX_NODES { - self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, rect, divider: split.divider }); + self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, divider: split.divider }); } } Node::Tabs { panels, active } => { @@ -212,21 +212,30 @@ impl Renderer<'_, P> { } // Register dividers after the headers so their narrow hit regions win at the // shared boundary. Closed entries retain their saved sizes and are skipped. + let scale = if total_wanted > 0.0 { f64::from(remaining) / total_wanted } else { 0.0 }; + let body_limits = |panel: &P| { + let mut limits = self.limits(panel); + limits.min.y = limits.min.y.max(self.style.min_pane); + limits.max = limits.max.max(limits.min); + limits + }; + let open: Vec<&crate::docking::StackEntry

> = entries.iter().filter(|entry| entry.open).collect(); + let row: Vec = open + .iter() + .map(|entry| { + let limits = body_limits(&entry.panel); + let size = (f64::from(entry.height.unwrap_or(self.style.min_pane).max(1.0)) * scale) as f32; + RowItem { size, min: limits.min.y, max: limits.max.y } + }) + .collect(); for (index, divider) in dividers { let Some(first) = entries.get(index) else { continue }; - let Some(second) = entries.iter().skip(index.saturating_add(1)).find(|entry| entry.open) else { continue }; - let a = f64::from(first.height.unwrap_or(self.style.min_pane).max(1.0)); - let b = f64::from(second.height.unwrap_or(self.style.min_pane).max(1.0)); - let scale = f64::from(remaining) / total_wanted; - let first_size = (a * scale) as f32; - let pair_size = ((a + b) * scale) as f32; - let body_limits = |panel: &P| { - let mut limits = self.limits(panel); - limits.min.y = limits.min.y.max(self.style.min_pane); - limits.max = limits.max.max(limits.min); - limits - }; - let (lo, hi) = split_bounds(pair_size, SplitAxis::Vertical, body_limits(&first.panel), body_limits(&second.panel)); + let Some(position) = open.iter().position(|entry| std::ptr::eq(*entry, first)) else { continue }; + let Some(first_size) = row.get(position).map(|item| item.size) else { continue }; + if position + 1 >= row.len() { + continue; + } + let (lo, hi) = row_range(&row, position); let response = ResizeHandle::new( self.id.with(("stack-resize", &first.panel)), SplitAxis::Vertical, @@ -234,14 +243,16 @@ impl Renderer<'_, P> { &self.style.resize_label, ResizeHandleStyle { hovered: self.style.accent, dragged: self.style.accent, focused: self.style.accent }, ) - .value(first_size, lo..=hi) + .value(first_size, (first_size + lo)..=(first_size + hi)) .show(ui); self.register_interaction(ui.ctx(), response.response.id); if response.delta != 0.0 && scale > 0.0 { - let delta = f64::from((first_size + response.delta).clamp(lo, hi) - first_size) / scale; - let next_a = (a + delta).clamp((a + b - 1_000_000.0).max(1.0), (a + b - 1.0).min(1_000_000.0)); - self.output.actions.push(Action::ResizeStack { panel: first.panel.clone(), height: Some(next_a as f32) }); - self.output.actions.push(Action::ResizeStack { panel: second.panel.clone(), height: Some((a + b - next_a) as f32) }); + for ((entry, item), next) in open.iter().zip(&row).zip(row_drag(&row, position, response.delta)) { + if next != item.size { + let height = (f64::from(next) / scale).clamp(1.0, 1_000_000.0) as f32; + self.output.actions.push(Action::ResizeStack { panel: entry.panel.clone(), height: Some(height) }); + } + } } } } @@ -252,10 +263,10 @@ impl Renderer<'_, P> { Sizer { style: self.style, content: &*self.content } } - /// `ResizeSplit` actions that move the divider of the split `node`, drawn at view path - /// `path` in `rect`, by `delta` points. - fn divider_actions(&self, node: &Node

, rect: Rect, path: &[bool], delta: f32) -> Vec> { - let sizes = self.sizer().drag(node, rect, delta, &mut path.to_vec()); + /// `ResizeSplit` actions that move the divider of the split at view path `path` in the + /// tree `root` by `delta` points. + fn divider_actions(&self, root: (&Node

, Rect), path: &[bool], delta: f32) -> Vec> { + let sizes = self.sizer().drag(root, path, delta); let resize = |path: Vec, size| match &self.tree { None => Action::ResizeSplit { path, size }, Some((panel, _)) => Action::ResizeWindowSplit { panel: panel.clone(), path, size }, @@ -274,7 +285,7 @@ impl Renderer<'_, P> { /// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on /// the split's own divider: dragging one moves both dividers. - fn intersections(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &[bool], divider: Rect, mark: usize) { + fn intersections(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &[bool], divider: Rect, mark: usize, root: (&Node

, Rect)) { let Node::Split { axis, .. } = node else { return }; let side = (self.style.gap * 3.0).max(12.0); let mut found = Vec::new(); @@ -286,9 +297,9 @@ impl Renderer<'_, P> { SplitAxis::Horizontal => pos2(divider.center().x, inner.divider.center().y), SplitAxis::Vertical => pos2(inner.divider.center().x, divider.center().y), }; - found.push((inner.path.clone(), inner.rect, Rect::from_center_size(center, Vec2::splat(side)).intersect(rect))); + found.push((inner.path.clone(), Rect::from_center_size(center, Vec2::splat(side)).intersect(rect))); } - for (inner_path, inner_rect, hit) in found { + for (inner_path, hit) in found { let Some(inner) = inner_path.get(path.len()..).and_then(|rest| crate::docking::dock_model::node_at(node, rest)) else { continue }; let (outer, inner_layout) = (self.original_path(path), self.original_path(&inner_path)); let id = match &self.tree { @@ -305,8 +316,8 @@ impl Renderer<'_, P> { if delta != Vec2::ZERO && delta.is_finite() { let along = |axis: SplitAxis| if axis == SplitAxis::Horizontal { delta.x } else { delta.y }; let Node::Split { axis: inner_axis, .. } = inner else { continue }; - let mut actions = self.divider_actions(node, rect, path, along(*axis)); - actions.extend(self.divider_actions(inner, inner_rect, &inner_path, along(*inner_axis))); + let mut actions = self.divider_actions(root, path, along(*axis)); + actions.extend(self.divider_actions(root, &inner_path, along(*inner_axis))); self.output.actions.extend(actions); } } diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index bf7fd3dc..8af0dd5a 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -1,5 +1,13 @@ //! Split-tree geometry: where each split draws its children, how far a divider can travel, -//! and the sizes that move a divider while every group away from it keeps its size. +//! and the sizes that move a divider. +//! +//! A dragged divider grows the groups touching it on one side, up to their maximums, and +//! shrinks the groups on the other side in turn: the group touching the divider gives way +//! down to its minimum, then the next group along the axis, and so on, until every group on +//! that side is at its minimum. Groups side by side across the axis shrink together, and the +//! line goes on past them once one is at its minimum. The line follows splits of the drag's +//! axis up the tree and ends at a split across it or beside a dock. Every other group keeps +//! its size. use egui::Rect; @@ -78,8 +86,9 @@ impl

Sizer<'_, P> { } /// How far the end edge (`end`) or start edge of `node` drawn in `rect` can move along - /// `axis`: inward while the groups touching it stay at their minimums, and outward up to - /// their maximums. + /// `axis`: inward until every group along the axis from that edge is at its minimum, and + /// outward while the groups touching it stay below their maximums. Groups side by side + /// across the axis move together, so the first of them to reach its limit stops them. pub(in crate::docking) fn travel(&self, node: &Node

, rect: Rect, axis: SplitAxis, end: bool) -> (f32, f32) { let docked = matches!(node, Node::Split { first, second, .. } if matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. })); @@ -89,8 +98,9 @@ impl

Sizer<'_, P> { self.travel(other, rect, axis, end) } (Node::Split { axis: split_axis, .. }, Some([a, b])) if *split_axis == axis => { - let (child, rect) = if end { b } else { a }; - self.travel(child, rect, axis, end) + let (near, far) = if end { (b, a) } else { (a, b) }; + let (near, far) = (self.travel(near.0, near.1, axis, end), self.travel(far.0, far.1, axis, end)); + (near.0 + far.0, near.1) } (_, Some([a, b])) => { let (a, b) = (self.travel(a.0, a.1, axis, end), self.travel(b.0, b.1, axis, end)); @@ -105,7 +115,9 @@ impl

Sizer<'_, P> { } /// The sizes that move the end (`end`) or start edge of `node` drawn in `rect` outward by - /// `grow` points: only the groups touching that edge change. Paths are relative to `path`. + /// `grow` points. Growing, only the groups touching that edge change. Shrinking, the group + /// at the edge gives way down to its minimum, then the next one along the axis, and so on. + /// Paths are relative to `path`. pub(in crate::docking) fn follow( &self, (node, rect): (&Node

, Rect), @@ -118,55 +130,195 @@ impl

Sizer<'_, P> { let Some([a, b]) = self.children(node, rect) else { return }; let Node::Split { axis: split_axis, first, second, .. } = node else { return }; let docked = matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }); - let visit = |child: (&Node

, Rect), second_child: bool, path: &mut Vec, out: &mut Vec<(Vec, SplitSize)>| { + let visit = |child: (&Node

, Rect), second_child: bool, grow: f32, path: &mut Vec, out: &mut Vec<(Vec, SplitSize)>| { path.push(second_child); self.follow(child, axis, end, grow, path, out); path.pop(); }; if docked { let dock_first = matches!(**first, Node::Dock { .. }); - visit(if dock_first { b } else { a }, dock_first, path, out); + visit(if dock_first { b } else { a }, dock_first, grow, path, out); } else if *split_axis == axis { - let available = extent(a.1, axis) + extent(b.1, axis) + grow; - let first_size = extent(a.1, axis) + if end { 0.0 } else { grow }; - if available > 0.0 { - out.push((path.clone(), SplitSize::Ratio((first_size / available).clamp(0.0, 1.0)))); + let (near, far) = if end { (b, a) } else { (a, b) }; + let near_grow = if grow < 0.0 { grow.max(-self.travel(near.0, near.1, axis, end).0) } else { grow }; + let far_grow = grow - near_grow; + let (near_size, far_size) = (extent(near.1, axis) + near_grow, extent(far.1, axis) + far_grow); + let first_size = if end { far_size } else { near_size }; + push_ratio(path, first_size, near_size + far_size, out); + visit(near, end, near_grow, path, out); + if far_grow != 0.0 { + visit(far, !end, far_grow, path, out); } - visit(if end { b } else { a }, end, path, out); } else { - visit(a, false, path, out); - visit(b, true, path, out); + visit(a, false, grow, path, out); + visit(b, true, grow, path, out); } } - /// The divider of the split `node` drawn in `rect` moved by `delta` points, clamped to its - /// travel: the split's own size and those of the splits that keep the groups away from the - /// divider at their sizes. Paths are relative to `path`, the split's own. - pub(in crate::docking) fn drag(&self, node: &Node

, rect: Rect, delta: f32, path: &mut Vec) -> Vec<(Vec, SplitSize)> { + /// The drawn splits from `root` down to the one at `path`, each with its rect. + fn chain<'n>(&self, root: (&'n Node

, Rect), path: &[bool]) -> Option, Rect)>> { + let mut chain = vec![root]; + for &second in path { + let (node, rect) = *chain.last()?; + let [a, b] = self.children(node, rect)?; + chain.push(if second { b } else { a }); + } + Some(chain) + } + + /// The ancestors that continue the line of the split at the end of `chain` along `axis`: + /// each with its children and whether the line runs through its second child, nearest first. + /// The line ends at a split across the axis or beside a dock. + fn line<'n>(&self, chain: &[(&'n Node

, Rect)], path: &[bool], axis: SplitAxis) -> Vec<(usize, [(&'n Node

, Rect); 2], bool)> { + let mut line = Vec::new(); + for depth in (0..path.len()).rev() { + let Some(&(node, rect)) = chain.get(depth) else { break }; + let Node::Split { axis: split_axis, first, second, .. } = node else { break }; + if *split_axis != axis || matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }) { + break; + } + let (Some(children), Some(&second_child)) = (self.children(node, rect), path.get(depth)) else { break }; + line.push((depth, children, second_child)); + } + line + } + + /// The divider of the split at `path` under `root` (a node and the rect it is drawn in) + /// moved by `delta` points, clamped to its travel. The groups touching the divider on the + /// side it moves toward give way, then the groups beyond them along the axis, one after + /// another, as far as the line of same-axis splits reaches; the groups on the other side + /// that touch the divider take the room. Paths are absolute, like `path`. + pub(in crate::docking) fn drag(&self, root: (&Node

, Rect), path: &[bool], delta: f32) -> Vec<(Vec, SplitSize)> { + let Some(chain) = self.chain(root, path) else { return Vec::new() }; + let Some(&(node, rect)) = chain.last() else { return Vec::new() }; let Node::Split { axis, size, first, second } = node else { return Vec::new() }; - let Some(split) = self.split(*axis, *size, first, second, rect) else { return Vec::new() }; - let (lo, hi) = self.range(node, rect); + let axis = *axis; + let Some(split) = self.split(axis, *size, first, second, rect) else { return Vec::new() }; + let (lo, hi) = self.range(root, path); let delta = delta.clamp(lo, hi); if delta == 0.0 || !delta.is_finite() || split.available() <= 0.0 { return Vec::new(); } - let mut out = vec![(path.clone(), SplitSize::Ratio(((split.first_size() + delta) / split.available()).clamp(0.0, 1.0)))]; + // Toward the end edge the second child gives way, toward the start the first. + let forward = delta > 0.0; + let moved = delta.abs(); + let given = if forward { + moved.min(self.travel(second, split.second, axis, false).0) + } else { + moved.min(self.travel(first, split.first, axis, true).0) + }; + let (first_grow, second_grow) = if forward { (moved, -given) } else { (-given, moved) }; + let mut out = Vec::new(); + let mut path = path.to_vec(); + let first_size = split.first_size() + first_grow; + push_ratio(&path, first_size, first_size + extent(split.second, axis) + second_grow, &mut out); path.push(false); - self.follow((first, split.first), *axis, true, delta, path, &mut out); + self.follow((first, split.first), axis, true, first_grow, &mut path, &mut out); path.pop(); path.push(true); - self.follow((second, split.second), *axis, false, -delta, path, &mut out); + self.follow((second, split.second), axis, false, second_grow, &mut path, &mut out); path.pop(); + // What the split's own child could not give, the ancestors' children beyond it give: + // the split grows by `rest` toward them, and so does each ancestor until one absorbs it. + let mut rest = moved - given; + for (depth, [a, b], second_child) in self.line(&chain, &path, axis) { + if rest <= 0.0 { + break; + } + path.truncate(depth); + let (a_size, b_size) = (extent(a.1, axis), extent(b.1, axis)); + let (first_size, second_size) = match (forward, second_child) { + (true, false) => { + let taken = rest.min(self.travel(b.0, b.1, axis, false).0); + path.push(true); + self.follow(b, axis, false, -taken, &mut path, &mut out); + path.pop(); + let sizes = (a_size + rest, b_size - taken); + rest -= taken; + sizes + } + (false, true) => { + let taken = rest.min(self.travel(a.0, a.1, axis, true).0); + path.push(false); + self.follow(a, axis, true, -taken, &mut path, &mut out); + path.pop(); + let sizes = (a_size - taken, b_size + rest); + rest -= taken; + sizes + } + (true, true) => (a_size, b_size + rest), + (false, false) => (a_size + rest, b_size), + }; + push_ratio(&path, first_size, first_size + second_size, &mut out); + } out } - /// How far the divider of the split `node` drawn in `rect` can move: `(lo, hi)`, with - /// `lo <= 0 <= hi`. - pub(in crate::docking) fn range(&self, node: &Node

, rect: Rect) -> (f32, f32) { + /// How far the divider of the split at `path` under `root` can move: `(lo, hi)`, with + /// `lo <= 0 <= hi`. Each way it is the lesser of the room the groups touching the divider + /// have to grow and the room every group beyond the divider, along the line of same-axis + /// splits, has to shrink. + pub(in crate::docking) fn range(&self, root: (&Node

, Rect), path: &[bool]) -> (f32, f32) { + let Some(chain) = self.chain(root, path) else { return (0.0, 0.0) }; + let Some(&(node, rect)) = chain.last() else { return (0.0, 0.0) }; let Node::Split { axis, size, first, second } = node else { return (0.0, 0.0) }; let Some(split) = self.split(*axis, *size, first, second, rect) else { return (0.0, 0.0) }; - let (first_in, first_out) = self.travel(first, split.first, *axis, true); - let (second_in, second_out) = self.travel(second, split.second, *axis, false); + let (mut first_in, first_out) = self.travel(first, split.first, *axis, true); + let (mut second_in, second_out) = self.travel(second, split.second, *axis, false); + for (_, [a, b], second_child) in self.line(&chain, path, *axis) { + if second_child { + first_in += self.travel(a.0, a.1, *axis, true).0; + } else { + second_in += self.travel(b.0, b.1, *axis, false).0; + } + } (-first_in.min(second_out), first_out.min(second_in)) } } + +/// The size of a split at `path` whose first child is `first` of `available` points. +fn push_ratio(path: &[bool], first: f32, available: f32, out: &mut Vec<(Vec, SplitSize)>) { + if available > 0.0 { + out.push((path.to_vec(), SplitSize::Ratio((first / available).clamp(0.0, 1.0)))); + } +} + +/// One group of a flat row, such as the panels of a stack or the groups of a dock column: +/// its size along the row and its limits. +#[derive(Clone, Copy)] +pub(in crate::docking) struct RowItem { + pub(in crate::docking) size: f32, + pub(in crate::docking) min: f32, + pub(in crate::docking) max: f32, +} + +/// How far the divider after `row[index]` can move: `(lo, hi)`, with `lo <= 0 <= hi`, by the +/// rule of [`row_drag`]. +pub(in crate::docking) fn row_range(row: &[RowItem], index: usize) -> (f32, f32) { + let room = |items: &[RowItem]| items.iter().map(|item| (item.size - item.min).max(0.0)).sum::(); + let grow = |item: Option<&RowItem>| item.map_or(0.0, |item| (item.max - item.size).max(0.0)); + let (before, after) = row.split_at((index + 1).min(row.len())); + (-room(before).min(grow(after.first())), room(after).min(grow(before.last()))) +} + +/// The sizes of `row` after the divider after `row[index]` moves by `delta`, clamped to its +/// travel: the item touching the divider on the side it moves toward gives way down to its +/// minimum, then the next one, and so on; the item on the other side takes the room. +pub(in crate::docking) fn row_drag(row: &[RowItem], index: usize, delta: f32) -> Vec { + let (lo, hi) = row_range(row, index); + let delta = if delta.is_finite() { delta.clamp(lo, hi) } else { 0.0 }; + let mut sizes: Vec = row.iter().map(|item| item.size).collect(); + let (grows, givers): (usize, Vec) = + if delta > 0.0 { (index, (index + 1..row.len()).collect()) } else { (index + 1, (0..=index).rev().collect()) }; + let mut rest = delta.abs(); + for giver in givers { + let (Some(size), Some(item)) = (sizes.get_mut(giver), row.get(giver)) else { continue }; + let taken = rest.min((item.size - item.min).max(0.0)); + *size -= taken; + rest -= taken; + } + if let Some(size) = sizes.get_mut(grows) { + *size += delta.abs() - rest; + } + sizes +} diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index 3c7c1c45..ea3283ad 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -227,6 +227,24 @@ fn a_divider_resizes_only_the_groups_that_share_it() { assert_eq!(group(&d, PROPERTIES[0]), properties); } +#[test] +fn a_divider_dragged_past_a_groups_minimum_shrinks_the_next_group_along_the_row() { + // A | B | C | D, 357 pt each, as (A | B) | (C | D): the cascade runs on past B's split. + let row = hsplit(0.5, hsplit(0.5, tabs(&[1]), tabs(&[2])), hsplit(0.5, tabs(&[3]), tabs(&[4]))); + let mut d = driver(Layout { root: Some(row), floating: Vec::new() }, Film::default()); + let widths = |d: &Driver| [1, 2, 3, 4].map(|panel| group(d, panel).width().round()); + assert_eq!(widths(&d), [357.0, 357.0, 357.0, 357.0]); + // B gives way down to its 40 pt minimum, then C. + drag_hit(&mut d, divider(&[false]), vec2(400.0, 0.0)); + assert_eq!(widths(&d), [757.0, 40.0, 274.0, 357.0]); + // The drag stops when B, C and D are all at their minimums. + drag_hit(&mut d, divider(&[false]), vec2(670.0, 0.0)); + assert_eq!(widths(&d), [1308.0, 40.0, 40.0, 40.0]); + // Back the other way, A gives way and only B, which touches the divider, grows. + drag_hit(&mut d, divider(&[false]), vec2(-1300.0, 0.0)); + assert_eq!(widths(&d), [40.0, 1308.0, 40.0, 40.0]); +} + #[test] fn an_intersection_moves_both_dividers() { let mut d = film(); @@ -361,12 +379,16 @@ fn assert_sizes(d: &Driver, context: &str) { let room = g.rect.size() - g.limits.min; assert!(room.x >= -0.01 && room.y >= -0.01, "{context}: {:?} is below its minimum {:?}", g.rect, g.limits.min); } - let mut nodes = vec![(root, area)]; - while let Some((node, rect)) = nodes.pop() { - let Some(children) = sizer.children(node, rect) else { continue }; - let (lo, hi) = sizer.range(node, rect); + let mut nodes = vec![(root, area, Vec::new())]; + while let Some((node, rect, path)) = nodes.pop() { + let Some([a, b]) = sizer.children(node, rect) else { continue }; + let (lo, hi) = sizer.range((root, area), &path); assert!(lo < -0.5 && hi > 0.5, "{context}: a divider is stuck ({lo}, {hi}) at {rect:?} in {:?}", d.layout); - nodes.extend(children); + for (second, (child, rect)) in [(false, a), (true, b)] { + let mut path = path.clone(); + path.push(second); + nodes.push((child, rect, path)); + } } } } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 1deff020..f133f190 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -829,7 +829,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { tree: None, }; if let Some(root) = &view.root { - renderer.node(ui, root, rect, &mut Vec::new()); + renderer.node(ui, root, rect, &mut Vec::new(), (root, rect)); ui.allocate_rect(rect, Sense::hover()); } renderer.flyout(ui.ctx()); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 3f21199a..2a3d7c56 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -653,6 +653,17 @@ fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() { let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; assert!(entries[0].height.unwrap() > after_pointer); assert!((entries[0].height.unwrap() + entries[2].height.unwrap() - 300.0).abs() < 0.001); + // Dragged past the next body's minimum, the one after it gives way too, down to the same + // minimum; the closed entry keeps its height. + let start = ctx.read_response(id).unwrap().rect.center(); + step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); + step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(0.0, 600.0))]); + step(&ctx, &mut layout, vec![pointer_button(start + vec2(0.0, 600.0), false)]); + let Node::Stack { entries } = layout.root.as_ref().unwrap() else { panic!() }; + let heights = entries.iter().map(|entry| entry.height.unwrap()).collect::>(); + assert!((heights[2] - heights[3]).abs() < 0.01 && heights[3] < 120.0, "{heights:?}"); + assert!((heights[0] + heights[2] + heights[3] - 420.0).abs() < 0.01, "{heights:?}"); + assert_eq!(heights[1], 91.0); } #[test] diff --git a/docs/ui-docking.md b/docs/ui-docking.md index fb6d0dee..2cac493d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -218,9 +218,14 @@ A flyout fits its content the same way, without the title bar. window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of the window and is offered only when every frame keeps its minimum. Past the band, a frame's own side zones apply. -- A divider resizes only the frames that touch it; every other frame keeps its size. It stops - when a frame touching it reaches its minimum. Where a divider ends on another divider, a - four-way handle (`Divider::Intersection`) moves both. +- A dragged divider grows the frames that touch it on one side, up to their maximums. On the + other side the frame touching it shrinks to its minimum, then the next frame along the same + axis, and so on: the divider stops when every frame on that side is at its minimum. Frames + side by side across the axis shrink together, and the next frame gives way once one of them + is at its minimum. The line of frames ends at a split across the axis or at a dock; every + other frame keeps its size. Dividers between stacked panels and between the groups of a dock + column work the same way. Where a divider ends on another divider, a four-way handle + (`Divider::Intersection`) moves both. - Every frame has the same minimum, `DockStyle::min_pane` (plus its tab strip), unless its panels ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it From cb352b39ffa84cd94060c1ac1b0a756937969854 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:06:11 +0000 Subject: [PATCH 113/160] docking: all four drop zones of a group share one depth Each side zone reaches zone_depth into the body, or a quarter of the body's smaller side when that is less. A tall narrow group's top and bottom zones are no deeper than its left and right ones. --- crates/ui/src/docking/surface.rs | 6 +++--- crates/ui/src/docking/targets.rs | 19 ++++++++++--------- crates/ui/src/docking/tests.rs | 17 ++++++++++++----- docs/ui-docking.md | 4 ++-- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index f133f190..64046b32 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -51,9 +51,9 @@ pub struct DockStyle { /// The outer edge band also runs along the left and right edges, where a drop adds a /// full-height column. Off: only the top and bottom edges add a full-width row. pub side_edge_zones: bool, - /// How far a group's side drop zones reach into its body, below the tab strip. A body less - /// than three times as wide or tall gets zones a third of its extent deep in that axis, so - /// it keeps a center. + /// How far a group's side drop zones reach into its body, below the tab strip. All four + /// zones share one depth: this, or a quarter of the body's smaller side when that is less, + /// so the body keeps a center. pub zone_depth: f32, /// [D] The docked area's left and right edges, where no dock is yet, take a drop as a new /// dock (see [`Self::new_dock_band`]). diff --git a/crates/ui/src/docking/targets.rs b/crates/ui/src/docking/targets.rs index 89e235e0..82eec325 100644 --- a/crates/ui/src/docking/targets.rs +++ b/crates/ui/src/docking/targets.rs @@ -1,4 +1,4 @@ -use egui::{Pos2, Rangef, Rect, Vec2, pos2, vec2}; +use egui::{Pos2, Rangef, Rect, Vec2, pos2}; use super::{Placement, Side, Target, Zone}; @@ -32,10 +32,11 @@ pub(super) fn usable(rect: Rect) -> bool { } /// Tab header (the top `tab_height` of `rect`) is the center; otherwise the nearest side zone of -/// the body below it wins. A side zone reaches `depth` into the body, or a third of the body's -/// extent in that axis when that is less, so a body always keeps a center; a `depth` of zero -/// or less leaves only the center. Outside, non-finite, empty and inverted rectangles have no -/// target. +/// the body below it wins. Every side zone reaches the same depth into the body: `depth`, or a +/// quarter of the body's smaller side when that is less, so a body always keeps a center and a +/// narrow body's top and bottom zones are no deeper than its left and right ones. A `depth` of +/// zero or less leaves only the center. Outside, non-finite, empty and inverted rectangles have +/// no target. pub fn drop_zone(rect: Rect, tab_height: f32, depth: f32, pointer: Pos2) -> Option { if !usable(rect) || !pointer.is_finite() || !rect.contains(pointer) || !tab_height.is_finite() || tab_height < 0.0 { return None; @@ -65,10 +66,10 @@ fn body(rect: Rect, tab_height: f32) -> Rect { } /// How deep the side zones of `body` reach in from its left and right edges (`x`) and its top -/// and bottom edges (`y`). +/// and bottom edges (`y`): the same on all four sides. fn side_depths(body: Rect, depth: f32) -> Vec2 { let depth = if depth > 0.0 { depth } else { 0.0 }; - vec2(depth.min(body.width() / 3.0), depth.min(body.height() / 3.0)) + Vec2::splat(depth.min(body.width().min(body.height()) / 4.0)) } /// The outline of a drop zone of the group drawn at `rect`, as [`drop_zone`] finds it with the @@ -152,8 +153,8 @@ pub fn tab_insertion_marker(target: &DropTarget

, before: Option<&P>) - /// Resolve a pointer against visible groups. The frontmost containing group occludes groups /// behind it even if it rejects the drop. A strip reorders by midpoint; the body of the source /// group is a no-op at center. The body's side zones are [`drop_zone`]'s with `zone_depth` (see -/// [`crate::docking::DockStyle::zone_depth`]). Floating groups normally set `allow_splits` to -/// false. +/// [`crate::docking::DockStyle::zone_depth`]): one depth on all four sides, at most a quarter of +/// the body's smaller side. Floating groups normally set `allow_splits` to false. pub fn resolve_drop(panel: &P, pointer: Pos2, targets: &[DropTarget

], zone_depth: f32) -> Option> { if !pointer.is_finite() || targets.len() > super::MAX_PANELS { return None; diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 2a3d7c56..80c1fb97 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -303,23 +303,30 @@ fn pointer_targets_distinguish_reorder_center_split_and_occlusion() { } #[test] -fn side_zones_are_40_pt_deep_or_a_third_of_a_small_body() { +fn side_zones_are_40_pt_deep_or_a_quarter_of_the_bodys_smaller_side() { let depth = DockStyle::default().zone_depth; assert_eq!(depth, 40.0); // The inner corner of the left trapezoid is the depth of both bands. let inner = |rect: Rect| zone_shape(rect, 0.0, depth, Zone::Left).unwrap()[1] - rect.min; - for size in [vec2(600.0, 400.0), vec2(130.0, 130.0)] { + for size in [vec2(600.0, 400.0), vec2(160.0, 160.0)] { let rect = Rect::from_min_size(pos2(10.0, 20.0), size); assert_eq!(inner(rect), vec2(40.0, 40.0), "{size:?}"); let y = rect.center().y; assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 39.0, y)), Some(Zone::Left), "{size:?}"); assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 41.0, y)), Some(Zone::Center), "{size:?}"); } - // A 90 pt body keeps a 30 pt center. + // A 90 pt body keeps a 45 pt center. let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(90.0, 90.0)); - assert_eq!(inner(rect), vec2(30.0, 30.0)); + assert_eq!(inner(rect), vec2(22.5, 22.5)); assert_eq!(drop_zone(rect, 0.0, depth, rect.center()), Some(Zone::Center)); - assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 29.0, rect.center().y)), Some(Zone::Left)); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.left() + 22.0, rect.center().y)), Some(Zone::Left)); + // A tall narrow body: its top and bottom zones are as deep as its left and right ones. + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(120.0, 600.0)); + assert_eq!(inner(rect), vec2(30.0, 30.0)); + let top = zone_shape(rect, 0.0, depth, Zone::Top).unwrap(); + assert_eq!(top[2].y - rect.top(), 30.0); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.center().x, rect.top() + 29.0)), Some(Zone::Top)); + assert_eq!(drop_zone(rect, 0.0, depth, pos2(rect.center().x, rect.top() + 31.0)), Some(Zone::Center)); // The bands start below the tab header. let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(600.0, 400.0)); assert_eq!(drop_zone(rect, 24.0, depth, pos2(rect.center().x, rect.top() + 60.0)), Some(Zone::Top)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 2cac493d..33b27d09 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -211,8 +211,8 @@ A flyout fits its content the same way, without the title bar. limits and the screen. A focused resize handle moves its edge 8 pt per arrow key. - Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The side zones are trapezoids along the four edges of the body below the tab strip, like a picture - frame: `DockStyle::zone_depth` (40 pt) deep, or a third of the body's width or height when that - is less, so every frame keeps a center. A split that would leave either half without + frame, all four the same depth: `DockStyle::zone_depth` (40 pt), or a quarter of the body's + smaller side when that is less, so every frame keeps a center. A split that would leave either half without `DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target. - The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of From 1ccdad9512504a27efae1bf5b726e7484c70e848 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:09:14 +0000 Subject: [PATCH 114/160] docking: Floating::with_id for layouts built in code; debug builds warn about rebuilt layouts Floating::new, single and stack draw a fresh id on each call, so a layout built anew each frame opened a new OS window each frame. with_id gives such a layout the same windows every frame. Debug builds log a warning once when three frames running replace every native window with new ones holding the same panels. --- Cargo.lock | 1 + crates/ui/Cargo.toml | 1 + crates/ui/src/docking/model.rs | 19 +++++++++-- crates/ui/src/docking/native_tests.rs | 47 ++++++++++++++++++++++++++ crates/ui/src/docking/render/native.rs | 39 +++++++++++++++++++++ docs/ui-docking.md | 11 ++++-- 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9ae6cc1..bb3435fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,7 @@ dependencies = [ "egui_extras", "egui_kittest", "image", + "log", "serde", "serde_json", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 554ed27f..6b1f8145 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] egui = { workspace = true } +log = "0.4" serde = { version = "1", features = ["derive"], optional = true } [features] diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index d539dabe..543e2bb6 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -68,8 +68,8 @@ pub struct StackEntry

{ #[derive(Clone, Debug)] pub struct Floating

{ /// Names the window across panel moves, and its OS window with feature `native-windows`. - /// Unique within a layout: [`Self::new`] and loading draw fresh ids, and merging windows - /// keeps the receiving window's. + /// Unique within a layout: [`Self::new`] and loading draw fresh ids, [`Self::with_id`] + /// sets one, and merging windows keeps the receiving window's. pub id: u64, /// `Split`, `Tabs` and `Stack` nodes; a `Dock` is invalid here. pub root: Node

, @@ -122,20 +122,33 @@ pub(super) fn fresh_window_id() -> u64 { } impl

Floating

{ + /// A window holding `root`. Each call makes a new window, with a fresh [`Self::id`]: build + /// a layout once and keep it, or give its windows ids with [`Self::with_id`]. A layout + /// built anew each frame without them opens new OS windows each frame. pub fn new(root: Node

, rect: [f32; 4]) -> Self { Self { id: fresh_window_id(), root, rect, native: None, monitor: None, monitor_size: None, minimized: false } } + /// A window with one group. Each call makes a new window, as [`Self::new`] does. pub fn single(panels: Vec

, active: usize, rect: [f32; 4]) -> Self { Self::new(Node::Tabs { panels, active }, rect) } + /// The window with `id`, for a layout the app builds in code, such as a default layout: + /// built with the same id, it is the same window, and the same OS window, on every frame. + /// Ids must be unique within a layout. + pub fn with_id(mut self, id: u64) -> Self { + self.id = id; + self + } + pub fn panels(&self) -> impl Iterator { tree_panels(&self.root).into_iter() } /// Groups stacked top to bottom, as a v1 layout stored them: each group over a vertical - /// split of the ones below it, sized by their heights. `None` without groups. + /// split of the ones below it, sized by their heights. `None` without groups. Each call + /// makes a new window, as [`Self::new`] does. pub fn stack(groups: Vec>, rect: [f32; 4]) -> Option { super::dock_model::stack_tree(groups).map(|root| Self::new(root, rect)) } diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index e383e1f1..24975f0d 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1084,3 +1084,50 @@ fn a_manual_resize_keeps_the_opposite_edges_and_the_minimum_size() { assert_eq!(resized(start, D::SouthWest, vec2(500.0, -500.0), min), [240.0, 50.0, 160.0, 100.0]); assert_eq!(resized(start, D::NorthEast, vec2(-500.0, 500.0), min), [100.0, 150.0, 160.0, 100.0]); } + +/// [`layout`] built anew, its window with id 7 when `with_id`. +fn rebuilt_layout(with_id: bool) -> Layout { + let mut layout = layout(true); + if with_id { + layout.floating = layout.floating.into_iter().map(|w| w.with_id(7)).collect(); + } + layout +} + +#[test] +fn a_layout_built_each_frame_with_ids_keeps_its_os_window() { + let mut d = driver(rebuilt_layout(true)); + let viewport = d.viewport(7); + for _ in 0..4 { + d.layout = rebuilt_layout(true); + d.step(vec![]); + } + assert_eq!(d.host().windows.keys().copied().collect::>(), [viewport], "one OS window, the same each frame"); +} + +#[cfg(debug_assertions)] +#[test] +fn a_layout_built_each_frame_without_ids_is_warned_about() { + let warned = |d: &DockDriver| d.ctx.data(|m| m.get_temp::(d.id.with("native-rebuilt"))).unwrap_or(false); + let mut d = driver(rebuilt_layout(false)); + for _ in 0..4 { + d.layout = rebuilt_layout(false); + d.step(vec![]); + } + assert!(warned(&d)); + assert!(d.host().windows.len() > 1, "a new OS window each frame"); + + // A layout kept across frames, and one rebuilt with ids, are not. + let mut d = driver(layout(true)); + d.layout = rebuilt_layout(false); + for _ in 0..4 { + d.step(vec![]); + } + assert!(!warned(&d), "loaded once"); + let mut d = driver(rebuilt_layout(true)); + for _ in 0..4 { + d.layout = rebuilt_layout(true); + d.step(vec![]); + } + assert!(!warned(&d)); +} diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 70ce6a0e..529d1c7e 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -160,6 +160,8 @@ impl Renderer<'_, P> { natives.push((window, rect, viewport_id(self.id, window.id, n))); } if native { + #[cfg(debug_assertions)] + self.warn_rebuilt(ctx, &natives); self.escape_elsewhere(ctx, None); // A window closed or docked during its own bar drag or resize ends it. let shown = |window: u64| natives.iter().any(|(w, ..)| w.id == window); @@ -198,6 +200,26 @@ impl Renderer<'_, P> { in_window } + /// Debug builds: warn once when for [`REBUILT_FRAMES`] frames running, every native window + /// is new, with the panels of the last frame's. The app builds its layout anew each frame + /// without [`Floating::with_id`], so each frame opens new OS windows. A layout loaded + /// again does it once. + #[cfg(debug_assertions)] + fn warn_rebuilt(&self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)]) { + let (key, warned) = (self.id.with("native-previous"), self.id.with("native-rebuilt")); + let current: Vec<(u64, Vec

)> = natives.iter().map(|(w, ..)| (w.id, w.panels().cloned().collect())).collect(); + let (runs, previous) = ctx.data_mut(|d| d.remove_temp::<(usize, Vec<(u64, Vec

)>)>(key)).unwrap_or_default(); + let runs = if rebuilt(&previous, ¤t) { runs + 1 } else { 0 }; + if runs >= REBUILT_FRAMES && !ctx.data(|d| d.get_temp::(warned).unwrap_or(false)) { + log::warn!( + "craft-ui docking: the native floating windows get new ids every frame, so each frame opens new OS windows. \ + Keep the layout across frames, or give its windows ids with Floating::with_id." + ); + ctx.data_mut(|d| d.insert_temp(warned, true)); + } + ctx.data_mut(|d| d.insert_temp(key, (runs, current))); + } + /// The main window and the shown native windows on the desktop, front first: the window /// focused most recently first. A window whose position is unknown is left out. fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool) { @@ -613,6 +635,23 @@ impl Renderer<'_, P> { } } +/// Frames running whose native windows are all new, with the last frame's panels, before +/// [`Renderer::warn_rebuilt`] warns. +#[cfg(debug_assertions)] +const REBUILT_FRAMES: usize = 3; + +/// Whether every window of `current` is new to `previous`, and they hold the same panels: +/// windows, with their panels, as `(id, panels)`. +#[cfg(debug_assertions)] +fn rebuilt(previous: &[(u64, Vec

)], current: &[(u64, Vec

)]) -> bool { + !current.is_empty() && current.iter().all(|(id, _)| previous.iter().all(|(p, _)| p != id)) && panels(previous) == panels(current) +} + +#[cfg(debug_assertions)] +fn panels(windows: &[(u64, Vec

)]) -> std::collections::HashSet<&Vec

> { + windows.iter().map(|(_, panels)| panels).collect() +} + /// Move and size a native window so its content is the desktop rect `to`. The size goes /// first, so the move puts the top-left corner in place whichever corner the window system /// keeps on a resize (AppKit's frame origin is the bottom left). diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 41541005..be677fb1 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -42,7 +42,11 @@ accordions, no docks), a rect, an optional native-window rect with the monitor i `minimized`. Its `id` names the window across panel moves (see [Native windows](#native-windows)). `Floating::single` makes a window with one group, `Floating::new` one with a tree, and `Floating::stack` one with groups stacked top to bottom (a vertical split sized by the groups' -heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Saved layouts keep loading: a window stored as one group (v1) or as a +heights, as an app's own stacked layouts migrate); `panels()` and `contains()` read it. Each of +these calls makes a new window with a fresh `id`. Build a layout once and keep it, or give its +windows ids with `with_id`: a layout built anew each frame, as an unsaved default layout might +be, otherwise opens new OS windows each frame and loses their drags and places. Debug builds log +a warning when native windows are replaced by new ones with the same panels three frames running. Saved layouts keep loading: a window stored as one group (v1) or as a stack of groups loads as that group or as a vertical split of the stacked groups, sized by their heights. @@ -56,7 +60,7 @@ use craft_ui::docking::{Action, Floating, Layout, Node, Placement}; let mut layout = Layout { root: Some(Node::Tabs { panels: vec!["layers", "properties"], active: 0 }), - floating: vec![Floating::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0])], + floating: vec![Floating::single(vec!["brushes"], 0, [40.0, 80.0, 240.0, 300.0]).with_id(1)], }; layout.apply(Action::Move { panel: "brushes", @@ -327,7 +331,8 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T (`with_taskbar(false)`). - **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change, - including when its first panel moves out. `Floating::new` and loading draw fresh ids; a layout + including when its first panel moves out. `Floating::new` and loading draw fresh ids, and + `Floating::with_id` sets one; a layout loads with unique ids; a window that merges into another takes the receiver's id, and a whole window that moves (floated again, or its only group dragged) keeps its own. - **Level.** The windows stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the From 2cad4d2bec62f80b4bf59e6376504e63ec5a25b2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:13:34 +0000 Subject: [PATCH 115/160] docking: clippy, a type for a split's children and one argument for a node and its rect --- crates/ui/src/docking/render/mod.rs | 4 ++-- crates/ui/src/docking/render/sizes.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 2f16a8d0..9867b24d 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -143,7 +143,7 @@ impl Renderer<'_, P> { let actions = self.divider_actions(root, path, response.delta); self.output.actions.extend(actions); } - self.intersections(ui, node, rect, path, split.divider, mark, root); + self.intersections(ui, (node, rect), path, split.divider, mark, root); if self.dividers.len() < crate::docking::model::MAX_NODES { self.dividers.push(DrawnDivider { path: path.clone(), axis: *axis, divider: split.divider }); } @@ -285,7 +285,7 @@ impl Renderer<'_, P> { /// Four-way handles where a divider drawn inside the split `node` (since `mark`) ends on /// the split's own divider: dragging one moves both dividers. - fn intersections(&mut self, ui: &mut Ui, node: &Node

, rect: Rect, path: &[bool], divider: Rect, mark: usize, root: (&Node

, Rect)) { + fn intersections(&mut self, ui: &mut Ui, (node, rect): (&Node

, Rect), path: &[bool], divider: Rect, mark: usize, root: (&Node

, Rect)) { let Node::Split { axis, .. } = node else { return }; let side = (self.style.gap * 3.0).max(12.0); let mut found = Vec::new(); diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index 8af0dd5a..1c44cc1a 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -15,6 +15,9 @@ use super::{dock::dock_rects, node_limits, split_bounds}; use crate::docking::{DockContent, DockStyle, Node, PanelLimits}; use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect}; +/// A split's two children with the rects they are drawn in. +type Children<'n, P> = [(&'n Node

, Rect); 2]; + /// Room a group keeps above its minimum after a drop, so each of its dividers can still /// travel both ways. pub(in crate::docking) const DROP_MARGIN: f32 = 1.0; @@ -169,7 +172,7 @@ impl

Sizer<'_, P> { /// The ancestors that continue the line of the split at the end of `chain` along `axis`: /// each with its children and whether the line runs through its second child, nearest first. /// The line ends at a split across the axis or beside a dock. - fn line<'n>(&self, chain: &[(&'n Node

, Rect)], path: &[bool], axis: SplitAxis) -> Vec<(usize, [(&'n Node

, Rect); 2], bool)> { + fn line<'n>(&self, chain: &[(&'n Node

, Rect)], path: &[bool], axis: SplitAxis) -> Vec<(usize, Children<'n, P>, bool)> { let mut line = Vec::new(); for depth in (0..path.len()).rev() { let Some(&(node, rect)) = chain.get(depth) else { break }; From 308163bcb52cdc0e99a5aa9933b35152d1263c26 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:40:17 +0000 Subject: [PATCH 116/160] docking: macOS resizes native windows itself, bands elsewhere are 6 pt with 16 pt corners On macOS the native window is resizable by AppKit again, with its own areas and cursors. The bar's buttons sit 19 pt in from the trailing edge, clear of AppKit's corner area (about 15 pt into the window, a 19 pt square measured on macOS 26), and the window sets no cursor of its own over AppKit's areas. Elsewhere the bands are 6 pt deep with 16 pt corner Ls, and hand the resize to the window system; the manual resize path is gone. --- crates/ui/src/docking/native_tests.rs | 91 +++++++----- crates/ui/src/docking/render/mod.rs | 5 +- crates/ui/src/docking/render/native.rs | 198 ++++++++++--------------- docs/ui-docking.md | 24 +-- 4 files changed, 156 insertions(+), 162 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 24975f0d..89269c66 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{DragPointer, WindowDrag, floats_shown, resize_bands, resized}; +use super::render::{APPKIT_CORNER, DragPointer, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -1000,10 +1000,12 @@ fn a_drag_ends_when_its_native_window_or_panel_goes() { } /// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop -/// and release. Returns the window's OS rect and the driver. -fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver) { +/// and release. Returns the window's OS rect, the resize the window handed to the window +/// system, and the driver. +fn press_and_drag(from: Pos2, by: Vec2) -> (Rect, Option, DockDriver) { let mut d = driver(layout(true)); let id = window(&d, 5).id; + let viewport = d.viewport(id); let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2(); d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); // The pointer is where it was moved on the desktop, wherever the window is now. @@ -1015,53 +1017,57 @@ fn resize_by(from: Pos2, by: Vec2) -> (Rect, DockDriver) { let pos = local(&d, by); d.window_step(id, vec![Event::PointerMoved(pos), button(pos, false)]); d.step(vec![]); - (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), d) + let resize = d.host().commands.iter().find_map(|(v, c)| match c { + ViewportCommand::BeginResize(direction) if *v == viewport => Some(*direction), + _ => None, + }); + (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), resize, d) } #[test] fn the_bars_close_button_wins_over_the_resize_bands() { + use egui::ResizeDirection as D; let mut d = driver(layout(true)); let id = window(&d, 5).id; let viewport = d.viewport(id); - // The × of the 240 pt wide window spans 217..235 across and the whole bar down. Its top + // The × of the 240 pt wide window spans 215..233 across and the whole bar down. Its top // right is next to the window's top-right corner, but it closes the window. - let out = d.window_click(id, pos2(234.0, 1.0)); + let out = d.window_click(id, pos2(232.0, 1.0)); assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); - assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::InnerSize(_))), "not resized"); + assert!(!d.host().commands.iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::BeginResize(_))), "not resized"); - // Beside it, at the window's edges, the bands resize the window from (400, 130), 240 by - // 300, keeping the opposite edges. - let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); - let (resized, d) = resize_by(pos2(238.0, 2.0), vec2(20.0, 20.0)); - assert_eq!(resized, rect(400.0, 150.0, 660.0, 430.0), "the top-right corner"); - assert_eq!(window(&d, 5).native, Some([400.0, 150.0, 260.0, 280.0]), "the layout follows"); - assert_eq!(resize_by(pos2(238.0, 15.0), vec2(20.0, 20.0)).0, rect(400.0, 130.0, 660.0, 430.0), "the right edge beside the ×"); - assert_eq!(resize_by(pos2(120.0, 1.0), vec2(20.0, -20.0)).0, rect(400.0, 110.0, 640.0, 430.0), "the top edge above the bar"); - assert_eq!(resize_by(pos2(2.0, 298.0), vec2(-20.0, 20.0)).0, rect(380.0, 130.0, 640.0, 450.0), "the bottom-left corner"); - assert_eq!(resize_by(pos2(2.0, 150.0), vec2(200.0, 0.0)).0, rect(480.0, 130.0, 640.0, 430.0), "no narrower than 160"); + // Beside it, at the window's edges, the bands hand the resize to the window system. + let resize = |from: Pos2| press_and_drag(from, vec2(20.0, 20.0)).1; + assert_eq!(resize(pos2(238.0, 2.0)), Some(D::NorthEast), "the top-right corner"); + assert_eq!(resize(pos2(237.0, 15.0)), Some(D::NorthEast), "the corner beside the ×"); + assert_eq!(resize(pos2(237.0, 100.0)), Some(D::East), "the right edge"); + assert_eq!(resize(pos2(120.0, 1.0)), Some(D::North), "the top edge above the bar"); + assert_eq!(resize(pos2(2.0, 298.0)), Some(D::SouthWest), "the bottom-left corner"); // Inside a corner's L, the bar moves the window. - let (moved, _) = resize_by(pos2(10.0, 10.0), vec2(20.0, 20.0)); - assert_eq!(moved, rect(420.0, 150.0, 660.0, 450.0)); + let rect = |x0: f32, y0: f32, x1: f32, y1: f32| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let (moved, resize, _) = press_and_drag(pos2(10.0, 10.0), vec2(20.0, 20.0)); + assert_eq!((moved, resize), (rect(420.0, 150.0, 660.0, 450.0), None)); } #[test] fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { let rect = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); - let close = Rect::from_center_size(pos2(226.0, 9.0), vec2(18.0, 18.0)); + let close = Rect::from_center_size(pos2(224.0, 9.0), vec2(18.0, 18.0)); let bands = resize_bands(rect, &[close]); let at = |p: Pos2| bands.iter().find(|(_, band)| band.contains(p)).map(|(direction, _)| *direction); for (_, band) in &bands { assert!(!band.intersect(close).is_positive(), "{band:?} covers the ×"); - assert!(band.width().min(band.height()) <= 4.0, "{band:?} is deeper than an edge band"); + assert!(band.width().min(band.height()) <= 6.0, "{band:?} is deeper than an edge band"); } - // Each edge resizes along its whole length, outside the ×, from 12 pt corners. + // Each edge resizes along its whole length, outside the ×, from 16 pt corners. use egui::ResizeDirection as D; // Which of an edge's three parts `v` is in: the first corner, the edge, the last corner. - let part = |v: f32, length: f32| usize::from(v >= 12.0) + usize::from(v > length - 12.0); + let part = |v: f32, length: f32| usize::from(v >= 16.0) + usize::from(v > length - 16.0); for i in 0..240 { let x = i as f32 + 0.5; let top = [D::NorthWest, D::North, D::NorthEast][part(x, 240.0)]; assert_eq!(at(pos2(x, 0.5)), (!close.contains(pos2(x, 0.5))).then_some(top), "top at {x}"); + assert_eq!(at(pos2(x, 5.5)), (!close.contains(pos2(x, 5.5))).then_some(top), "6 pt down at {x}"); assert_eq!(at(pos2(x, 299.5)), Some([D::SouthWest, D::South, D::SouthEast][part(x, 240.0)]), "bottom at {x}"); } for i in 0..300 { @@ -1069,20 +1075,37 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { assert_eq!(at(pos2(0.5, y)), Some([D::NorthWest, D::West, D::SouthWest][part(y, 300.0)]), "left at {y}"); assert_eq!(at(pos2(239.5, y)), Some([D::NorthEast, D::East, D::SouthEast][part(y, 300.0)]), "right at {y}"); } - assert_eq!(at(pos2(6.0, 6.0)), None, "a corner is an L along its edges"); - assert_eq!(at(pos2(120.0, 4.5)), None, "the bar below the top band"); + assert_eq!(at(pos2(10.0, 10.0)), None, "a corner is an L along its edges"); + assert_eq!(at(pos2(120.0, 6.5)), None, "the bar below the top band"); } #[test] -fn a_manual_resize_keeps_the_opposite_edges_and_the_minimum_size() { - use egui::ResizeDirection as D; - let (start, min) = ([100.0, 50.0, 300.0, 200.0], [160.0, 100.0]); - // Right edge 400, bottom 250. - assert_eq!(resized(start, D::NorthWest, vec2(-20.0, -10.0), min), [80.0, 40.0, 320.0, 210.0]); - assert_eq!(resized(start, D::East, vec2(30.0, 99.0), min), [100.0, 50.0, 330.0, 200.0]); - assert_eq!(resized(start, D::South, vec2(30.0, -20.0), min), [100.0, 50.0, 300.0, 180.0]); - assert_eq!(resized(start, D::SouthWest, vec2(500.0, -500.0), min), [240.0, 50.0, 160.0, 100.0]); - assert_eq!(resized(start, D::NorthEast, vec2(-500.0, 500.0), min), [100.0, 150.0, 160.0, 100.0]); +fn on_macos_the_bars_buttons_keep_clear_of_appkits_corner_resize_areas() { + use crate::docking::chrome::{ChromeStyle, TitleBar}; + let ctx = egui::Context::default(); + for right_to_left in [false, true] { + let mut buttons = Vec::new(); + let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { + let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; + let bar = Rect::from_min_size(full.min, vec2(full.width(), crate::docking::placement::BAR_HEIGHT)); + let shown = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").menu("Menu").close("Close").show( + ui, + buttons_rect(bar, right_to_left, true), + &style, + ); + buttons.extend(shown.close.iter().chain(&shown.menu).map(|r| r.rect)); + }); + output.textures_delta.clear(); + assert_eq!(buttons.len(), 2); + let [top_left, top_right, ..] = appkit_resize_areas(full); + let corner = if right_to_left { top_left } else { top_right }; + assert_eq!(corner.width(), APPKIT_CORNER); + for button in buttons { + // The × keeps 4 pt clear across; the bar, 18 pt high, cannot keep it below. + assert!(corner.expand2(vec2(4.0, 0.0)).intersect(button).width() <= 0.0, "{button:?} reaches into {corner:?}"); + } + } } /// [`layout`] built anew, its window with id 7 when `with_id`. diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 862117f1..36ce1f11 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -15,7 +15,10 @@ mod iconic; #[cfg(feature = "native-windows")] mod native; #[cfg(all(test, feature = "native-windows"))] -pub(in crate::docking) use {cross::DragPointer, native::WindowDrag, native::floats_shown, native::resize_bands, native::resized}; +pub(in crate::docking) use { + cross::DragPointer, + native::{APPKIT_CORNER, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}, +}; /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 529d1c7e..0279e28c 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -26,14 +26,27 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0]; const HIDE_DELAY: f64 = 0.25; /// How long reports of a native window's place may lag a move the dock area commanded. const MOVE_WAIT: f64 = 0.5; -/// How far the resize bands reach into a native window from its edges. -const EDGE: f32 = 4.0; +/// How far the resize bands reach into a native window from its edges, where the window has +/// no frame of its own to resize from. +const EDGE: f32 = 6.0; /// How far a corner's grab runs along each of its two edges. -const CORNER: f32 = 12.0; -/// The window system runs a resize from our bands (`BeginResize`). winit cannot on macOS, so -/// there the window follows the pointer by `InnerSize` and `OuterPosition`. Unit tests take -/// that path on every platform, since the test host carries it out. -const SYSTEM_RESIZE: bool = !cfg!(any(target_os = "macos", test)); +const CORNER: f32 = 16.0; +/// macOS keeps the window's frame, and AppKit resizes the window from its edges and corners. +/// Elsewhere the window is undecorated and resizes from our bands, which hand the resize to +/// the window system (`BeginResize`). +const OS_RESIZES: bool = cfg!(target_os = "macos"); +/// How far AppKit's corner resize area reaches into a titled, resizable window along each +/// edge. AppKit takes a press there before the content sees it, and shows its resize cursor. +/// Measured on macOS 26 as a 19 pt square at each corner, 62 % of which would lie inside a +/// square-cornered window: about 15 pt inside and 4 pt outside +/// (). +/// There is no published measurement for earlier releases. +pub(in crate::docking) const APPKIT_CORNER: f32 = 15.0; +/// How far AppKit's edge resize area reaches into the content between the corners: a few +/// points; most of it lies outside the window. +const APPKIT_EDGE: f32 = 4.0; +/// Room between AppKit's corner area and the bar's buttons. +const CORNER_MARGIN: f32 = 4.0; /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -51,21 +64,6 @@ pub(in crate::docking) struct WindowDrag { pub docks: bool, } -/// A resize of a native window from one of its bands where the window system does not run it -/// ([`SYSTEM_RESIZE`]), in the context's data under the area's `"window-resize"`. -#[derive(Clone, Copy, Debug)] -struct WindowResize { - /// [`Floating::id`] of the resized window. - window: u64, - direction: ResizeDirection, - /// Desktop rect of the window's content when the drag began. - start: [f32; 4], - /// Desktop point where the pointer pressed. - press: Pos2, - /// Desktop rect of the window's content as last commanded. - to: [f32; 4], -} - /// What the dock area knows of a shown native window's place, in the context's data under /// the area's `("native-place", viewport)`. Rects are desktop rects of the window's content. #[derive(Clone, Copy, Debug)] @@ -163,15 +161,11 @@ impl Renderer<'_, P> { #[cfg(debug_assertions)] self.warn_rebuilt(ctx, &natives); self.escape_elsewhere(ctx, None); - // A window closed or docked during its own bar drag or resize ends it. - let shown = |window: u64| natives.iter().any(|(w, ..)| w.id == window); - let (key, resize_key) = (self.id.with("window-drag"), self.id.with("window-resize")); - if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !shown(d.window)) { + // A window closed or docked during its own bar drag ends the drag. + let key = self.id.with("window-drag"); + if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(key)); } - if ctx.data(|d| d.get_temp::(resize_key)).is_some_and(|r| !shown(r.window)) { - ctx.data_mut(|d| d.remove::(resize_key)); - } self.map_desktop(ctx, &natives, visibility.0); // The main window owns drags of the panels it draws. let id = self.id; @@ -317,16 +311,16 @@ impl Renderer<'_, P> { .with_min_inner_size(MIN_SIZE) .with_window_level(WindowLevel::AlwaysOnTop) .with_taskbar(false); - // The bar replaces the OS title bar. macOS keeps the frame for its shadow and rounded - // corners, but not its resizing: AppKit's resize areas reach into the content over the - // bar's × and take the pointer from it. The bands resize the window instead. + // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and + // buttons hidden: AppKit resizes a movable window from its edges and corners with its + // own cursors, and draws its rounded corners and standard shadow. #[cfg(target_os = "macos")] let builder = builder .with_titlebar_shown(false) .with_title_shown(false) .with_fullsize_content_view(true) .with_titlebar_buttons_shown(false) - .with_resizable(false); + .with_resizable(window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); if toggled || (info.is_none() && !visible) { @@ -348,8 +342,11 @@ impl Renderer<'_, P> { return Report::default(); } if window.panels().all(|p| self.permissions(p).movable) { - self.resize_edges(ui, window.id, full, &buttons); - self.follow_window_resize(ui, window.id); + if OS_RESIZES { + leave_cursor_to_os(ui, full); + } else { + resize_edges(ui, self.id.with(("native-edges", window.id)), full, &buttons); + } } let mut surface = Surface { window: window.id, @@ -433,14 +430,14 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); ui.painter().rect_filled(rect, 0.0, style.title_background); - // The buttons keep clear of the side's resize band, so the corner's grab runs down - // beside them. - let rect = if style.right_to_left { rect.with_min_x(rect.left() + EDGE) } else { rect.with_max_x(rect.right() - EDGE) }; + let rect = buttons_rect(rect, style.right_to_left, OS_RESIZES); let movable = window.panels().all(|p| self.permissions(p).movable); let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); - let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable); + // On macOS the bar keeps the arrow, as native title bars do: AppKit's resize areas + // cover its ends and top edge, and a cursor set over them flickers with AppKit's. + let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES); if closable { bar = bar.close(&close_label); } @@ -559,62 +556,6 @@ impl Renderer<'_, P> { self.cross.release = Some((drag, pointer)); } - /// The resize bands of a movable native window. Drawn after its content, so they take the - /// pointer at its edges; the bar's `buttons` are left out. - fn resize_edges(&mut self, ui: &Ui, window: u64, full: Rect, buttons: &[Rect]) { - let id = self.id.with(("native-edges", window)); - for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { - let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); - if response.drag_started() { - self.start_window_resize(ui, window, direction); - } - } - } - - /// Start a resize: by the window system where it runs one, else by following the pointer - /// from the place the window system reports. - fn start_window_resize(&mut self, ui: &Ui, window: u64, direction: ResizeDirection) { - let press = ui.input(|i| i.pointer.press_origin().or(i.pointer.latest_pos())); - let content = ui.input(|i| placed(i.viewport())); - match (SYSTEM_RESIZE, content, press) { - (false, Some(start), Some(press)) => { - let resize = WindowResize { window, direction, start, press: pos2(start[0], start[1]) + press.to_vec2(), to: start }; - ui.ctx().data_mut(|d| d.insert_temp(self.id.with("window-resize"), resize)); - } - _ => ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)), - } - } - - /// Size and move the window while the button is held, the edges opposite the grabbed band - /// in place, no smaller than [`MIN_SIZE`]. Released, a window left with too little of its - /// bar on a monitor goes back onto the nearest, as after a bar drag. - fn follow_window_resize(&mut self, ui: &Ui, window: u64) { - let key = self.id.with("window-resize"); - let Some(mut resize) = ui.ctx().data(|d| d.get_temp::(key)).filter(|r| r.window == window) else { return }; - ui.ctx().set_cursor_icon(resize_cursor(resize.direction)); - let content = ui.input(|i| content_of(i.viewport())); - let frame = content.map_or(Vec2::ZERO, |(_, frame)| frame); - // As in a bar drag: a pointer move is relative to where the window system reports the - // window now. - let moved = ui.input(|i| i.events.iter().rev().find_map(|e| if let Event::PointerMoved(p) = e { Some(*p) } else { None })); - let mut to = resize.to; - if let (Some((origin, _)), Some(local)) = (content, moved.filter(|p| p.is_finite())) { - to = resized(resize.start, resize.direction, origin + local.to_vec2() - resize.press, MIN_SIZE); - } - let over = !ui.input(|i| i.pointer.primary_down()) || !self.cross.focused; - if over { - to = kept_reachable(to, &self.monitors); - ui.ctx().data_mut(|d| d.remove::(key)); - } - if differs(to, resize.to) { - place_window(ui.ctx(), ui.ctx().viewport_id(), to, frame); - resize.to = to; - if !over { - ui.ctx().data_mut(|d| d.insert_temp(key, resize)); - } - } - } - /// Keys, text and clipboard events the window's widgets left over, for the application's /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. fn forward_events(&mut self, ui: &Ui) { @@ -660,6 +601,52 @@ fn place_window(ctx: &Context, viewport: ViewportId, to: [f32; 4], frame: Vec2) ctx.send_viewport_cmd_to(viewport, ViewportCommand::OuterPosition(pos2(to[0], to[1]) - frame)); } +/// The resize bands of a movable native window without a frame of its own. Drawn after its +/// content, so they take the pointer at its edges; the bar's `buttons` are left out. +fn resize_edges(ui: &Ui, id: Id, full: Rect, buttons: &[Rect]) { + for (i, (direction, band)) in resize_bands(full, buttons).into_iter().enumerate() { + let response = ui.interact(band, id.with(i), Sense::drag()).on_hover_cursor(resize_cursor(direction)); + if response.drag_started() { + ui.ctx().send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } +} + +/// Over AppKit's resize areas of the window's content `full`, the default cursor whatever the +/// content asked for: AppKit shows its resize cursor there, and any other cursor we set fights +/// it. +fn leave_cursor_to_os(ui: &Ui, full: Rect) { + if ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| appkit_resize_areas(full).iter().any(|area| area.contains(p))) { + ui.ctx().set_cursor_icon(CursorIcon::Default); + } +} + +/// AppKit's resize areas inside a window's content `rect`: a square at each corner and a strip +/// along each edge. +pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] { + let (c, e) = (Vec2::splat(APPKIT_CORNER), APPKIT_EDGE); + [ + Rect::from_min_size(rect.left_top(), c), + Rect::from_min_size(rect.right_top() - vec2(c.x, 0.0), c), + Rect::from_min_size(rect.left_bottom() - vec2(0.0, c.y), c), + Rect::from_min_size(rect.right_bottom() - c, c), + rect.with_max_y(rect.top() + e), + rect.with_min_y(rect.bottom() - e), + rect.with_max_x(rect.left() + e), + rect.with_min_x(rect.right() - e), + ] +} + +/// The part of a native window's bar `rect` its buttons are laid out in, its trailing end +/// pulled in. Where AppKit resizes the window (`os_resizes`) the buttons keep clear of its +/// corner area, which takes presses from the content; the bar is too short to keep them below +/// it. Elsewhere they keep clear of the side's resize band, so the corner's grab runs down +/// beside them. +pub(in crate::docking) fn buttons_rect(rect: Rect, right_to_left: bool, os_resizes: bool) -> Rect { + let inset = if os_resizes { APPKIT_CORNER + CORNER_MARGIN } else { EDGE }; + if right_to_left { rect.with_min_x(rect.left() + inset) } else { rect.with_max_x(rect.right() - inset) } +} + /// The bands along the edges of a native window's content `rect` that resize it: an L at each /// corner, [`EDGE`] deep and [`CORNER`] along both edges, and the edges between them. They /// leave out the bar's `buttons`, which take their own clicks. @@ -709,27 +696,6 @@ fn outside(rect: Rect, hole: Rect) -> Vec { .collect() } -/// The desktop rect `start` with the edges `direction` names moved by `delta`, the opposite -/// edges kept, and no smaller than `min`. -pub(in crate::docking) fn resized(start: [f32; 4], direction: ResizeDirection, delta: Vec2, min: [f32; 2]) -> [f32; 4] { - use ResizeDirection as D; - let [x, y, w, h] = start; - let (mut left, mut top, mut right, mut bottom) = (x, y, x + w, y + h); - if matches!(direction, D::West | D::NorthWest | D::SouthWest) { - left = (left + delta.x).min(right - min[0]); - } - if matches!(direction, D::East | D::NorthEast | D::SouthEast) { - right = (right + delta.x).max(left + min[0]); - } - if matches!(direction, D::North | D::NorthWest | D::NorthEast) { - top = (top + delta.y).min(bottom - min[1]); - } - if matches!(direction, D::South | D::SouthWest | D::SouthEast) { - bottom = (bottom + delta.y).max(top + min[1]); - } - [left, top, right - left, bottom - top] -} - fn resize_cursor(direction: ResizeDirection) -> CursorIcon { use {CursorIcon as C, ResizeDirection as D}; match direction { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 853f77cf..d014c0f9 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -347,17 +347,19 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T - **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On Wayland, where a window cannot be placed, the bar hands the move to the compositor. On - Windows and Linux the OS frame is off. On macOS the frame stays for its shadow and rounded - corners, with its title bar and buttons hidden, and is not resizable: AppKit's resize areas - reach well into the content and would take clicks from the ×. -- **Resize.** A movable window resizes from bands 4 pt deep along its edges. Each corner is an - L along both edges, 12 pt long and as deep as the edges. The bands leave out the bar's - buttons, which sit 4 pt in from the window's edge so the corner's grab runs down beside - them; elsewhere the top band lies over the bar's drag area. The OS runs the resize - (`ViewportCommand::BeginResize`), except on macOS, where winit cannot: there the window - follows the pointer by `InnerSize` and `OuterPosition`, keeping the opposite edges and the - 160 by 100 pt minimum, and a window released with too little of its bar on a monitor moves - back onto the nearest one, as after a bar drag. + Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and + buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area + paints no shadow of its own around a native window. +- **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it, + with its own areas and cursors. Its corner area reaches about 15 pt into the window along + each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees + them, so the bar's buttons sit 19 pt in from the window's trailing edge; the 18 pt bar is too + short to keep them below it. Over AppKit's areas the window sets no cursor of its own, which + would flicker with AppKit's. Elsewhere the window resizes from bands 6 pt deep along its + edges, and each corner is an L along both edges, 16 pt long and as deep as the edges. The + bands leave out the bar's buttons, which sit 6 pt in from the window's edge so the corner's + grab runs down beside them; elsewhere the top band lies over the bar's drag area. The OS + runs the resize from a band (`ViewportCommand::BeginResize`). - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's From 2f90228e10951f73396b4146fa3826f0a011d4d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:41:27 +0000 Subject: [PATCH 117/160] docking: a narrow group splits when its neighbours have room for both halves A side split that leaves a half at its minimum is taken when the dividers along that half can still travel, the groups beyond giving way as they do for a dragged divider. An 80 pt group, whose halves need 84, splits on all four sides and takes the rest from its neighbour; only when the groups beyond are at their minimums too does the drop join the group's tabs. --- crates/ui/src/docking/render/preview.rs | 39 +++++++++++++-------- crates/ui/src/docking/render/sizes.rs | 32 +++++++++++++++-- crates/ui/src/docking/split_tests.rs | 46 ++++++++++++++++++++++++- docs/ui-docking.md | 5 +-- 4 files changed, 103 insertions(+), 19 deletions(-) diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 33f234cb..e280f5d8 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -8,6 +8,7 @@ use crate::docking::{ Action, Drag, DropProposal, DropRequest, DropTarget, GroupDrag, GroupGesture, Layout, Node, Placement, Target, Zone, cancel_drag, dock_model::Place, resolve_drop, surface::TAB_PADDING, }; +use crate::layout::SplitAxis; /// Ctrl (Windows, Linux) or Cmd (macOS) held: the drop floats and no target is offered. fn float_only(ctx: &Context) -> bool { @@ -139,31 +140,41 @@ impl Renderer<'_, P> { } /// Whether `action`, a drop that splits a group or the outer edge, leaves room: every group - /// it makes smaller stays [`DROP_MARGIN`] above its minimum, so each divider can still - /// travel both ways. An action the model rejects is left to the model's check. + /// it makes smaller keeps its minimum, and each divider can still travel both ways. A group + /// within [`DROP_MARGIN`] of its minimum along an axis passes when the dividers along its + /// sides can travel, the groups beyond it giving way as they do for a dragged divider, so a + /// narrow group still splits when its neighbours have room for both halves. An action the + /// model rejects is left to the model's check. pub(super) fn leaves_room(&self, action: Action

) -> bool { let mut candidate = self.layout.clone(); if candidate.apply_with_permissions(action, |p| self.permissions(p)).is_err() { return true; } let sizer = self.sizer(); - let drawn = |layout: &Layout

| { + let drawn = |root: &Node

, area: Rect| { let mut out: Vec> = Vec::new(); - if let Some(root) = &layout.root { - sizer.groups(root, self.workspace, &mut out); - } - for window in layout.floating.iter().filter(|w| !w.minimized) { - sizer.groups(&window.root, self.window_body(window), &mut out); - } - out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect.size(), d.limits.min))).collect::>() + sizer.groups(root, area, &mut out); + out.into_iter().filter_map(|d| Some((lead(d.node)?.clone(), d.rect, d.limits.min))).collect::>() }; - let before: HashMap = drawn(self.layout).into_iter().map(|(panel, size, _)| (panel, size)).collect(); - drawn(&candidate).into_iter().all(|(panel, size, min)| { - let room = size - min; - (room.x >= DROP_MARGIN && room.y >= DROP_MARGIN) || before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + let before: HashMap = + self.trees(self.layout).into_iter().flat_map(|(root, area)| drawn(root, area)).map(|(panel, rect, _)| (panel, rect.size())).collect(); + self.trees(&candidate).into_iter().all(|(root, area)| { + drawn(root, area).into_iter().all(|(panel, rect, min)| { + let (size, room) = (rect.size(), rect.size() - min); + let fits = |room: f32, axis| room >= DROP_MARGIN || sizer.frees((root, area), rect, axis); + before.get(&panel).is_some_and(|old| size.x >= old.x - 0.5 && size.y >= old.y - 0.5) + || (room.min_elem() > -0.01 && fits(room.x, SplitAxis::Horizontal) && fits(room.y, SplitAxis::Vertical)) + }) }) } + /// The trees of `layout` as drawn: the docked root in the workspace and each open floating + /// window's tree in its body. + fn trees<'l>(&self, layout: &'l Layout

) -> Vec<(&'l Node

, Rect)> { + let windows = layout.floating.iter().filter(|w| !w.minimized).map(|w| (&w.root, self.window_body(w))); + layout.root.iter().map(|root| (root, self.workspace)).chain(windows).collect() + } + /// Release of a group drag: join or split at the target, or float at the ghost with the /// group's size. Over its own docked position nothing changes; a floating group released /// over itself moves there. diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index 1c44cc1a..4d3fae3e 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -18,8 +18,8 @@ use crate::layout::{SplitAxis, SplitLayout, SplitSize, split_rect}; /// A split's two children with the rects they are drawn in. type Children<'n, P> = [(&'n Node

, Rect); 2]; -/// Room a group keeps above its minimum after a drop, so each of its dividers can still -/// travel both ways. +/// How far each divider beside a group that a drop makes smaller can still travel both ways: +/// the group keeps this much above its minimum, or the groups beyond it give the room. pub(in crate::docking) const DROP_MARGIN: f32 = 1.0; pub(in crate::docking) struct Sizer<'a, P> { @@ -277,6 +277,34 @@ impl

Sizer<'_, P> { } (-first_in.min(second_out), first_out.min(second_in)) } + + /// Whether every divider of `root` across `axis` that runs along a side of the group drawn + /// at `group` can travel [`DROP_MARGIN`] both ways, by [`Self::range`]: a group at its + /// minimum holds no divider still while the groups beyond it can give way. + pub(in crate::docking) fn frees(&self, root: (&Node

, Rect), group: Rect, axis: SplitAxis) -> bool { + let edges = |rect: Rect| match axis { + SplitAxis::Horizontal => (rect.left(), rect.right()), + SplitAxis::Vertical => (rect.top(), rect.bottom()), + }; + let (start, end) = edges(group); + let beside = |child: Rect, edge: f32, own: f32| child.contains(group.center()) && (edge - own).abs() < 0.5; + let mut nodes = vec![(root.0, root.1, Vec::new())]; + while let Some((node, rect, path)) = nodes.pop() { + let (Node::Split { axis: split_axis, .. }, Some([a, b])) = (node, self.children(node, rect)) else { continue }; + if *split_axis == axis && (beside(a.1, edges(a.1).1, end) || beside(b.1, edges(b.1).0, start)) { + let (lo, hi) = self.range(root, &path); + if -lo < DROP_MARGIN || hi < DROP_MARGIN { + return false; + } + } + for (second, (child, rect)) in [(false, a), (true, b)] { + let mut path = path.clone(); + path.push(second); + nodes.push((child, rect, path)); + } + } + true + } } /// The size of a split at `path` whose first child is `first` of `available` points. diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index ea3283ad..e4b6774f 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -193,8 +193,10 @@ fn groups_have_one_small_minimum_and_no_maximum() { drag_hit(&mut d, divider(&[true, true]), vec2(-2000.0, 0.0)); assert_eq!(group(&d, TOOLS).width(), 40.0, "and shrinks to the minimum, no further"); - // A group too narrow for two halves takes a drop on its edge as a tab. + // At its minimum, with Project left of it at its own, it has no room for two halves and + // takes a drop on its edge as a tab. let tools = group(&d, TOOLS); + assert_eq!(group(&d, PROJECT[0]).width(), 40.0); let out = d.drag(tab(&d, INFO), pos2(tools.right() - 4.0, tools.center().y + 30.0)); assert!( matches!(&out.actions[..], [Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(Zone::Center) | Placement::Tab { .. } }]), @@ -801,3 +803,45 @@ fn zones_a_group_does_not_take_are_not_drawn() { d.hold(tab(&d, TIMELINE), timeline.center() + vec2(0.0, 13.0)); assert!(zone_outlines(&d).is_empty()); } + +#[test] +fn a_narrow_group_splits_on_every_side_and_its_neighbours_give_the_room() { + // Tools at 80 pt: two halves need 84, so a side split takes the rest from Timeline. + let narrow = || { + let mut d = film(); + let width = group(&d, TOOLS).width(); + drag_hit(&mut d, divider(&[true, true]), vec2(80.0 - width, 0.0)); + d + }; + let d = narrow(); + let (tools, timeline) = (group(&d, TOOLS), group(&d, TIMELINE)); + assert_eq!(tools.width().round(), 80.0); + let body_top = tools.top() + 26.0; + for (zone, at) in [ + (Zone::Left, pos2(tools.left() + 5.0, tools.center().y)), + (Zone::Right, pos2(tools.right() - 5.0, tools.center().y)), + (Zone::Top, pos2(tools.center().x, body_top + 5.0)), + (Zone::Bottom, pos2(tools.center().x, tools.bottom() - 15.0)), + ] { + let mut d = narrow(); + d.hold(tab(&d, INFO), at); + assert_eq!(zone_outlines(&d).len(), 6, "{zone:?}: four sides, the center and the strip"); + assert!(d.filled_polygon(&zone_shape(tools, 26.0, 40.0, zone).unwrap()), "{zone:?} is filled"); + let out = d.release(at); + assert_eq!(out.actions, vec![Action::Move { panel: INFO, anchor: TOOLS, placement: Placement::Split(zone) }]); + d.settle(); + let (info, split) = (group(&d, INFO), group(&d, TOOLS)); + let beside = match zone { + Zone::Left => info.right() < split.left(), + Zone::Right => info.left() > split.right(), + Zone::Top => info.bottom() < split.top(), + _ => info.top() > split.bottom(), + }; + assert!(beside, "{zone:?}: Info {info:?}, Tools {split:?}"); + if matches!(zone, Zone::Left | Zone::Right) { + assert_eq!((info.width(), split.width()), (40.0, 40.0), "{zone:?}"); + assert!((group(&d, TIMELINE).width() - (timeline.width() - 4.0)).abs() < 1.0, "{zone:?}: Timeline gives the room"); + } + assert_sizes(&d, &format!("{zone:?}")); + } +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 33b27d09..0e039ec1 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -212,8 +212,9 @@ A flyout fits its content the same way, without the title bar. - Over a frame, the tab strip and the middle join its tabs, and the nearest side splits it. The side zones are trapezoids along the four edges of the body below the tab strip, like a picture frame, all four the same depth: `DockStyle::zone_depth` (40 pt), or a quarter of the body's - smaller side when that is less, so every frame keeps a center. A split that would leave either half without - `DockStyle::min_pane` joins the frame's tabs instead. The gap between two frames is no target. + smaller side when that is less, so every frame keeps a center. A frame too narrow or short for two + halves of `DockStyle::min_pane` still splits when the frames beyond it can give the room, as they + do for a dragged divider; when they cannot, the drop joins the frame's tabs instead. The gap between two frames is no target. - The outer edge band (`DockStyle::edge_band`) takes a drop as a new frame across the whole window side: top and bottom, and left and right with `side_edge_zones`. It takes a quarter of the window and is offered only when every frame keeps its minimum. Past the band, a frame's own From ba00e9fd9e951bb5dc4593ec3c4e1d55e48b2b8a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:43:18 +0000 Subject: [PATCH 118/160] craft-ui: end drags and resizes on a focus loss, not while a window reports no focus egui-winit starts RawInput::focused false and only a winit Focused event sets it. A window that egui never heard was focused cancelled every tab, group and window drag and every resize on its first frame. Gestures now end on a focused-to-unfocused transition (or Escape); a window that never reported focus still drags while the pointer is pressed in it. --- crates/ui/src/docking/chrome.rs | 4 +- crates/ui/src/docking/dock_tests.rs | 43 +++++++++++++++- crates/ui/src/docking/native_tests.rs | 13 +++++ crates/ui/src/docking/render/cross.rs | 8 +-- crates/ui/src/docking/render/floating.rs | 3 +- crates/ui/src/docking/render/native.rs | 4 +- crates/ui/src/docking/render/preview.rs | 4 +- crates/ui/src/focus.rs | 64 ++++++++++++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/panes/mod.rs | 2 +- 10 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 crates/ui/src/focus.rs diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 2933ffd0..724df73f 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -180,7 +180,7 @@ impl<'a> FloatingFrame<'a> { let mut started = None; let mut responses = Vec::new(); let limits = PanelLimits { min: self.limits.min.max(vec2(40.0, RESIZE_BAND + TITLE_HEIGHT + 20.0)).min(self.limits.max), ..self.limits }; - let canceled = ui.input(|input| input.key_pressed(Key::Escape) || !input.focused); + let canceled = ui.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ui.ctx()); for (name, handle, direction) in handles(rect) { if !super::targets::usable(handle) { continue; @@ -409,7 +409,7 @@ pub fn live_move(ctx: &Context, bar: &Response, rect: Rect, bounds: Rect) -> Opt } let (start, origin) = ctx.data(|data| data.get_temp::<(Rect, Pos2)>(key))?; let forget = || ctx.data_mut(|data| data.remove::<(Rect, Pos2)>(key)); - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { forget(); return Some(MoveStep::Canceled(start)); } diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 7819667f..265ae188 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -84,7 +84,23 @@ impl Driver { } fn with(layout: Layout, content: TestContent, right_to_left: bool) -> Self { - let mut driver = Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }; + Self::start(Self { ctx: Context::default(), layout, content, focused: true, right_to_left, shapes: Vec::new(), time: 0.0 }) + } + + /// A window egui never heard has focus (macOS can make it key without a `Focused` event). + fn never_focused(layout: Layout) -> Self { + Self::start(Self { + ctx: Context::default(), + layout, + content: TestContent::default(), + focused: false, + right_to_left: false, + shapes: Vec::new(), + time: 0.0, + }) + } + + fn start(mut driver: Self) -> Self { // Floating areas settle after their sizing pass. for _ in 0..3 { driver.step(vec![]); @@ -515,6 +531,31 @@ fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel( assert_eq!(d.layout, layout); } +#[test] +fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() { + let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]); + if let Some(Node::Split { size, .. }) = &mut layout.root { + *size = SplitSize::FixedFirst(480.0); + } + let to = pos2(484.0, 300.0); + let mut d = Driver::never_focused(layout.clone()); + let out = d.drag(d.tab(3).center(), to); + assert_eq!(out.actions, vec![Action::Move { panel: 3, anchor: 4, placement: Placement::Column { after: true } }]); + + // Focus that arrives during the drag and goes again ends it. + let mut d = Driver::never_focused(layout.clone()); + d.hold(d.tab(3).center(), to); + d.focused = true; + d.step(vec![Event::WindowFocused(true)]); + assert!(is_dragging::(&d.ctx, Id::new(AREA))); + d.focused = false; + d.step(vec![Event::WindowFocused(false)]); + let out = d.step(vec![button(to, false)]); + assert!(out.actions.is_empty(), "{out:?}"); + assert_eq!(d.layout, layout); + assert!(!is_dragging::(&d.ctx, Id::new(AREA))); +} + #[test] fn a_dock_wider_than_the_window_shrinks_to_fit_and_resizes() { let widths = render::fit_widths(&[(600.0, Some(40.0)), (600.0, Some(40.0)), (48.0, None)], 800.0); diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 89269c66..db123d72 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -940,6 +940,19 @@ fn escape_in_a_focused_native_window_cancels_a_main_window_drag() { assert!(!super::is_dragging::(&d.ctx, d.id)); } +#[test] +fn a_main_window_without_focus_still_drags_a_tab_into_another_group() { + let mut d = driver(layout(false)); + d.host().focus = None; + d.settle(); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let over = d.hit(&Hit::Tab(3)).unwrap().center(); + d.hold(tab, over); + assert!(super::is_dragging::(&d.ctx, d.id)); + let out = d.release(over); + assert!(out.actions.iter().any(|a| matches!(a, Action::Move { panel: 1, .. })), "{:?}", out.actions); +} + #[test] fn focus_moving_to_a_native_window_mid_drag_keeps_the_drag() { let mut d = driver(two_windows()); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index 3a17f307..b30e058c 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -81,8 +81,8 @@ enum Dest { pub(in crate::docking) struct Cross

{ /// Viewports are native: drags cross windows. pub on: bool, - /// One of the application's windows has focus. - pub focused: bool, + /// The application's windows lost focus this pass: one had it on the last pass, none now. + pub focus_lost: bool, /// The main window's content on the desktop, where known. pub main: Option, /// Shown native windows' desktop rects, front first. @@ -109,7 +109,7 @@ impl

Default for Cross

{ fn default() -> Self { Self { on: false, - focused: true, + focus_lost: false, main: None, windows: Vec::new(), surfaces: Vec::new(), @@ -432,7 +432,7 @@ impl Renderer<'_, P> { pointer: at, down: pointer.down, released: pointer.released, - focused: self.cross.focused, + focused: !self.cross.focus_lost, gone: false, escape: pointer.escape, command: pointer.command, diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index ceaecb95..b1a2a419 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -153,7 +153,8 @@ impl Renderer<'_, P> { && let Some(drag) = ui.ctx().data(|d| d.get_temp::>(self.id.with("group-drag"))) && drag.gesture == GroupGesture::Window && let Some(pointer) = ui.ctx().pointer_interact_pos() - && !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused) + && !ui.input(|i| i.key_pressed(Key::Escape)) + && !crate::focus::lost(ui.ctx()) { // From the press origin, so fractional pointer steps do not accumulate rounding. let moved = clamp_float(drag.source.translate(pointer - drag.origin), self.float_bounds); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 0279e28c..952027cd 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -251,7 +251,7 @@ impl Renderer<'_, P> { let focused = i.focused || infos().any(|v| v.focused == Some(true)) || infos().all(|v| v.focused.is_none()); (focused, i.time) }); - self.cross.focused = focused; + self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); let key = self.id.with("native-visibility"); let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); let last = if focused { now } else { last }; @@ -525,7 +525,7 @@ impl Renderer<'_, P> { ui.ctx().send_viewport_cmd(ViewportCommand::OuterPosition(to - frame)); r.cross.moved = Some(to); }; - if pointer.escape || !self.cross.focused { + if pointer.escape || self.cross.focus_lost { if pointer.escape { move_to(self, drag.start); ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 0bf8da81..b746c8e6 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -27,6 +27,7 @@ pub(super) struct DragInput { pub pointer: Option, pub down: bool, pub released: bool, + /// The window kept its focus: false on the pass it loses it (see [`crate::focus`]). pub focused: bool, /// The pointer left the window. pub gone: bool, @@ -41,11 +42,12 @@ impl DragInput { /// This window's own input. fn of(ctx: &Context) -> Self { let command = float_only(ctx); + let focus_lost = crate::focus::lost(ctx); ctx.input(|i| Self { pointer: i.pointer.interact_pos().filter(|p| p.is_finite()), down: i.pointer.primary_down(), released: i.pointer.primary_released(), - focused: i.focused, + focused: !focus_lost, gone: !i.pointer.has_pointer(), escape: i.key_pressed(Key::Escape), command, diff --git a/crates/ui/src/focus.rs b/crates/ui/src/focus.rs new file mode 100644 index 00000000..006f8f82 --- /dev/null +++ b/crates/ui/src/focus.rs @@ -0,0 +1,64 @@ +//! Window focus as pointer gestures read it. +//! +//! A drag or resize ends when its window loses focus, not while the window merely reports no +//! focus. egui-winit starts `RawInput::focused` false and only a winit `Focused` event sets it +//! (on macOS to `has_focus()` at the time the event is read), so a window can take pointer +//! input for a long time while egui reports it unfocused, at first launch until the user +//! switches to another application and back. Gestures in such a window still run. + +use egui::{Context, Id}; + +#[derive(Clone, Copy)] +struct Seen { + pass: u64, + before: bool, + now: bool, +} + +/// Whether `now` is false and was true on the previous pass, as recorded under `key`. With no +/// record from the previous pass there is no transition, so a gesture asks on every pass it +/// runs. Asking again in the same pass gives the same answer. +pub(crate) fn turned_false(ctx: &Context, key: Id, now: bool) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| { + let seen = match d.get_temp::(key) { + Some(seen) if seen.pass == pass => seen, + Some(seen) if seen.pass + 1 == pass => Seen { pass, before: seen.now, now }, + _ => Seen { pass, before: now, now }, + }; + d.insert_temp(key, seen); + seen.before && !seen.now + }) +} + +/// Whether the window being drawn lost focus this pass: focused on the previous pass, not now. +pub(crate) fn lost(ctx: &Context) -> bool { + let focused = ctx.input(|i| i.focused); + turned_false(ctx, Id::new("craft-ui-window-focus").with(ctx.viewport_id()), focused) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::RawInput; + + fn pass(ctx: &Context, focused: bool) -> bool { + let mut lost_now = false; + let mut out = ctx.run_ui(RawInput { focused, ..Default::default() }, |ui| { + lost_now = lost(ui.ctx()); + assert_eq!(lost(ui.ctx()), lost_now, "asking twice in a pass"); + }); + out.textures_delta.clear(); + lost_now + } + + #[test] + fn only_a_focused_to_unfocused_pass_is_a_loss() { + let ctx = Context::default(); + assert!(!pass(&ctx, false), "a window that never had focus has not lost it"); + assert!(!pass(&ctx, false)); + assert!(!pass(&ctx, true)); + assert!(pass(&ctx, false)); + assert!(!pass(&ctx, false), "the loss is one pass"); + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index f54d1bbc..d1a30342 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -7,6 +7,7 @@ pub mod buttons; pub mod docking; +mod focus; pub mod icons; pub mod layout; pub mod panes; diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index 75b20ddd..b50829f1 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -559,7 +559,7 @@ impl PaneHost { memory.drag_out = Some(DragOut { pane: s.id, canceled: false }); } let Some(mut drag) = memory.drag_out.filter(|d| d.pane == s.id) else { return }; - if ctx.input(|input| input.key_pressed(Key::Escape) || !input.focused) { + if ctx.input(|input| input.key_pressed(Key::Escape)) || crate::focus::lost(ctx) { drag.canceled = true; } let near = rect.expand(DOCK_SNAP); From 3250dd230553c5ccdca3408bf105b3e92c9676b8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 03:50:55 +0000 Subject: [PATCH 119/160] =?UTF-8?q?docking:=20the=20macOS=20=C3=97=20sits?= =?UTF-8?q?=208=20pt=20from=20the=20corner,=20its=20hit=20area=20reaching?= =?UTF-8?q?=20away=20from=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ×'s glyph inset, size and hit area are set in one place (MACOS_CLOSE, OTHER_CLOSE in render/native.rs). On macOS the glyph sits where macOS puts its close button, and the hit area reaches 10 pt toward the leading edge and down to the bar's bottom, past AppKit's corner resize area. Elsewhere the layout is the same as before. CRAFT_DOCK_DEBUG_CLOSE=1 outlines the hit area and AppKit's resize areas. --- crates/ui/src/docking/chrome.rs | 21 +++++-- crates/ui/src/docking/native_tests.rs | 47 ++++++++------ crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 86 ++++++++++++++++++++++---- docs/ui-docking.md | 21 ++++--- 5 files changed, 133 insertions(+), 44 deletions(-) diff --git a/crates/ui/src/docking/chrome.rs b/crates/ui/src/docking/chrome.rs index 724df73f..ab80552d 100644 --- a/crates/ui/src/docking/chrome.rs +++ b/crates/ui/src/docking/chrome.rs @@ -282,7 +282,7 @@ pub(super) fn truncated(ui: &Ui, text: WidgetText, width: f32, font: FontId) -> ui.painter().layout_job(job) } -/// A title bar: an optional title, an optional ▾ menu button and an optional ×, 10 pt +/// A title bar: an optional title, an optional ▾ menu button and an optional ×, by default 10 pt /// in from the right edge, mirrored when [`ChromeStyle::right_to_left`] is set. The bar senses /// clicks and drags; double-clicks are the caller's. pub struct TitleBar<'a> { @@ -291,6 +291,8 @@ pub struct TitleBar<'a> { title: Option, menu: Option<&'a str>, close: Option<&'a str>, + /// The ×'s hit area and glyph, where the caller places them. + close_at: Option<(Rect, Rect)>, movable: bool, } @@ -306,7 +308,7 @@ impl<'a> TitleBar<'a> { /// `id` is the bar's own; the buttons use `id.with("menu")` and `id.with("close")`. /// `move_label` names the bar for accessibility. pub fn new(id: Id, move_label: &'a str) -> Self { - Self { id, move_label, title: None, menu: None, close: None, movable: true } + Self { id, move_label, title: None, menu: None, close: None, close_at: None, movable: true } } /// Text at the leading edge, cut with '…' to fit. @@ -327,6 +329,14 @@ impl<'a> TitleBar<'a> { self } + /// Place the × at `glyph`, taking clicks in `hit`: rects as drawn, already mirrored in a + /// right-to-left layout. By default the × is a square the bar's height, its center 10 pt + /// in from the trailing edge. + pub fn close_at(mut self, hit: Rect, glyph: Rect) -> Self { + self.close_at = Some((hit, glyph)); + self + } + /// Whether the bar shows the grab cursor. It senses drags either way. pub fn movable(mut self, movable: bool) -> Self { self.movable = movable; @@ -345,7 +355,10 @@ impl<'a> TitleBar<'a> { r } }; - let close_rect = self.close.map(|_| Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side))); + let close_rect = self.close.map(|_| match self.close_at { + Some((hit, _)) => mirror(hit), + None => Rect::from_center_size(pos2(rect.right() - 10.0, rect.center().y), vec2(side, side)), + }); let menu_right = close_rect.map_or(rect.right() - 3.0, |close| close.left() - 2.0); let menu_rect = self.menu.map(|_| Rect::from_min_max(pos2(menu_right - side, rect.top()), pos2(menu_right, rect.bottom()))); let buttons = menu_rect.or(close_rect).map_or(rect.right(), |r| r.left()); @@ -376,7 +389,7 @@ impl<'a> TitleBar<'a> { }); let close = close_rect.zip(self.close).map(|(close_rect, label)| { let (response, stroke) = button(ui, close_rect, "close", label); - let r = Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))); + let r = self.close_at.map_or_else(|| Rect::from_center_size(close_rect.center(), Vec2::splat((side * 0.5).max(4.0))), |(_, glyph)| glyph); painter.line_segment([r.left_top(), r.right_bottom()], stroke); painter.line_segment([r.right_top(), r.left_bottom()], stroke); response diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index db123d72..6cbeef07 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{APPKIT_CORNER, DragPointer, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}; +use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; use crate::layout::{SplitAxis, SplitSize}; @@ -1093,31 +1093,38 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() { } #[test] -fn on_macos_the_bars_buttons_keep_clear_of_appkits_corner_resize_areas() { +fn on_macos_the_close_glyph_sits_by_the_corner_and_its_hit_area_reaches_away_from_it() { use crate::docking::chrome::{ChromeStyle, TitleBar}; + use crate::docking::placement::BAR_HEIGHT; let ctx = egui::Context::default(); + let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); + let bar = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT)); for right_to_left in [false, true] { - let mut buttons = Vec::new(); - let full = Rect::from_min_size(Pos2::ZERO, vec2(240.0, 300.0)); - let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { - let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; - let bar = Rect::from_min_size(full.min, vec2(full.width(), crate::docking::placement::BAR_HEIGHT)); - let shown = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").menu("Menu").close("Close").show( - ui, - buttons_rect(bar, right_to_left, true), - &style, - ); - buttons.extend(shown.close.iter().chain(&shown.menu).map(|r| r.rect)); - }); - output.textures_delta.clear(); - assert_eq!(buttons.len(), 2); + let (hit, glyph) = close_rects(bar, MACOS_CLOSE, right_to_left); + // Distances from the corner the × sits by: across from the trailing edge, and down. + let across = |x: f32| if right_to_left { x - full.left() } else { full.right() - x }; + let (glyph_near, glyph_far) = (across(glyph.right()).min(across(glyph.left())), across(glyph.right()).max(across(glyph.left()))); + assert_eq!((glyph_near, glyph.top(), glyph.width(), glyph.height()), (8.0, 4.5, 9.0, 9.0), "the glyph by the corner"); + let (hit_near, hit_far) = (across(hit.right()).min(across(hit.left())), across(hit.right()).max(across(hit.left()))); + assert_eq!(hit_near, glyph_near, "the hit area reaches no closer to the corner than the glyph"); + assert_eq!(hit_far, glyph_far + 10.0, "the hit area reaches away from the corner"); + assert_eq!((hit.top(), hit.bottom()), (glyph.top(), BAR_HEIGHT), "the hit area reaches down to the bar's bottom, not below"); + // AppKit's corner area takes the presses nearest the corner; the rest reach the ×. let [top_left, top_right, ..] = appkit_resize_areas(full); let corner = if right_to_left { top_left } else { top_right }; assert_eq!(corner.width(), APPKIT_CORNER); - for button in buttons { - // The × keeps 4 pt clear across; the bar, 18 pt high, cannot keep it below. - assert!(corner.expand2(vec2(4.0, 0.0)).intersect(button).width() <= 0.0, "{button:?} reaches into {corner:?}"); - } + assert!(hit_far - APPKIT_CORNER >= 10.0, "{hit:?} has too little room outside {corner:?}"); + + let mut shown = None; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(full), ..Default::default() }, |ui| { + let style = ChromeStyle { right_to_left, ..ChromeStyle::from_ui(ui) }; + let response = TitleBar::new(egui::Id::new("bar"), "Move").title("Panel").close("Close").close_at(hit, glyph).show(ui, bar, &style); + shown = response.close.map(|close| (close.rect, response.bar.rect)); + }); + output.textures_delta.clear(); + let (close, drag) = shown.unwrap_or((Rect::NOTHING, Rect::NOTHING)); + assert_eq!(close, hit, "the bar takes clicks for the × in its hit area"); + assert!(!drag.intersect(hit).is_positive(), "the bar's drag area {drag:?} leaves the × alone"); } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 36ce1f11..2972f05b 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -17,7 +17,7 @@ mod native; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, WindowDrag, appkit_resize_areas, buttons_rect, floats_shown, resize_bands}, + native::{APPKIT_CORNER, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}, }; /// The viewport of the native window `window` of the dock area `area`. diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 952027cd..21595078 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -45,8 +45,47 @@ pub(in crate::docking) const APPKIT_CORNER: f32 = 15.0; /// How far AppKit's edge resize area reaches into the content between the corners: a few /// points; most of it lies outside the window. const APPKIT_EDGE: f32 = 4.0; -/// Room between AppKit's corner area and the bar's buttons. -const CORNER_MARGIN: f32 = 4.0; + +/// Where the × of a native window's bar sits and where it takes clicks, in points from the +/// window's top and trailing edges, mirrored in a right-to-left layout. +#[derive(Clone, Copy, Debug)] +pub(in crate::docking) struct CloseLayout { + /// From the window's top edge to the top of the glyph. + pub glyph_top: f32, + /// From the window's trailing edge to the glyph's trailing side. + pub glyph_trailing: f32, + /// Width and height of the glyph. + pub glyph_size: f32, + /// How far the hit area reaches past the glyph to the left (to the right when mirrored). + pub hit_left: f32, + /// How far the hit area reaches past the glyph to the right (to the left when mirrored). + pub hit_right: f32, + /// How far the hit area reaches above the glyph. + pub hit_up: f32, + /// How far the hit area reaches below the glyph. Past the bar's bottom it takes clicks + /// from the tab strip under the bar. + pub hit_down: f32, +} + +/// The × on macOS, where macOS puts the close button of its own windows: centered in the +/// 18 pt bar, its trailing side 8 pt in. AppKit's corner resize area ([`APPKIT_CORNER`]) +/// covers its trailing half and takes presses there, so the hit area reaches away from the +/// corner: left of the glyph, and down to the bar's bottom. +pub(in crate::docking) const MACOS_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 8.0, glyph_size: 9.0, hit_left: 10.0, hit_right: 0.0, hit_up: 0.0, hit_down: 4.5 }; + +/// The × elsewhere: an 18 pt square the height of the bar, its trailing side 7 pt in so the +/// side's resize band ([`EDGE`]) and the corner's grab run down beside it, with the glyph at +/// its center. +pub(in crate::docking) const OTHER_CLOSE: CloseLayout = + CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 }; + +/// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas: +/// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once. +fn debug_close() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("CRAFT_DOCK_DEBUG_CLOSE").is_ok_and(|v| v == "1")) +} /// A drag of a native window's bar, in the context's data under the area's `"window-drag"`. #[derive(Clone, Copy, Debug, PartialEq)] @@ -336,6 +375,9 @@ impl Renderer<'_, P> { let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); self.floating_body(ui, window, body, full); + if debug_close() { + debug_outlines(ui, full, &buttons); + } // An embedded fallback reads the main window's input: its close request, place, // keys and resizes are not this window's. if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { @@ -430,7 +472,7 @@ impl Renderer<'_, P> { let style = ChromeStyle::from_dock(self.style); let rect = Rect::from_min_size(full.min, vec2(full.width(), BAR_HEIGHT.min(full.height()))); ui.painter().rect_filled(rect, 0.0, style.title_background); - let rect = buttons_rect(rect, style.right_to_left, OS_RESIZES); + let (close_hit, close_glyph) = close_rects(rect, if OS_RESIZES { MACOS_CLOSE } else { OTHER_CLOSE }, style.right_to_left); let movable = window.panels().all(|p| self.permissions(p).movable); let closable = self.style.close_button && window.panels().all(|p| self.permissions(p).closable); let (move_label, close_label) = (self.style.move_window_label.clone(), self.style.close_window_label.clone()); @@ -439,7 +481,7 @@ impl Renderer<'_, P> { // cover its ends and top edge, and a cursor set over them flickers with AppKit's. let mut bar = TitleBar::new(self.id.with(("native-bar", window.id)), &move_label).title(title).movable(movable && !OS_RESIZES); if closable { - bar = bar.close(&close_label); + bar = bar.close(&close_label).close_at(close_hit, close_glyph); } let response = bar.show(ui, rect, &style); if response.close.as_ref().is_some_and(|close| close.clicked()) { @@ -637,14 +679,34 @@ pub(in crate::docking) fn appkit_resize_areas(rect: Rect) -> [Rect; 8] { ] } -/// The part of a native window's bar `rect` its buttons are laid out in, its trailing end -/// pulled in. Where AppKit resizes the window (`os_resizes`) the buttons keep clear of its -/// corner area, which takes presses from the content; the bar is too short to keep them below -/// it. Elsewhere they keep clear of the side's resize band, so the corner's grab runs down -/// beside them. -pub(in crate::docking) fn buttons_rect(rect: Rect, right_to_left: bool, os_resizes: bool) -> Rect { - let inset = if os_resizes { APPKIT_CORNER + CORNER_MARGIN } else { EDGE }; - if right_to_left { rect.with_min_x(rect.left() + inset) } else { rect.with_max_x(rect.right() - inset) } +/// Over a native window's content `full`: its bar's `buttons` outlined in red and, on macOS, +/// AppKit's resize areas in orange. +fn debug_outlines(ui: &Ui, full: Rect, buttons: &[Rect]) { + let stroke = |color| egui::Stroke::new(1.0, color); + for button in buttons { + ui.painter().rect_stroke(*button, 0.0, stroke(egui::Color32::RED), egui::StrokeKind::Inside); + } + if OS_RESIZES { + for area in appkit_resize_areas(full) { + ui.painter().rect_stroke(area, 0.0, stroke(egui::Color32::ORANGE), egui::StrokeKind::Inside); + } + } +} + +/// The hit area and glyph of the × in a native window's bar `bar`, laid out by `layout` from +/// the bar's top and trailing edges. +pub(in crate::docking) fn close_rects(bar: Rect, layout: CloseLayout, right_to_left: bool) -> (Rect, Rect) { + let l = layout; + let glyph = Rect::from_min_size(pos2(bar.right() - l.glyph_trailing - l.glyph_size, bar.top() + l.glyph_top), Vec2::splat(l.glyph_size)); + let hit = Rect::from_min_max(glyph.min - vec2(l.hit_left, l.hit_up), glyph.max + vec2(l.hit_right, l.hit_down)); + let mirror = |r: Rect| { + if right_to_left { + Rect::from_x_y_ranges((bar.left() + bar.right() - r.right())..=(bar.left() + bar.right() - r.left()), r.y_range()) + } else { + r + } + }; + (mirror(hit), mirror(glyph)) } /// The bands along the edges of a native window's content `rect` that resize it: an L at each diff --git a/docs/ui-docking.md b/docs/ui-docking.md index d8c33e87..1af22a93 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -351,16 +351,23 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T Windows and Linux the OS frame is off. On macOS the frame stays, with its title bar and buttons hidden, for its resizing, rounded corners and standard window shadow. The dock area paints no shadow of its own around a native window. +- **The ×.** Its place is set in `render/native.rs` (`MACOS_CLOSE`, `OTHER_CLOSE`): the + glyph's inset from the top and trailing edges, its size, and how far the hit area reaches + past the glyph on each side. On macOS the 9 pt glyph sits where macOS puts the close button + of its own windows: centered in the bar, its trailing side 8 pt in. AppKit's corner resize + area covers the glyph's trailing half and takes presses there, so the hit area reaches 10 pt + further toward the leading edge and down to the bar's bottom. Elsewhere the × is an 18 pt + square, its trailing side 7 pt in. With `CRAFT_DOCK_DEBUG_CLOSE=1` in the environment, + native windows outline the ×'s hit area in red and, on macOS, AppKit's resize areas in + orange. - **Resize.** A movable window resizes from its edges and corners. On macOS AppKit does it, with its own areas and cursors. Its corner area reaches about 15 pt into the window along each edge (a 19 pt square, measured on macOS 26) and takes presses before the content sees - them, so the bar's buttons sit 19 pt in from the window's trailing edge; the 18 pt bar is too - short to keep them below it. Over AppKit's areas the window sets no cursor of its own, which - would flicker with AppKit's. Elsewhere the window resizes from bands 6 pt deep along its - edges, and each corner is an L along both edges, 16 pt long and as deep as the edges. The - bands leave out the bar's buttons, which sit 6 pt in from the window's edge so the corner's - grab runs down beside them; elsewhere the top band lies over the bar's drag area. The OS - runs the resize from a band (`ViewportCommand::BeginResize`). + them. Over AppKit's areas the window sets no cursor of its own, which would flicker with + AppKit's. Elsewhere the window resizes from bands 6 pt deep along its edges, and each corner + is an L along both edges, 16 pt long and as deep as the edges. The bands leave out the bar's + buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the + bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`). - **Close.** The bar's × and the OS close request close the window's panels, as the floating window's × does. They reopen from the app's Window menu. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's From bbaa61e64a76b2239f69322fde6128fdec05b79d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:13:41 +0000 Subject: [PATCH 120/160] docking: the resize band test runs off macOS only, where the bands are --- crates/ui/src/docking/native_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 6cbeef07..4c8bb3b9 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1,7 +1,7 @@ //! Floating windows in their own OS windows (feature `native-windows`), drawn by the driver's //! stand-in for OS windows. -use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; +use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}; use super::testing::DockDriver; @@ -1015,14 +1015,15 @@ fn a_drag_ends_when_its_native_window_or_panel_goes() { /// Press at `from` in the native window of panel 5, move the pointer by `by` on the desktop /// and release. Returns the window's OS rect, the resize the window handed to the window /// system, and the driver. -fn press_and_drag(from: Pos2, by: Vec2) -> (Rect, Option, DockDriver) { +#[cfg(not(target_os = "macos"))] +fn press_and_drag(from: Pos2, by: egui::Vec2) -> (Rect, Option, DockDriver) { let mut d = driver(layout(true)); let id = window(&d, 5).id; let viewport = d.viewport(id); let press = os_window(&d, 5).map_or(Pos2::ZERO, |(rect, _)| rect.min) + from.to_vec2(); d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); // The pointer is where it was moved on the desktop, wherever the window is now. - let local = |d: &DockDriver, step: Vec2| d.window_local(id, press + step).unwrap_or_default(); + let local = |d: &DockDriver, step: egui::Vec2| d.window_local(id, press + step).unwrap_or_default(); for step in [vec2(3.0, 3.0), by] { let pos = local(&d, step); d.window_step(id, vec![Event::PointerMoved(pos)]); @@ -1037,6 +1038,9 @@ fn press_and_drag(from: Pos2, by: Vec2) -> (Rect, Option, (os_window(&d, 5).map_or(Rect::NOTHING, |(rect, _)| rect), resize, d) } +// On macOS AppKit resizes the window and there are no bands; the macOS × layout has its own +// test. +#[cfg(not(target_os = "macos"))] #[test] fn the_bars_close_button_wins_over_the_resize_bands() { use egui::ResizeDirection as D; From a22c6d0c63a7887b27025af4c361d3cccddfefeb Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:32:17 +0000 Subject: [PATCH 121/160] docking: a native window fades to half opacity while it is dragged, on macOS --- Cargo.lock | 3 +++ crates/ui/Cargo.toml | 6 +++++ crates/ui/src/docking/render/native.rs | 35 +++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index bb3435fd..b55e3a2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -895,6 +895,9 @@ dependencies = [ "egui_kittest", "image", "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", "serde_json", ] diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 6b1f8145..b35b71a3 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -15,6 +15,12 @@ egui = { workspace = true } log = "0.4" serde = { version = "1", features = ["derive"], optional = true } +# Fading a native window while it is dragged (`NSWindow.alphaValue`); winit already uses these. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "NSApplication", "NSResponder", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSString"] } + [features] serde = ["dep:serde"] # Floating windows may move to their own OS windows (desktop egui backends with viewports). diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 21595078..1d100b6c 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -80,6 +80,32 @@ pub(in crate::docking) const MACOS_CLOSE: CloseLayout = pub(in crate::docking) const OTHER_CLOSE: CloseLayout = CloseLayout { glyph_top: 4.5, glyph_trailing: 11.5, glyph_size: 9.0, hit_left: 4.5, hit_right: 4.5, hit_up: 4.5, hit_down: 4.5 }; +/// Opacity of a native window while its bar or only tab drags it: the window stays above the +/// main window, so the drop preview under the pointer shows only through it. +#[cfg(target_os = "macos")] +const DRAG_OPACITY: f64 = 0.5; + +/// Sets the opacity of the AppKit windows titled `title`, never one titled `main` (the main +/// window's title). egui has no viewport command for a window's opacity, and a transparent +/// surface would need the main window transparent too (eframe picks one alpha mode for every +/// viewport), so this goes to AppKit, which fades the content, corners and shadow together. +/// The window is found by title: its native handle is a raw `NSView` pointer, and turning that +/// into an `NSWindow` needs `unsafe`, which the workspace forbids. Two floats with one title +/// fade together. +#[cfg(target_os = "macos")] +fn set_window_opacity(title: &str, main: Option<&str>, opacity: f64) { + if title.is_empty() || main == Some(title) { + return; + } + let Some(mtm) = objc2::MainThreadMarker::new() else { return }; + let title = objc2_foundation::NSString::from_str(title); + for window in objc2_app_kit::NSApplication::sharedApplication(mtm).windows().iter() { + if window.title().isEqualToString(&title) && window.alphaValue() != opacity { + window.setAlphaValue(opacity); + } + } +} + /// Whether native windows outline their ×'s hit area, and on macOS AppKit's resize areas: /// `CRAFT_DOCK_DEBUG_CLOSE=1`, read once. fn debug_close() -> bool { @@ -344,7 +370,7 @@ impl Renderer<'_, P> { let spawn = place.spawn; let title = crate::docking::dock_model::first_active(&window.root).map(|p| self.label_of(p)).unwrap_or_default(); let builder = ViewportBuilder::default() - .with_title(title) + .with_title(title.clone()) .with_position([spawn[0], spawn[1]]) .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) @@ -421,6 +447,13 @@ impl Renderer<'_, P> { (place.known[0], place.known[1], place.pending) = (to.x, to.y, Some(now + MOVE_WAIT)); } let moving = moving(ctx); + // Set every frame from the drag state, so any way a drag ends (a drop, Escape, a focus + // loss, the window closing) brings the window back to full opacity. + #[cfg(target_os = "macos")] + { + let main = ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).and_then(|v| v.title.clone())); + set_window_opacity(&title, main.as_deref(), if moving { DRAG_OPACITY } else { 1.0 }); + } if let Some(placed) = report.placed.filter(|_| !moving) { let waiting = place.pending.is_some_and(|until| now < until); if !differs(placed, place.known) { From 1564d5495902f2baf83f8d80b07415c82d97e0bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:48:34 +0000 Subject: [PATCH 122/160] docking: a native window moved by its only tab or group joins a body's center Dragging a native window's only tab, or its only group, moves the window and docks it on release. It took a frame's edges and its tab strip but not its body's center, a rule meant for the bar, so the center rectangle was neither a target nor filled under the pointer. Such a move now joins the center as the tab or group drag would; the bar still does not. --- crates/ui/src/docking/native_tests.rs | 37 +++++++++++++++++++++++++ crates/ui/src/docking/render/preview.rs | 13 +++++---- docs/ui-docking.md | 3 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 4c8bb3b9..164c7cec 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -859,6 +859,43 @@ fn the_only_tab_of_a_native_window_docks_it_with_title_bar_docks_off() { assert!(!in_root(&d, 7)); } +#[test] +fn the_only_tab_of_a_native_window_fills_and_joins_a_frame_bodys_center() { + let mut layout = layout(false); + layout.floating.push(Floating::single(vec![7], 0, [300.0, 80.0, 240.0, 200.0])); + let style = |s: &mut super::DockStyle| { + s.side_edge_zones = true; + s.title_bar_docks = false; + s.float_hint = false; + }; + let mut d = DockDriver::new(layout, Content::default()).with_style(style).with_native_host(ORIGIN); + let id = window(&d, 7).id; + // The middle of frame [3, 4], in the main window. + let middle = pos2(600.0, 300.0); + let from = pos2(15.0, TAB_ROW); + d.window_step(id, vec![Event::PointerMoved(from), button(from, true)]); + d.window_step(id, vec![Event::PointerMoved(from + vec2(8.0, 6.0))]); + for _ in 0..2 { + let local = d.window_local(id, ORIGIN + middle.to_vec2()).unwrap(); + d.window_step(id, vec![Event::PointerMoved(local)]); + } + // The center rectangle is the only zone around the pointer: outlined and filled. + let zone = |filled: bool| { + d.shapes().iter().any(|clipped| match &clipped.shape { + egui::Shape::Path(path) => { + path.closed && (path.fill != Color32::TRANSPARENT) == filled && Rect::from_points(&path.points).contains(middle) + } + _ => false, + }) + }; + assert!(zone(false), "outlined"); + assert!(zone(true), "filled"); + let local = d.window_local(id, ORIGIN + middle.to_vec2()).unwrap(); + let out = d.window_step(id, vec![button(local, false)]); + assert!(matches!(window_move(&out), Some(Target::At { anchor: 3 | 4, placement: Placement::Split(super::Zone::Center) })), "{:?}", out.actions); + assert!(in_root(&d, 7)); +} + #[test] fn a_native_window_forwards_no_keys_while_one_of_its_widgets_has_focus() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index b746c8e6..60d38b89 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -116,7 +116,8 @@ impl Renderer<'_, P> { /// The valid target under the pointer for a whole group. A target holding the group /// itself is a no-op proposal; a target the model would reject is no target. A window - /// moving live looks through itself and does not split frames or join a body's center. + /// moving live looks through itself and does not split frames or, moved by its bar, join + /// a body's center. pub(super) fn group_proposal(&self, drag: &GroupDrag

, pointer: Pos2) -> Option> { let window = drag.gesture == GroupGesture::Window; if let Some(proposal) = self.extra_proposal(&drag.members, pointer, window) { @@ -148,14 +149,16 @@ impl Renderer<'_, P> { } let proposal = self.resolve_roomy(&drag.panel, pointer, std::slice::from_ref(target), |to| group_action(drag, to))?; // A window moving live splits only groups of other floating windows. A native - // window's bar also takes the main window's frame edges, but not a body's center. + // window's bar also takes the main window's frame edges, but not a body's center. A + // native window moved by its only tab or group joins a body's center too, as that tab + // or group drag would. let into_window = matches!(self.place(&proposal.anchor), Some(Place::Floating { .. })); #[cfg(feature = "native-windows")] - let edges = self.cross.bar; + let (edges, joins) = (self.cross.bar, self.cross.docks); #[cfg(not(feature = "native-windows"))] - let edges = false; + let (edges, joins) = (false, false); let split = match proposal.placement { - Placement::Split(Zone::Center) => true, + Placement::Split(Zone::Center) => !joins, Placement::Split(_) => !edges, _ => false, }; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 1af22a93..4ebf7b93 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -439,7 +439,8 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T `DockStyle::title_bar_docks`; the only tab or group docks either way, since it is the way back in with that flag off. Targets: in the main window a dock line, a frame's tab row or edge zone, or the layout's outer edge; in a - floating window a frame zone or tab row. A frame body's center takes no whole window. Over + floating window a frame zone or tab row. A frame body's center takes a window moved by its + only tab or group, as that tab or group drag would, but not one moved by its bar. Over the desktop, or with Ctrl/Cmd held, it stays where it was moved, and the layout takes its place once, at the release. Escape puts it back. An in-window floating window's title bar still splits only groups of other floating windows. From 3ddbb0b1818f7dc24c351589704b9a2fc21b7152 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 13:07:07 +0000 Subject: [PATCH 123/160] docking: Placement::Split's doc describes the half split --- crates/ui/src/docking/model.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 543e2bb6..a30e5994 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -302,10 +302,8 @@ impl

Default for Layout

{ pub enum Placement

{ /// Insert before this existing destination member, or append. Also reorders a group. Tab { before: Option

}, - /// Center inserts after the anchor; sides create a new half-size split. Sides apply to - /// `Tabs` and `Stack` nodes only. A frame that holds the fixed size of its parent split - /// along the same axis keeps it: on its outer side the new split wraps the parent, on its - /// inner side it halves the parent's other child. + /// Center inserts after the anchor; sides split the anchor's frame into two halves, and + /// every other frame keeps its size. Sides apply to `Tabs` and `Stack` nodes only. Split(Zone), /// A new group above or below the anchor's group in a dock column. On an anchor in a /// floating window, a split of the anchor's group above or below. From 5bbf45a20abdbc135f0ad26ed6b60a5d1328558a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 13:12:49 +0000 Subject: [PATCH 124/160] panes: a bottom pane dragged to a side previews the width it docks at --- crates/ui/src/panes/mod.rs | 15 +++++++++++---- crates/ui/src/panes/tests.rs | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/panes/mod.rs b/crates/ui/src/panes/mod.rs index b50829f1..f0eb6676 100644 --- a/crates/ui/src/panes/mod.rs +++ b/crates/ui/src/panes/mod.rs @@ -534,13 +534,19 @@ impl PaneHost { } if content.movable(&s.pane) { - self.drag_out(ui.ctx(), &bar, s, edge, rect, bounds, memory, preview, style, requests); + // Docked at the host's left or right edge, the pane gets the width the side limits + // give its size: a bottom pane's height, not its width. + let (side_min, side_max) = self.limits(content, &s.pane, bounds.width()); + let side_width = + s.state.size.filter(|v| v.is_finite()).or(default.filter(|v| v.is_finite())).unwrap_or(side_min).clamp(side_min, side_max); + self.drag_out(ui.ctx(), &bar, s, edge, rect, side_width, bounds, memory, preview, style, requests); } rect } /// A docked pane's title dragged beyond [`DOCK_SNAP`] of the pane: dropped near the host's - /// left or right edge it docks there, elsewhere it floats under the pointer. + /// left or right edge it docks there `side_width` wide, elsewhere it floats under the + /// pointer. #[allow(clippy::too_many_arguments)] fn drag_out( &self, @@ -549,6 +555,7 @@ impl PaneHost { s: &Shown

, edge: Edge, rect: Rect, + side_width: f32, bounds: Rect, memory: &mut Memory, preview: &egui::Painter, @@ -592,8 +599,8 @@ impl PaneHost { return; } let shape = match target.map(|t| self.side(t)) { - Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + rect.width(), bounds.y_range()), - Some(_) => Rect::from_x_y_ranges(bounds.right() - rect.width()..=bounds.right(), bounds.y_range()), + Some(Side::Left) => Rect::from_x_y_ranges(bounds.left()..=bounds.left() + side_width, bounds.y_range()), + Some(_) => Rect::from_x_y_ranges(bounds.right() - side_width..=bounds.right(), bounds.y_range()), None => float, }; chrome::drop_preview(preview, shape.shrink(1.0), style.accent, true); diff --git a/crates/ui/src/panes/tests.rs b/crates/ui/src/panes/tests.rs index 6d449e66..bb433521 100644 --- a/crates/ui/src/panes/tests.rs +++ b/crates/ui/src/panes/tests.rs @@ -174,6 +174,25 @@ fn columns_sit_side_by_side_resize_and_move_to_end() { assert_eq!(edge(&h, Pane::Styles), L + 250.0); } +#[test] +fn a_bottom_pane_dragged_to_a_side_previews_the_width_it_docks_at() { + let mut app = App::new(&[Pane::Comments]); + app.states.insert(Pane::Comments, PaneState::docked(Edge::Bottom, 300.0)); + let mut h = harness(app); + let from = title(&h, Pane::Comments).center(); + let to = pos2(L + 10.0, 200.0); + hold(&mut h, from, to); + let previewed = h.output().shapes.iter().any(|clipped| match &clipped.shape { + egui::Shape::Rect(shape) => shape.rect.left() == L + 1.0 && shape.rect.width() == 298.0, + _ => false, + }); + assert!(previewed, "a 300 pt side pane"); + send(&mut h, vec![button(to, false)]); + h.run_steps(2); + assert_eq!(h.state().place(Pane::Comments), Place::Docked(Edge::Start)); + assert_eq!(edge(&h, Pane::Comments), L + 300.0); +} + #[test] fn title_dragged_out_floats_and_a_drop_near_an_edge_docks() { // Drag-out-to-float and the 32 pt edge snap follow wordcraft#2584. From 6680968a837f2c025a987018d3bafd0916e123d6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 13:12:58 +0000 Subject: [PATCH 125/160] docking: DockArea::locked draws a locked workspace; a lock still records native windows --- crates/ui/src/docking/model_tests.rs | 11 +++++++++- crates/ui/src/docking/native_tests.rs | 30 +++++++++++++++++++++++++- crates/ui/src/docking/render/mod.rs | 5 ++++- crates/ui/src/docking/render/native.rs | 3 ++- crates/ui/src/docking/surface.rs | 11 ++++++++++ crates/ui/src/docking/testing.rs | 9 +++++--- crates/ui/src/docking/workspace.rs | 13 ++++++++--- docs/ui-docking.md | 3 ++- 8 files changed, 74 insertions(+), 11 deletions(-) diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index 792d64da..f208209b 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -287,6 +287,16 @@ fn dock_validation_rejects_two_docks_per_side_nested_docks_empty_columns_and_bad assert_eq!(layout, docked()); } +#[test] +fn a_locked_workspace_records_its_native_windows() { + let mut workspace = Workspace::new(docked()); + workspace.locked = true; + for rect in [[0.0, 0.0, 300.0, 400.0], [40.0, 30.0, 300.0, 400.0]] { + workspace.apply(Action::SetNative { panel: 6, rect: Some(rect) }, |_| Permissions::default()).unwrap(); + assert_eq!(workspace.layout.floating[0].native, Some(rect)); + } +} + #[test] fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resize() { let mut workspace = Workspace::new(docked()); @@ -304,7 +314,6 @@ fn locked_workspace_rejects_structure_but_allows_activate_open_minimize_and_resi Action::Close { panel: 4 }, Action::CloseGroup { panel: 4 }, Action::CloseWindow { panel: 6 }, - Action::SetNative { panel: 6, rect: Some([0.0, 0.0, 100.0, 100.0]) }, ]; for action in refused { assert_eq!(workspace.apply(action.clone(), allow), Err(DockError::NotAllowed), "{action:?}"); diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 164c7cec..ef55c9dc 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -5,7 +5,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCo use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}; use super::testing::DockDriver; -use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target}; +use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, Workspace}; use crate::layout::{SplitAxis, SplitSize}; /// Desktop position of the main window's content. @@ -235,6 +235,34 @@ fn a_restored_window_opens_on_a_connected_monitor() { assert_eq!(window(&d, 5).monitor.as_ref().and_then(|m| m.name.as_deref()), Some("left")); } +#[test] +fn a_locked_area_moves_no_native_window_and_records_where_the_os_put_it() { + let mut d = driver(layout(true)); + d.locked = true; + let id = window(&d, 5).id; + let viewport = d.viewport(id); + let sent = d.host().commands.len(); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(70.0, 9.0)), button(pos2(70.0, 9.0), false)]); + assert!(out.actions.is_empty(), "{:?}", out.actions); + let moves = d.host().commands[sent..] + .iter() + .filter(|(v, c)| *v == viewport && matches!(c, ViewportCommand::OuterPosition(_) | ViewportCommand::StartDrag)) + .count(); + assert_eq!(moves, 0, "the bar is inert"); + + // The window system moves the window anyway: the locked workspace takes its new place. + let mut workspace = Workspace { locked: true, ..Workspace::new(d.layout.clone()) }; + d.host().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(pos2(700.0, 200.0), vec2(240.0, 300.0)); + for _ in 0..60 { + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })), "{:?}", out.actions); + workspace.apply_all(out.actions, |_| Permissions::default()).unwrap(); + } + assert_eq!(workspace.layout.floating[0].native, Some([700.0, 200.0, 240.0, 300.0])); +} + #[test] fn dragging_the_bar_moves_the_os_window() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 2972f05b..0c507272 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -48,6 +48,8 @@ pub(super) struct Renderer<'a, P> { pub(super) content: &'a mut dyn DockContent

, pub(super) activation: Option

, pub(super) blockers: Vec, + /// [`crate::docking::DockArea::locked`]. + pub(super) locked: bool, pub(super) layout: &'a Layout

, /// The docked area, for host drop requests. pub(super) workspace: Rect, @@ -100,7 +102,8 @@ impl Renderer<'_, P> { } pub(super) fn permissions(&self, panel: &P) -> Permissions { - self.content.permissions(panel) + let permissions = self.content.permissions(panel); + if self.locked { permissions.locked() } else { permissions } } pub(super) fn limits(&self, panel: &P) -> PanelLimits { diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 1d100b6c..366b3fef 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -208,7 +208,8 @@ impl Renderer<'_, P> { let mut ids = Vec::new(); let mut natives = Vec::new(); for window in windows { - let allowed = window.panels().all(|p| self.content.allow_native(p) && self.permissions(p).floatable); + // The application's own policy: a locked area keeps its native windows. + let allowed = window.panels().all(|p| self.content.allow_native(p) && self.content.permissions(p).floatable); if !native || !allowed || window.panels().next().is_none() { in_window.push(window); continue; diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 5965b0da..b8c38820 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -689,6 +689,7 @@ pub struct DockArea<'a, P = ()> { id: Id, activation: Option

, blockers: Vec, + locked: bool, maximized: Option

, hide: HidePanels<'a, P>, auto_collapse: bool, @@ -702,6 +703,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { id, activation: None, blockers: Vec::new(), + locked: false, maximized: None, hide: HidePanels::None, auto_collapse: true, @@ -777,6 +779,14 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Draw a locked workspace: pass [`super::Workspace::locked`]. Every panel gets + /// [`Permissions::locked`], so no tab, bar or native window starts a move, float or close + /// that the workspace would refuse. + pub fn locked(mut self, locked: bool) -> Self { + self.locked = locked; + self + } + /// Host overlays that must reject docking gestures, including floating fallback. pub fn blocked_rects(mut self, rects: &[Rect]) -> Self { self.blockers = rects.to_vec(); @@ -856,6 +866,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { content, activation: self.activation, blockers: self.blockers, + locked: self.locked, layout, workspace: rect, float_bounds: self.float_bounds.map_or(ui.ctx().content_rect(), |bounds| bounds.intersect(ui.ctx().content_rect())), diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 71e175dc..9804ba8b 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -46,6 +46,8 @@ pub struct DockDriver { pub float_bounds: Option, /// Passed to [`DockArea::monitors`]. pub monitors: Vec, + /// Passed to [`DockArea::locked`]. + pub locked: bool, /// The stand-in for OS windows, in use after [`Self::with_native_host`]. #[cfg(feature = "native-windows")] host: Option>>, @@ -74,6 +76,7 @@ impl> Do raise: None, float_bounds: None, monitors: Vec::new(), + locked: false, #[cfg(feature = "native-windows")] host: None, #[cfg(feature = "native-windows")] @@ -122,15 +125,15 @@ impl> Do } let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); let monitors = self.monitors.clone(); - let (style_fn, maximized, raise, auto, bounds) = - (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds); + let (style_fn, maximized, raise, auto, bounds, locked) = + (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds, self.locked); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if let Some(adjust) = style_fn { adjust(&mut style); } ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { - let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto).monitors(monitors.clone()); + let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto).monitors(monitors.clone()).locked(locked); if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index da414e78..244c5f0d 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -26,8 +26,9 @@ pub struct Workspace

{ pub saved: Option>, /// Lock Workspace, saved with this workspace: [`Self::apply`] uses /// [`Permissions::locked`], so moving, floating and closing are rejected while activating, - /// opening, minimizing and resizing still work. An application with a global lock - /// preference instead passes locked permissions itself. + /// opening, minimizing, resizing and recording native windows still work. Draw it with + /// [`super::DockArea::locked`]. An application with a global lock preference instead + /// passes locked permissions itself. pub locked: bool, } @@ -96,7 +97,13 @@ impl Workspace

{ for action in actions { let closed = proposed.layout.closed_by(&action)?; let locations = close_locations(&proposed.layout, &closed); - proposed.layout.apply_with_permissions(action, policy)?; + // SetNative records where the window system has a floating window, so a lock + // takes it too. + if matches!(action, Action::SetNative { .. }) { + proposed.layout.apply_with_permissions(action, &permissions)?; + } else { + proposed.layout.apply_with_permissions(action, policy)?; + } for (panel, location) in locations { proposed.hidden.retain(|(p, _)| p != &panel); proposed.hidden.push((panel, location)); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 4ebf7b93..6e361086 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -108,7 +108,7 @@ an optional saved arrangement for Reset, and `locked`. | Method | Does | |---|---| -| `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused. | +| `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused; `SetNative`, which records the OS windows, still applies. | | `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else beside `fallback`. | | `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | @@ -151,6 +151,7 @@ Builder options on `DockArea`: | `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. | | `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). | | `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. | +| `locked(on)` | Draw `Workspace::locked`: every panel gets `Permissions::locked`, so nothing starts a move, float or close the workspace would refuse. | | `blocked_rects(rects)` | App overlays that docking gestures must ignore. | | `float_bounds(rect)` | Where floating windows, flyouts and drag ghosts may go (default: the whole viewport). Pass the viewport below a title bar drawn under a transparent OS title bar, so no floating window sits where a press moves the app window. | From 72ca5ea0ebb7c432557344ea9791c456f3699d42 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 13:12:58 +0000 Subject: [PATCH 126/160] docking: windows sharing the dragged window's title stay faded on macOS --- crates/ui/src/docking/render/native.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 366b3fef..1f9caac9 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -449,11 +449,14 @@ impl Renderer<'_, P> { } let moving = moving(ctx); // Set every frame from the drag state, so any way a drag ends (a drop, Escape, a focus - // loss, the window closing) brings the window back to full opacity. + // loss, the window closing) brings the window back to full opacity. AppKit finds the + // window by title, so a window sharing the dragged one's title stays faded with it. #[cfg(target_os = "macos")] { let main = ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).and_then(|v| v.title.clone())); - set_window_opacity(&title, main.as_deref(), if moving { DRAG_OPACITY } else { 1.0 }); + let dragged = ctx.data(|d| d.get_temp::(drag_key)).and_then(|drag| self.layout.floating.iter().find(|w| w.id == drag.window)); + let faded = dragged.and_then(|w| crate::docking::dock_model::first_active(&w.root)).is_some_and(|p| self.label_of(p) == title); + set_window_opacity(&title, main.as_deref(), if faded { DRAG_OPACITY } else { 1.0 }); } if let Some(placed) = report.placed.filter(|_| !moving) { let waiting = place.pending.is_some_and(|until| now < until); From 16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:49:29 -0400 Subject: [PATCH 127/160] docking: the window under the pointer draws the undock hint (#285) --- crates/ui/src/docking/native_tests.rs | 48 ++++++++++++++ crates/ui/src/docking/render/cross.rs | 83 +++++++------------------ crates/ui/src/docking/render/frame.rs | 8 +-- crates/ui/src/docking/render/preview.rs | 4 +- 4 files changed, 75 insertions(+), 68 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index ef55c9dc..12a0eabf 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -559,6 +559,54 @@ fn the_preview_is_drawn_in_the_window_under_the_pointer() { assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 1, anchor: 7, .. }]), "{:?}", out.actions); } +/// The texts drawn in `shapes`. +fn texts(shapes: &[ClippedShape]) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out +} + +#[test] +fn the_undock_hint_is_drawn_in_the_window_under_the_pointer() { + let key = if egui::os::OperatingSystem::from_target_os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; + let hint = format!("Hold {key} to undock"); + let hinted = |shapes: &[ClippedShape]| texts(shapes).contains(&hint); + let driver = || { + DockDriver::new(two_windows(), Content::default()) + .with_style(|s| { + s.command_press_floats = true; + s.float_hint = true; + }) + .with_native_host(ORIGIN) + }; + + // Tab 1 of the main window over window [7]'s body. + let mut d = driver(); + let viewport = d.viewport(window(&d, 7).id); + let tab = d.hit(&Hit::Tab(1)).unwrap().center(); + let body = d.host().windows[&viewport].rect.center() - ORIGIN.to_vec2(); + d.hold(tab, body); + d.move_to(body); + assert!(hinted(&d.host().windows[&viewport].shapes), "{:?}", texts(&d.host().windows[&viewport].shapes)); + assert!(!hinted(d.shapes()), "not in the source"); + + // Tab 5 of window [5, 6] over frame [1, 2] of the main window. + let mut d = driver(); + let id = window(&d, 5).id; + let viewport = d.viewport(id); + let local = d.window_local(id, ORIGIN + vec2(100.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), local); + assert!(hinted(d.shapes()), "{:?}", texts(d.shapes())); + assert!(!hinted(&d.host().windows[&viewport].shapes), "not in the source"); +} + /// Zone outlines: closed 1 pt paths without a fill. fn zone_outlines(shapes: &[ClippedShape]) -> usize { shapes diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index b30e058c..bd7b767d 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -8,7 +8,7 @@ //! frame, with the targets it drew. The release is resolved at the end of the main window's //! frame, after every native window has drawn. -use std::{cell::Cell, fmt::Debug, hash::Hash}; +use std::{fmt::Debug, hash::Hash}; use egui::{Context, Key, Pos2, Rect, Ui, Vec2, vec2}; @@ -22,8 +22,6 @@ use crate::docking::{Action, Drag, DropTarget, Floating, GroupDrag, GroupGesture /// Key of [`DragPointer`] in the context's data, under the area's id. pub(super) const POINTER: &str = "drag-pointer"; -/// Key of whether a target was offered last frame, for the source window's undock hint. -const OFFERED: &str = "drag-offered"; /// Key of an Escape that reached one of the application's windows other than the drag's /// owner. Keys go to the window with keyboard focus, which need not be the one dragging. const ESCAPE: &str = "drag-escape"; @@ -93,12 +91,8 @@ pub(in crate::docking) struct Cross

{ pub pointer: Option, /// A bar drag released this frame. pub release: Option<(WindowDrag, DragPointer)>, - /// Drawing for a window other than the drag's source: the undock hint is the source's. - pub quiet: bool, /// Resolving a native window's bar drag over the main window: frame edge zones count. pub bar: bool, - /// A target was offered while `quiet`. - pub offered: Cell, /// Where the native window drawing now was told to move its content this frame. pub moved: Option, /// Resolving a window move that docks whatever `title_bar_docks` says. @@ -115,9 +109,7 @@ impl

Default for Cross

{ surfaces: Vec::new(), pointer: None, release: None, - quiet: false, bar: false, - offered: Cell::new(false), moved: None, docks: false, } @@ -173,27 +165,25 @@ impl Renderer<'_, P> { } /// Run `f` with a native window's targets and bounds in place of the main window's. - /// `quiet` leaves the undock hint to the drag's source window. - fn within(&mut self, surface: &mut Surface

, quiet: bool, f: impl FnOnce(&mut Self) -> R) -> R { + fn within(&mut self, surface: &mut Surface

, f: impl FnOnce(&mut Self) -> R) -> R { std::mem::swap(&mut self.targets, &mut surface.targets); std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets); std::mem::swap(&mut self.frame.windows, &mut surface.windows); let bounds = std::mem::replace(&mut self.float_bounds, surface.bounds); let blockers = std::mem::take(&mut self.blockers); - let was = std::mem::replace(&mut self.cross.quiet, quiet); let result = f(self); std::mem::swap(&mut self.targets, &mut surface.targets); std::mem::swap(&mut self.dock_targets, &mut surface.dock_targets); std::mem::swap(&mut self.frame.windows, &mut surface.windows); - (self.float_bounds, self.blockers, self.cross.quiet) = (bounds, blockers, was); + (self.float_bounds, self.blockers) = (bounds, blockers); result } /// [`Self::within`] the native window `window` drawn this frame. - fn within_window(&mut self, window: u64, quiet: bool, f: impl FnOnce(&mut Self) -> R) -> Option { + fn within_window(&mut self, window: u64, f: impl FnOnce(&mut Self) -> R) -> Option { let index = self.cross.surfaces.iter().position(|s| s.window == window)?; let mut surface = self.cross.surfaces.swap_remove(index); - let result = self.within(&mut surface, quiet, f); + let result = self.within(&mut surface, f); self.cross.surfaces.push(surface); Some(result) } @@ -214,7 +204,6 @@ impl Renderer<'_, P> { /// drag in the main window's coordinates and the input to finish it with; elsewhere it /// docks in another window or floats a new one on release. pub(super) fn cross_tab(&mut self, ctx: &Context, drag: &Drag

, input: DragInput) -> Option<(Drag

, DragInput)> { - self.cross.quiet = false; let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else { return Some((drag.clone(), input)); }; @@ -227,15 +216,14 @@ impl Renderer<'_, P> { match self.destination(&pointer, None)? { Dest::Main(local) if !floats_out => { let shift = self.shift(&pointer, self.cross.main.map(|m| m.min)); - self.cross.quiet = pointer.owner.is_some(); let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; return Some((drag, DragInput { pointer: Some(local), ..input })); } - _ if !pointer.released => self.source_hint(ctx, &pointer), + _ if !pointer.released => {} Dest::Window(window, local) if !float_only => { let shift = self.shift(&pointer, self.origin_of(window)); let drag = Drag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; - let action = self.within_window(window, true, |r| { + let action = self.within_window(window, |r| { let proposal = r.tab_proposal(&drag.panel, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?; Some(Action::Move { panel: drag.panel.clone(), anchor: proposal.anchor, placement: proposal.placement }) }); @@ -258,7 +246,6 @@ impl Renderer<'_, P> { /// A group or column drag at the end of the main window's frame, like [`Self::cross_tab`]. pub(super) fn cross_group(&mut self, ui: &Ui, drag: &GroupDrag

, input: DragInput) -> Option<(GroupDrag

, DragInput)> { - self.cross.quiet = false; let Some(pointer) = self.cross.pointer.filter(|p| self.cross.on && p.window.is_none() && p.local.is_some()) else { return Some((drag.clone(), input)); }; @@ -274,15 +261,14 @@ impl Renderer<'_, P> { match self.destination(&pointer, None)? { Dest::Main(local) if !floats_out => { let shift = self.shift(&pointer, self.cross.main.map(|m| m.min)); - self.cross.quiet = pointer.owner.is_some(); let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; return Some((drag, DragInput { pointer: Some(local), ..input })); } - _ if !pointer.released => self.source_hint(ctx, &pointer), + _ if !pointer.released => {} Dest::Window(window, local) if !pointer.command => { let shift = self.shift(&pointer, self.origin_of(window)); let drag = GroupDrag { origin: drag.origin + shift, source: drag.source.translate(shift), ..drag.clone() }; - let action = self.within_window(window, true, |r| { + let action = self.within_window(window, |r| { let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop && local.distance(drag.origin) >= 2.0)?; Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement })) }); @@ -305,7 +291,6 @@ impl Renderer<'_, P> { /// A bar drag of a native window, at the end of the main window's frame: the preview /// over the main window, and the release. pub(in crate::docking) fn finish_window_drag(&mut self, ui: &Ui) { - self.cross.quiet = false; if let Some((drag, pointer)) = self.cross.release.take() { self.drop_native_window(drag.window, &pointer); return; @@ -313,9 +298,9 @@ impl Renderer<'_, P> { let Some(pointer) = self.cross.pointer.filter(|p| p.window.is_some() && !p.released) else { return }; let Some(drag) = pointer.window.and_then(|w| self.window_group_drag(w)) else { return }; if let Some(Dest::Main(local)) = self.destination(&pointer, None) { - (self.cross.quiet, self.cross.bar, self.cross.docks) = (true, true, pointer.docks); + (self.cross.bar, self.cross.docks) = (true, pointer.docks); self.paint_group_drag(ui, &drag, local, pointer.command); - (self.cross.quiet, self.cross.bar, self.cross.docks) = (false, false, false); + (self.cross.bar, self.cross.docks) = (false, false); } } @@ -330,7 +315,7 @@ impl Renderer<'_, P> { (self.cross.bar, self.cross.docks) = (false, false); } Some(Dest::Window(target, local)) => { - let action = self.within_window(target, true, |r| { + let action = self.within_window(target, |r| { let proposal = r.group_proposal(&drag, local).filter(|p| !p.noop)?; Some(group_action(&drag, Target::At { anchor: proposal.anchor, placement: proposal.placement })) }); @@ -350,23 +335,16 @@ impl Renderer<'_, P> { Some(GroupDrag { panel, original: window.rect, gesture: GroupGesture::Window, members, origin: Pos2::ZERO, source: Rect::NOTHING }) } - /// At the end of a native window's frame: the drop preview when the drag's pointer is - /// over this window, or the undock hint when this window is the drag's source and a target - /// elsewhere was offered last frame. + /// At the end of a native window's frame: the drop preview, and its undock hint, when the + /// drag's pointer is over this window. pub(super) fn paint_cross(&mut self, ui: &Ui, surface: &mut Surface

) { let ctx = ui.ctx(); let Some(pointer) = ctx.data(|d| d.get_temp::(self.id.with(POINTER))).filter(|p| !p.released && p.down) else { return }; - let source = pointer.owner == Some(surface.window); - match self.destination(&pointer, Some(&*surface)) { - Some(Dest::Window(window, local)) if window == surface.window => { - let shift = self.shift(&pointer, surface.origin); - self.within(surface, !source, |r| r.paint_drop(ui, &pointer, local, shift)); - } - _ if source && ctx.data(|d| d.get_temp::(self.id.with(OFFERED))).unwrap_or(false) => { - let Some(local) = pointer.local else { return }; - self.within(surface, false, |r| r.paint_float_hint(ctx, local)); - } - _ => {} + if let Some(Dest::Window(window, local)) = self.destination(&pointer, Some(&*surface)) + && window == surface.window + { + let shift = self.shift(&pointer, surface.origin); + self.within(surface, |r| r.paint_drop(ui, &pointer, local, shift)); } } @@ -397,19 +375,7 @@ impl Renderer<'_, P> { } } - /// The undock hint in the main window, when it is the source and another window offered a - /// target this frame. - fn source_hint(&self, ctx: &Context, pointer: &DragPointer) { - if pointer.owner.is_none() - && self.cross.offered.get() - && let Some(local) = pointer.local - { - self.paint_float_hint(ctx, local); - } - } - - /// End of the main window's frame: remember whether a target was offered for the source - /// window's hint, and forget the pointer of a finished drag. + /// End of the main window's frame: forget the pointer of a finished drag. pub(in crate::docking) fn end_cross(&mut self, ctx: &Context) { let id = self.id; let active = ctx.data(|d| { @@ -417,12 +383,9 @@ impl Renderer<'_, P> { || d.get_temp::>(id.with("group-drag")).is_some() || d.get_temp::(id.with("window-drag")).is_some() }); - ctx.data_mut(|d| { - d.insert_temp(id.with(OFFERED), self.cross.offered.get()); - if !active { - d.remove::(id.with(POINTER)); - } - }); + if !active { + ctx.data_mut(|d| d.remove::(id.with(POINTER))); + } } /// The drag's input as its owner saw it, at `pointer` in the coordinates of the window diff --git a/crates/ui/src/docking/render/frame.rs b/crates/ui/src/docking/render/frame.rs index f0096df1..618216ec 100644 --- a/crates/ui/src/docking/render/frame.rs +++ b/crates/ui/src/docking/render/frame.rs @@ -193,17 +193,11 @@ impl Renderer<'_, P> { } /// Under the pointer while a drag offers a docking target: which modifier floats the drop - /// instead. + /// instead. With native windows, the window under the pointer draws it, beside its preview. pub(super) fn paint_float_hint(&self, ctx: &Context, pointer: Pos2) { if !self.style.float_hint || self.style.float_hint_label.is_empty() { return; } - // A window other than the drag's source: the source shows the hint. - #[cfg(feature = "native-windows")] - if self.cross.quiet { - self.cross.offered.set(true); - return; - } let key = if ctx.os() == egui::os::OperatingSystem::Mac { "⌘" } else { "Ctrl" }; let painter = self.preview_painter(ctx); let galley = painter.layout_no_wrap(self.style.float_hint_label.replace("{key}", key), self.style.font.clone(), self.style.text); diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 60d38b89..5488850a 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -286,7 +286,9 @@ impl Renderer<'_, P> { if let Some((action, line)) = self.dock_group_proposal(drag, pointer) { if action.is_some() { self.paint_dock_line(ctx, line); - self.paint_float_hint(ctx, pointer); + if drag.gesture != GroupGesture::Window { + self.paint_float_hint(ctx, pointer); + } } return; } From a0679c1dfc01637c7a0e47f35d81cef7f109167f Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:14:38 -0400 Subject: [PATCH 128/160] docking: dock column groups give way to the filler from their default heights (DockContent::dock_heights) (#287) * docking: default, compact and fill heights for dock column groups DockContent::dock_heights gives a panel's body heights in a dock column while the user has not resized its group. Groups at their default give way down to their compact heights so the filling group gets its fill height; then every group gives way down to its minimum. Saved heights stay. Without it, layouts are unchanged. * docking: dock column defaults give way nearest the filler first The group nearest the filling group shrinks to its compact height before the next one gives way, so groups further up keep their defaults. * docking: shared groups with different minimums split the rest evenly above their minimums * docking: a group asking for a fill height fills the column beside groups without dock heights --- crates/ui/src/docking/dock_model.rs | 4 +- crates/ui/src/docking/dock_tests.rs | 104 +++++++++++++++++++++++ crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/render/dock.rs | 122 ++++++++++++++++++++++----- crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/surface.rs | 51 +++++++++++ docs/ui-docking.md | 14 ++- 7 files changed, 273 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/docking/dock_model.rs b/crates/ui/src/docking/dock_model.rs index 8fb3ffe0..9abd4082 100644 --- a/crates/ui/src/docking/dock_model.rs +++ b/crates/ui/src/docking/dock_model.rs @@ -35,7 +35,9 @@ pub struct Group

{ /// Only the tab strip shows. `height` is kept for when the group expands again. #[cfg_attr(feature = "serde", serde(default))] pub minimized: bool, - /// Preferred height in points; `None` shares the free height of the column or stack. + /// Preferred height in points, set when the user resizes the group; `None` shares the free + /// height of the column or stack, or in a dock column takes + /// [`DockContent::dock_heights`](crate::docking::DockContent::dock_heights). #[cfg_attr(feature = "serde", serde(default))] pub height: Option, } diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 265ae188..1881135b 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -615,3 +615,107 @@ fn screen_reader_values_follow_the_column_width_on_either_edge() { assert_eq!(out.actions, vec![Action::ResizeColumn { panel: 2, width }], "{case}"); } } + +/// Panels 1-3 as a creative app's Color, Properties and Layers: Layers fills the column and asks +/// for room for about ten rows. +struct Creative; + +impl DockContent for Creative { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn permissions(&self, panel: &u32) -> Permissions { + if *panel == 0 { Permissions::PROTECTED } else { Permissions::default() } + } + fn limits(&self, panel: &u32) -> PanelLimits { + PanelLimits { min: vec2(0.0, if *panel == 3 { 120.0 } else { 60.0 }), ..PanelLimits::default() } + } + fn dock_heights(&self, panel: &u32) -> Option { + match panel { + 1 => Some(DockHeights::new(160.0).compact(100.0)), + 2 => Some(DockHeights::new(220.0).compact(130.0)), + 3 => Some(DockHeights::new(300.0).fill(480.0)), + _ => None, + } + } +} + +/// Color, Properties and Layers docked in a window `height` tall, with saved `heights`. +fn sized_column(height: f32, heights: [Option; 3]) -> testing::DockDriver { + let mut layout = docked(Side::Start, &[&[&[1], &[2], &[3]]]); + if let Some(Node::Split { first, .. }) = &mut layout.root + && let Node::Dock { columns, .. } = first.as_mut() + { + columns[0].groups.iter_mut().zip(heights).for_each(|(g, h)| g.height = h); + } + let mut d = testing::DockDriver::new(layout, Creative); + d.screen = Rect::from_min_size(Pos2::ZERO, vec2(1440.0, height)); + d.area = d.screen; + d.settle(); + d +} + +/// Heights of the column's groups as drawn, from their tabs' tops. +fn drawn_heights(d: &testing::DockDriver) -> [f32; 3] { + let top = |p| d.hit(&Hit::Tab(p)).unwrap().top(); + [top(2) - top(1), top(3) - top(2), d.area.bottom() - top(3)] +} + +#[test] +fn a_default_column_gives_the_filler_its_fill_height() { + let strip = DockStyle::from_visuals(&egui::Visuals::dark()).tab_height; + // A 900 pt window: Properties, nearest Layers, gives way towards its compact height so + // Layers gets the height it asks for; Color keeps its default. + let d = sized_column(900.0, [None; 3]); + let [color, properties, layers] = drawn_heights(&d); + assert!((layers - (480.0 + strip)).abs() < 0.5, "{:?}", drawn_heights(&d)); + assert_eq!(color, 160.0 + strip, "{:?}", drawn_heights(&d)); + assert!(properties < 220.0 + strip && properties >= 130.0 + strip, "{:?}", drawn_heights(&d)); + // A tall window: both at their defaults, Layers takes the rest. + let d = sized_column(1200.0, [None; 3]); + let [color, properties, layers] = drawn_heights(&d); + assert_eq!((color, properties), (160.0 + strip, 220.0 + strip)); + assert!(layers > 480.0 + strip); + // Nothing is saved until the user resizes a group. + assert!(column(&d.layout, 0).groups.iter().all(|g| g.height.is_none())); +} + +#[test] +fn shared_groups_with_different_minimums_fit_an_exact_room() { + let open = |min: f32, content: Option| render::GroupSize { minimized: false, saved: None, min, content }; + let column = [open(50.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, None)]; + assert_eq!(render::group_heights(&column, 26.0, 418.0), vec![186.0, 86.0, 146.0]); + assert_eq!(render::group_heights(&column, 26.0, 518.0), vec![186.0, 166.0, 166.0]); +} + +#[test] +fn a_group_asking_for_a_fill_fills_the_column_beside_groups_without_dock_heights() { + let open = |min: f32, content: Option| render::GroupSize { minimized: false, saved: None, min, content }; + let column = [open(86.0, Some(DockHeights::new(186.0))), open(86.0, None), open(146.0, Some(DockHeights::new(326.0).fill(506.0)))]; + assert_eq!(render::group_heights(&column, 26.0, 1000.0), vec![186.0, 86.0, 728.0]); +} + +#[test] +fn defaults_give_way_to_the_filler_but_user_heights_stay() { + let open = |saved: Option, min: f32, content: Option| render::GroupSize { minimized: false, saved, min, content }; + let color = Some(DockHeights::new(186.0).compact(126.0)); + let properties = Some(DockHeights::new(246.0).compact(156.0)); + let layers = Some(DockHeights::new(326.0).fill(506.0)); + let column = [open(None, 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)]; + // Tall: defaults, the filler takes the rest. + assert_eq!(render::group_heights(&column, 26.0, 1200.0), vec![186.0, 246.0, 768.0]); + // Short: the default nearest the filler gives way first; Color keeps its default. + assert_eq!(render::group_heights(&column, 26.0, 863.0), vec![186.0, 171.0, 506.0]); + // Shorter: Properties is at its compact height before Color gives way. + assert_eq!(render::group_heights(&column, 26.0, 818.0), vec![156.0, 156.0, 506.0]); + // Shorter than the compact heights and the fill: every group gives way towards its minimum. + let hs = render::group_heights(&column, 26.0, 400.0); + assert!(hs.iter().zip(&column).all(|(h, g)| *h >= g.min - 0.01), "{hs:?}"); + assert!((hs.iter().sum::() - 400.0).abs() < 0.01 && hs[0] < 126.0 && hs[1] < 156.0, "{hs:?}"); + // Heights the user set stay; only the defaults give way. + let mine = [open(Some(200.0), 86.0, color), open(None, 86.0, properties), open(None, 146.0, layers)]; + assert_eq!(render::group_heights(&mine, 26.0, 838.0), vec![200.0, 156.0, 482.0]); + // In a drawn column too: Layers gets less than it asks for rather than the user's heights. + let d = sized_column(900.0, [Some(200.0), Some(340.0), None]); + assert_eq!(drawn_heights(&d), [200.0, 340.0, 900.0 - 14.0 - 540.0]); +} diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 6fefc004..79457375 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -31,8 +31,8 @@ pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ - DockArea, DockContent, DockEvent, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, cancel_drag, - float_space, is_dragging, + DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, + cancel_drag, float_space, is_dragging, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 7ce4e88f..ba754d53 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -10,7 +10,7 @@ use egui::{Context, Key, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, p use super::{GroupKind, Renderer, RowItem, row_drag, row_range}; use crate::docking::{ - Action, Column, DockEvent, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, + Action, Column, DockEvent, DockHeights, DockStyle, Group, GroupDrag, GroupGesture, Node, Placement, Side, Target, dock_model::Unit, targets::{DockDrop, DockTarget, column_edge, horizontal_line, resolve_dock, usable, vertical_line}, }; @@ -70,43 +70,85 @@ pub(in crate::docking) fn column_width

(column: &Column

, style: &DockStyle) if column.iconic { column.iconic_width.unwrap_or(style.iconic_width).clamp(style.iconic_width, style.iconic_max_width) } else { column.width } } -/// Heights of stacked groups in a column body `room` high. Each entry is (minimized, saved -/// height, minimum). Minimized groups take `strip`, groups with a saved height keep it, and the -/// rest share what is left; when every open group has a height, the last one takes the rest. -/// Saved heights shrink towards their minimums before the column overflows and scrolls. -pub(in crate::docking) fn group_heights(groups: &[(bool, Option, f32)], strip: f32, room: f32) -> Vec { - let open: Vec = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.0)).collect(); - let mut shared: Vec = open.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| g.1.is_none_or(|h| !h.is_finite()))).collect(); +/// A group of a column, as [`group_heights`] sizes it. Heights include the tab strip. +#[derive(Clone, Copy, Debug, Default)] +pub(in crate::docking) struct GroupSize { + pub(in crate::docking) minimized: bool, + /// `Group::height`: set by the user; `None` or not finite when never resized. + pub(in crate::docking) saved: Option, + pub(in crate::docking) min: f32, + /// The content's [`DockHeights`](crate::docking::DockHeights), the strip added (to a + /// `fill` above 0 only, so 0 still means the group does not ask to fill). + pub(in crate::docking) content: Option, +} + +/// Heights of stacked groups in a column body `room` high. Minimized groups take `strip`, +/// groups with a saved height keep it, and groups the user never resized whose content asks +/// for a fill height share what is left. Without those, the groups without content heights +/// share it, and when every open group has a height, the last one takes the rest. Other +/// groups take their content's default height, or their minimum. Default heights shrink towards their compact heights until the +/// filling groups get their content's fill height, then every height shrinks towards its +/// minimum before the column overflows and scrolls. Default heights give way nearest the +/// filling groups first; saved and default heights then shrink in proportion to their room. +pub(in crate::docking) fn group_heights(groups: &[GroupSize], strip: f32, room: f32) -> Vec { + let saved = |g: &GroupSize| g.saved.filter(|h| h.is_finite()); + let open: Vec = (0..groups.len()).filter(|i| groups.get(*i).is_some_and(|g| !g.minimized)).collect(); + let unsaved = |i: &usize, test: fn(Option) -> bool| groups.get(*i).is_some_and(|g| saved(g).is_none() && test(g.content)); + let mut shared: Vec = open.iter().copied().filter(|i| unsaved(i, |c| c.is_some_and(|c| c.fill > 0.0))).collect(); + if shared.is_empty() { + shared = open.iter().copied().filter(|i| unsaved(i, |c| c.is_none())).collect(); + } if shared.is_empty() { shared.extend(open.last()); } + let sized: Vec = open.iter().copied().filter(|i| !shared.contains(i)).collect(); + let mut defaulted: Vec = sized.iter().copied().filter(|i| groups.get(*i).is_some_and(|g| saved(g).is_none())).collect(); let mut heights: Vec = groups .iter() .enumerate() - .map(|(i, (minimized, height, min))| { - if *minimized { + .map(|(i, g)| { + if g.minimized { strip } else if shared.contains(&i) { 0.0 } else { - height.unwrap_or(*min).max(*min) + saved(g).or(g.content.map(|c| c.default)).unwrap_or(g.min).max(g.min) } }) .collect(); - let mins: Vec = groups.iter().map(|g| if g.0 { strip } else { g.2 }).collect(); - let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum(); - let deficit = heights.iter().sum::() + shared_min - room; - let slack: f32 = open.iter().filter(|i| !shared.contains(i)).filter_map(|i| Some(heights.get(*i)? - mins.get(*i)?)).sum(); - if deficit > 0.0 && slack > 0.0 { - let take = deficit.min(slack) / slack; - for i in open.iter().filter(|i| !shared.contains(i)) { - if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { - *height -= (*height - min) * take; - } + let mins: Vec = groups.iter().map(|g| if g.minimized { strip } else { g.min }).collect(); + let compacts: Vec = groups.iter().zip(&mins).map(|(g, min)| g.content.map_or(*min, |c| c.compact).max(*min)).collect(); + let fill: f32 = shared.iter().filter_map(|i| groups.get(*i)).map(|g| g.content.map_or(g.min, |c| c.fill).max(g.min)).sum(); + // Nearest the filling groups first: one group gives way to its compact height before the next. + defaulted.sort_by_key(|i| shared.iter().map(|s| s.abs_diff(*i)).min()); + let mut deficit = heights.iter().sum::() + fill - room; + for i in defaulted { + if deficit <= 0.0 { + break; + } + if let (Some(height), Some(floor)) = (heights.get_mut(i), compacts.get(i)) { + let give = (*height - floor).clamp(0.0, deficit); + *height -= give; + deficit -= give; } } + let shared_min: f32 = shared.iter().filter_map(|i| mins.get(*i)).sum(); + give_way(&mut heights, &sized, &mins, shared_min - room); let free = (room - heights.iter().sum::()).max(0.0); - let share = free / shared.len().max(1) as f32; + // The shared groups split what is left evenly, each at least its minimum: groups whose + // minimum is above the even share take their minimum and the rest split the remainder. + let mut floors: Vec = shared.iter().filter_map(|i| mins.get(*i).copied()).collect(); + floors.sort_by(|a, b| b.total_cmp(a)); + let (mut rest, mut count) = (free, floors.len()); + for floor in floors { + if count > 1 && floor > rest / count as f32 { + rest -= floor; + count -= 1; + } else { + break; + } + } + let share = rest / count.max(1) as f32; for i in &shared { if let (Some(height), Some(min)) = (heights.get_mut(*i), mins.get(*i)) { *height = share.max(*min); @@ -115,6 +157,21 @@ pub(in crate::docking) fn group_heights(groups: &[(bool, Option, f32)], str heights } +/// Shrink the `which` heights in proportion towards their `floors` until the column's heights +/// plus `extra` fit, or every one is at its floor. +fn give_way(heights: &mut [f32], which: &[usize], floors: &[f32], extra: f32) { + let deficit = heights.iter().sum::() + extra; + let slack: f32 = which.iter().filter_map(|i| Some((heights.get(*i)? - floors.get(*i)?).max(0.0))).sum(); + if deficit > 0.0 && slack > 0.0 { + let take = deficit.min(slack) / slack; + for i in which { + if let (Some(height), Some(floor)) = (heights.get_mut(*i), floors.get(*i)) { + *height -= (*height - floor).max(0.0) * take; + } + } + } +} + /// Column widths that fit `room`. Each entry is (width, floor); a column with a floor shrinks /// in proportion, never below its floor, and one without keeps its width. A dock saved on a /// larger screen still fits, as far as the floors allow. @@ -274,6 +331,21 @@ impl Renderer<'_, P> { self.style.tab_height + body } + /// The largest of each of the group's panels' [`DockHeights`], the tab strip added. + fn dock_heights(&self, group: &Group

) -> Option { + let strip = self.style.tab_height; + group + .panels + .iter() + .filter_map(|p| self.content.dock_heights(p).filter(|h| h.valid())) + .reduce(|a, b| DockHeights { default: a.default.max(b.default), compact: a.compact.max(b.compact), fill: a.fill.max(b.fill) }) + .map(|h| DockHeights { + default: h.default + strip, + compact: h.compact.min(h.default) + strip, + fill: if h.fill > 0.0 { h.fill + strip } else { 0.0 }, + }) + } + fn column(&mut self, ui: &mut Ui, column: &Column

, first: &P, rect: Rect, edge: Edge) { if !usable(rect) { return; @@ -289,7 +361,11 @@ impl Renderer<'_, P> { ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside); return; } - let sizes: Vec<_> = column.groups.iter().map(|g| (g.minimized, g.height, self.group_min(g))).collect(); + let sizes: Vec<_> = column + .groups + .iter() + .map(|g| GroupSize { minimized: g.minimized, saved: g.height, min: self.group_min(g), content: self.dock_heights(g) }) + .collect(); let heights = group_heights(&sizes, self.style.tab_height, body.height()); let total: f32 = heights.iter().sum(); let start = self.targets.len(); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 0c507272..1d0078b0 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -34,7 +34,7 @@ pub(in crate::docking) use iconic::Iconic; pub use iconic::{close_flyout, flyout, open_flyout}; #[cfg(test)] -pub(in crate::docking) use dock::fit_widths; +pub(in crate::docking) use dock::{GroupSize, fit_widths, group_heights}; pub(in crate::docking) use floating::RESIZE_HANDLES; mod group; mod preview; diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index b8c38820..00aac1ff 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -464,6 +464,43 @@ impl PanelLimits { } } +/// Body heights a panel asks for in a dock column while the user has not resized its group +/// (`Group::height` is `None`). Like [`PanelLimits`] they exclude the tab strip. A group takes +/// the largest of each among its panels. +#[derive(Clone, Copy, Debug, PartialEq)] +#[non_exhaustive] +pub struct DockHeights { + /// The height the group gets when the column has room. + pub default: f32, + /// The height it gives way down to, so the group that fills the column keeps its `fill`. + pub compact: f32, + /// The height it asks for when it fills the column: groups at their default shrink towards + /// `compact` before it gets less. + pub fill: f32, +} + +impl DockHeights { + /// A `default` height that does not give way (`compact` is `default`) and, filling the + /// column, asks for no more than its minimum (`fill` is 0). + pub fn new(default: f32) -> Self { + Self { default, compact: default, fill: 0.0 } + } + + pub fn compact(mut self, compact: f32) -> Self { + self.compact = compact; + self + } + + pub fn fill(mut self, fill: f32) -> Self { + self.fill = fill; + self + } + + pub(super) fn valid(self) -> bool { + [self.default, self.compact, self.fill].iter().all(|h| h.is_finite() && *h >= 0.0 && *h <= 1_000_000.0) + } +} + #[derive(Clone)] pub(super) struct Drag

{ pub(super) panel: P, @@ -557,6 +594,17 @@ pub trait DockContent

{ fn limits(&self, _panel: &P) -> PanelLimits { PanelLimits::default() } + /// How the panel's group sizes itself in a dock column until the user resizes it. Groups + /// with a [`DockHeights::fill`] above 0 share the column's free height. `None` (the + /// default) shares it with the other groups that have none when no group asks for a fill, + /// and takes the group's minimum when one does. + /// Groups at their [`DockHeights::default`] give way down to their `compact` height, the one + /// nearest the filler first, so the group that fills the column gets its `fill` height; when + /// the column is still too short, + /// every group gives way down to its minimum. A height the user set is kept. + fn dock_heights(&self, _panel: &P) -> Option { + None + } /// Preferred floating size, independent of the current dock geometry: the width of a /// floated panel and the most height it takes. A floated panel or group, and a flyout, fit /// their height to the content the active panel last drew (at least 120 points), up to this @@ -651,6 +699,9 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn limits(&self, panel: &P) -> PanelLimits { (self.limits)(panel) } + fn dock_heights(&self, panel: &P) -> Option { + self.inner.dock_heights(panel) + } fn floating_size(&self, panel: &P) -> Option { self.inner.floating_size(panel) } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 6e361086..6544bd69 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -124,7 +124,7 @@ returns a `DockOutput`. It does not change the layout. The app applies `output.a its own command path, so pointer, keyboard and control commands share one path. `DockContent

` is the app's adapter. It draws each panel body and gives its label, icon, -permissions, size limits, preferred floating size, panel menu and group header. The group header +permissions, size limits, dock column heights, preferred floating size, panel menu and group header. The group header sits at the end of the tab strip; `group_header_width` gets the strip width, so a header that draws its own tabs can size them to the room left. An icon is an image (`icon`), or drawn in code by `paint_icon`, which gets a painter, the icon rect and the tint color and returns true when it painted. Panel bodies keep a stable egui id when they move, so widget memory @@ -238,6 +238,18 @@ A flyout fits its content the same way, without the title bar. ask for more through `PanelLimits::min`. Frames have no maximum unless every panel in them asks for one through `PanelLimits::max`: a panel without a maximum lifts the maximum of the panels it joins. +- A dock column stacks its groups top to bottom. A minimized group shows only its tab strip, and a + group the user resized keeps that `Group::height`. A group the user never resized (`height` is + `None`) takes the default height its panels ask for through `DockContent::dock_heights`. Groups + whose panels ask for a `fill` height share the rest of the column, and groups whose panels ask + for no heights take their minimum. When no group asks for a fill, the groups without heights + share the rest, and when every open group has a height, the last one fills the column. In a short column the groups at their default give way first, down + to their `compact` heights, so the filling group gets its `fill` height. When the column is still + too short, every group gives way down to its minimum, and past that the column scrolls. Defaults + give way nearest the filling group first: one is at its compact height before the next gives + way. Towards the minimums, groups give way in proportion to their room. A group takes the + largest default, compact and fill height of its panels. All three are body heights, without + the tab strip, like `PanelLimits`. - Drop previews show the destination: an outline for tabs, a line for a new group, column or dock, a filled half for a split. With `side_edge_zones`, a drag over a group instead outlines each of its zones that a drop would change something in (the side trapezoids, the center rectangle and From 5a371c978e7973896c7787d2a6971c328f953e5a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:14:58 -0400 Subject: [PATCH 129/160] docking: cancel the OS close of a native window that may not close (#288) --- crates/ui/src/docking/native_tests.rs | 28 ++++++++++++++++++-------- crates/ui/src/docking/render/native.rs | 10 ++++++--- docs/ui-docking.md | 4 +++- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 12a0eabf..9a8caae3 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -152,15 +152,27 @@ fn closing_the_os_window_closes_its_panels() { let id = window(&d, 5).id; let out = d.window_click(id, pos2(230.0, 9.0)); assert!(out.actions.contains(&Action::CloseWindow { panel: 5 }), "{:?}", out.actions); +} - // A panel that may not close keeps the window open. - let mut d = driver(layout(true)); - d.content.fixed = vec![6]; - let viewport = d.viewport(window(&d, 5).id); - d.host().windows.get_mut(&viewport).unwrap().close = true; - let out = d.step(vec![]); - assert!(out.error.is_some()); - assert!(d.layout.contains(&5)); +#[test] +fn the_os_close_of_a_window_that_may_not_close_is_cancelled() { + // A panel that may not close, or a locked area. + for locked in [false, true] { + let mut d = driver(layout(true)); + if locked { + d.locked = true; + } else { + d.content.fixed = vec![6]; + } + let viewport = d.viewport(window(&d, 5).id); + let sent = d.host().commands.len(); + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::CloseWindow { .. })), "locked {locked}: {:?}", out.actions); + assert!(out.error.is_none(), "locked {locked}: {:?}", out.error); + assert!(d.host().commands[sent..].contains(&(viewport, ViewportCommand::CancelClose)), "locked {locked}: cancelled"); + assert!(d.layout.contains(&5) && d.layout.contains(&6)); + } } #[test] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 1f9caac9..c4c3b628 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -475,9 +475,13 @@ impl Renderer<'_, P> { } ctx.data_mut(|d| d.insert_temp(key, place)); if report.closed { - // Like the bar's ×: its panels close and reopen from the application's menu. - self.output.actions.push(Action::CloseWindow { panel: first }); - return Some(key); + // Like the bar's ×: its panels close and reopen from the application's menu. A + // window the application would refuse to close stays open. + if window.panels().all(|p| self.permissions(p).closable) { + self.output.actions.push(Action::CloseWindow { panel: first }); + return Some(key); + } + ctx.send_viewport_cmd_to(viewport, ViewportCommand::CancelClose); } // The layout follows the window's place, once a bar drag is over. let known = place.known; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 6544bd69..05ee7e14 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -382,7 +382,9 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T buttons, so the corner's grab runs down beside the ×; elsewhere the top band lies over the bar's drag area. The OS runs the resize from a band (`ViewportCommand::BeginResize`). - **Close.** The bar's × and the OS close request close the window's panels, as the - floating window's × does. They reopen from the app's Window menu. + floating window's × does. They reopen from the app's Window menu. A window with a panel that + may not close, or in a locked area, has no × and answers the OS close request with + `ViewportCommand::CancelClose`. - **Place.** `Floating::native` is the desktop rect of the window's content (egui's `inner_rect`), and the in-window `rect` follows it for builds that draw the window in-window. The content is also the window's origin for drags; only `ViewportCommand::OuterPosition` From 96f8ae3d5a1678f757bc7c9b61c71cafc3f00625 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:20 -0400 Subject: [PATCH 130/160] docking: a double-click on a minimized group's active tab leaves it expanded (#289) * docking: keep a minimized group expanded when its active tab is double-clicked * docking: no extra SetMinimized for a click on the active tab --- crates/ui/src/docking/render/group.rs | 7 +++++-- crates/ui/src/docking/trial_tests.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index ec7d98bd..9b214ee0 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -381,8 +381,11 @@ impl Renderer<'_, P> { if response.clicked() { self.output.actions.push(Action::Activate { panel: panel.clone() }); self.output.focused = Some(panel.clone()); - if minimized == Some(true) && !active { - self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + if minimized == Some(true) { + // Activate already expands the group of its active tab. + if !active { + self.output.actions.push(Action::SetMinimized { panel: panel.clone(), minimized: false }); + } let now = ui.input(|i| i.time); ui.ctx().data_mut(|d| d.insert_temp(expand_key, now)); } diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index 2cc4fe30..aa3ec6ce 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -160,6 +160,35 @@ fn double_click_on_a_tab_of_a_minimized_group_ends_expanded() { assert_eq!(d.layout.floating[0].groups()[0].active_panel(), Some(&5)); } +#[test] +fn double_click_on_the_active_tab_of_a_minimized_group_or_window_ends_expanded() { + let window = Floating { minimized: true, ..Floating::single(vec![5, 6], 1, [80.0, 70.0, 300.0, 220.0]) }; + let mut d = Driver::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![window] }); + let active = d.tab(6).center(); + d.click(active); + d.click(active); + assert!(!d.layout.floating[0].minimized); + + let mut layout = docked(); + let Some(Node::Split { first, .. }) = &mut layout.root else { panic!() }; + let group = Group { minimized: true, ..Group::new(vec![2, 3], 1) }; + **first = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group], 240.0)] }; + let mut d = Driver::new(layout); + let minimized = |d: &Driver| { + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!() }; + let Node::Dock { columns, .. } = &**first else { panic!() }; + columns[0].groups[0].minimized + }; + let active = d.tab(3).center(); + d.click(active); + d.click(active); + assert!(!minimized(&d)); + d.wait(); + d.click(active); + d.click(active); + assert!(minimized(&d)); +} + #[test] fn a_clicked_tab_shows_no_focus_outline_but_tab_key_focus_does() { let mut d = Driver::new(frames()); From 24a472717acacfade2964241842745d7f0dd2929 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:15:41 -0400 Subject: [PATCH 131/160] prepress: transparency flattener (flatten feature) (#194) * hayro-interpret: ink colors, overprint and font bytes for the flattener Local patches for craft-prepress's PDF-in adapter, labeled Craft patch (flatten) and listed in vendor/README.craft-reader.md: - Device::set_overprint and Device::set_isolated, called where the blend mode is set and after an isolated group is pushed. - Color::ink_paint, ColorSpace::ink_space and ink_values: colors and image samples as the inks they print, without converting to RGB. RasterImage::with_inks gives native samples with Decode and Matte. - OutlineFontData::embedded and index, and the CFF bytes of Type1C fonts, so text can be embedded again. - The sh operator ignores /Background (ISO 32000-2 8.7.4.5.1). * plate-test: flattened-page checks, image alpha and scenes F01, F02 - report::run_flattened separates each scene's page after a caller's flattener as well as the original, and structure::transparency lists what transparency a page still has. plate-test doesn't depend on the flattener. - Scene images take an alpha channel, written as /SMask. - F01: a partly transparent image over a flat fill; where its alpha is 0 the fill keeps its exact inks (vectorcraft#2160). - F02: art hidden under opaque fills leaves no trace after flattening (vectorcraft#2161). * prepress: transparency flattener (flatten feature) craft_prepress::flatten turns PDF pages with transparency into pages without it, keeping inks, overprint and live text, for PDF/X-1a and other output that can't carry transparency. - pdf: reads a page into a display list in inks through hayro-interpret: paths, outlined strokes, clips, groups, soft masks, overprint, spot and DeviceN colors, images in their own color space, gradients, rasterized mesh shadings, tiling patterns and glyph runs. - font: prepares the source fonts for embedding again (TrueType, OpenType, CFF wrapped as OpenType); others fall back to outlines with ActualText. - geom: planar regions with the shapes that cover each one (linesweeper). - compose: composites each region per ink (separable and non-separable blend modes, isolated and knockout groups, overprint per plate as ISO 32000 defines it); regions under images, gradients and soft masks are marked complex. - raster: complex regions as DeviceN images through plate mode, grid snapped with a one-pixel outset; flatten_page. - write: writes the result with the vendored krilla; flatten_document writes every page into one PDF with the output intent, page boxes and fonts shared across pages when their bytes match. The regions and the compositor are ported from VectorCraft (commit c2b15982, crates/pathops and crates/engine/src/cmd/flatten.rs), credited in the crate README. scripts/plate-check.sh --flatten separates every scene's flattened page with Ghostscript (local only). CI runs craft-prepress with all features. * krilla: DeviceN /Colorants from PDF 1.3, NChannel attributes from 1.6 (patch 11) * prepress: flatten_document output PDF version and PDF/X version * prepress: format the PDF/X-1a flatten test * krilla, craft-prepress: write GTS_PDFXConformance next to GTS_PDFXVersion PDF/X-1a:2003 files carry GTS_PDFXVersion (PDF/X-1:2003) and GTS_PDFXConformance (PDF/X-1a:2003). Krilla patch 12 adds SerializeSettings::pdfx_conformance, and FlattenDocumentOptions passes it through. * krilla, craft-pdf, craft-prepress: carry the modification date over Krilla patch 13 adds Metadata::modification_date, written as /ModDate and xmp:ModifyDate in place of the creation date. craft-pdf's Metadata gains modified, and flatten_document takes it from the source's ModDate. * craft-prepress: count the print annotations a read leaves out ReadReport::dropped_annotations counts the annotations a printer would paint (Print flag, not Hidden, with a normal appearance) when ReadOptions::annotations is false; FlattenTotals sums it. * craft-prepress: degenerate groups rasterize without a soft mask A group the region sweep fails on is clipped to its members' boxes, which no other group's items reach, so the paper is its only backdrop. Its image is written opaque, as the plates of the items over paper, and the page keeps no transparency for PDF/X-1a. * craft-prepress: write untouched JPEG images as they are A DCT-encoded DeviceGray or DeviceCMYK image that krilla's JPEG passthrough writes with the same meaning (8 bits, no decode parameters, the default gray Decode or the inverted Adobe CMYK one) keeps its data in ImageItem::jpeg, and the writer embeds it unchanged when the image has no soft mask. Other images are still 8-bit Flate samples. * craft-prepress: keep group members with no transparency above them Groups are transitive by bounding box, so a background frame put all the body text on it under a transparent object far away, and the text came back as outlined fills. A member is now drawn as it is when it is opaque and, in every region its shapes are in, only members kept this way are above it. The region fills under it are the composite of the other members, and it is drawn over them in paint order. * prepress: keep the backdrop under elements of a knockout group * prepress: rasterize complex regions thinner than a point * prepress: mark flatten errors, reasons and reports non_exhaustive * prepress: bound the items a tiling pattern fill expands into Each of up to MAX_TILING_CELLS cells copied the whole cell content, and a cell can hold many items or an expanded pattern of its own. A fill whose cells would hold more than MAX_TILING_ITEMS items in all, counting groups, masks and nested patterns, is read as an image. * prepress: stroke solid past the dash budget A dash pattern that cuts a stroke into more than 250,000 pieces is outlined solid, with the estimate hayro's renderer uses. * prepress: bound the sampling and memory of rasterized tiling patterns Each pixel sampled every cell over it, which for a BBox far larger than its step is millions of cells per pixel; past MAX_TILING_CELLS cells per pixel the fill paints nothing, and below it the image resolution drops to keep the samples within MAX_TILING_SAMPLES. The canvases also hold a value per page ink, so their pixel limits shrink with the ink count to keep the memory of CMYK canvases, and the canvas size uses checked arithmetic. * prepress: mark the page reader's error and report non_exhaustive * prepress: format the tiling tests * prepress: CodeRabbit fixes: no nonzero overprint mode for images and shadings, count dropped tilings, widen the hhea ascender * prepress: refuse DeviceN spaces past the output's colorant limit and regions past the separated spot inks --- .github/workflows/pdf.yml | 4 +- Cargo.lock | 21 + Cargo.toml | 1 + crates/pdf/README.md | 3 +- crates/pdf/src/write.rs | 5 + crates/pdf/tests/print_color.rs | 42 +- crates/pdf/tests/writing.rs | 17 + crates/plate-test/README.md | 23 +- .../scenes/F01-image-alpha-over-fill.json | 48 + crates/plate-test/scenes/F02-hidden-art.json | 63 + crates/plate-test/src/reference.rs | 22 +- crates/plate-test/src/report.rs | 191 ++- crates/plate-test/src/scene.rs | 14 + crates/plate-test/src/structure.rs | 31 + crates/plate-test/tests/ghostscript.rs | 7 +- crates/prepress/Cargo.toml | 11 + crates/prepress/README.md | 31 +- crates/prepress/src/flatten/compose.rs | 1161 ++++++++++++++ crates/prepress/src/flatten/document.rs | 387 +++++ crates/prepress/src/flatten/font.rs | 209 +++ crates/prepress/src/flatten/geom.rs | 1016 +++++++++++++ crates/prepress/src/flatten/mod.rs | 235 +++ crates/prepress/src/flatten/pdf.rs | 1335 +++++++++++++++++ crates/prepress/src/flatten/raster.rs | 393 +++++ crates/prepress/src/flatten/shade.rs | 828 ++++++++++ crates/prepress/src/flatten/write.rs | 777 ++++++++++ crates/prepress/src/ink.rs | 2 +- crates/prepress/src/lib.rs | 2 + crates/prepress/src/plates.rs | 62 +- crates/prepress/tests/fixtures/cyan-cmyk.jpg | Bin 0 -> 297 bytes crates/prepress/tests/fixtures/gray-25.jpg | Bin 0 -> 162 bytes crates/prepress/tests/flatten_document.rs | 379 +++++ crates/prepress/tests/flatten_pdf.rs | 613 ++++++++ crates/prepress/tests/flatten_plates.rs | 38 + crates/prepress/tests/flatten_raster.rs | 416 +++++ crates/prepress/tests/flatten_timings.rs | 209 +++ crates/prepress/tests/flatten_write.rs | 472 ++++++ crates/prepress/tests/font_reembed.rs | 521 +++++++ scripts/plate-check.sh | 17 +- vendor/README.craft-reader.md | 18 + vendor/hayro-interpret/SOURCE.json | 24 +- vendor/hayro-interpret/src/color.rs | 297 +++- vendor/hayro-interpret/src/device.rs | 8 + vendor/hayro-interpret/src/font/blob.rs | 8 + vendor/hayro-interpret/src/font/cid.rs | 14 + vendor/hayro-interpret/src/font/mod.rs | 3 +- vendor/hayro-interpret/src/font/outline.rs | 27 +- vendor/hayro-interpret/src/font/type1.rs | 9 + vendor/hayro-interpret/src/interpret/mod.rs | 5 +- vendor/hayro-interpret/src/interpret/path.rs | 14 + vendor/hayro-interpret/src/interpret/text.rs | 2 + vendor/hayro-interpret/src/types.rs | 33 + vendor/hayro-interpret/src/x_object.rs | 118 +- vendor/krilla/README.craft.md | 56 +- vendor/krilla/SOURCE.json | 6 +- .../patches/14-devicen-pdf-version.patch | 51 + .../krilla/patches/15-pdfx-conformance.patch | 46 + .../krilla/patches/16-modification-date.patch | 65 + vendor/krilla/src/graphics/device_n.rs | 30 +- vendor/krilla/src/interchange/metadata.rs | 24 +- vendor/krilla/src/serialize.rs | 14 +- 61 files changed, 10373 insertions(+), 105 deletions(-) create mode 100644 crates/plate-test/scenes/F01-image-alpha-over-fill.json create mode 100644 crates/plate-test/scenes/F02-hidden-art.json create mode 100644 crates/prepress/src/flatten/compose.rs create mode 100644 crates/prepress/src/flatten/document.rs create mode 100644 crates/prepress/src/flatten/font.rs create mode 100644 crates/prepress/src/flatten/geom.rs create mode 100644 crates/prepress/src/flatten/mod.rs create mode 100644 crates/prepress/src/flatten/pdf.rs create mode 100644 crates/prepress/src/flatten/raster.rs create mode 100644 crates/prepress/src/flatten/shade.rs create mode 100644 crates/prepress/src/flatten/write.rs create mode 100644 crates/prepress/tests/fixtures/cyan-cmyk.jpg create mode 100644 crates/prepress/tests/fixtures/gray-25.jpg create mode 100644 crates/prepress/tests/flatten_document.rs create mode 100644 crates/prepress/tests/flatten_pdf.rs create mode 100644 crates/prepress/tests/flatten_plates.rs create mode 100644 crates/prepress/tests/flatten_raster.rs create mode 100644 crates/prepress/tests/flatten_timings.rs create mode 100644 crates/prepress/tests/flatten_write.rs create mode 100644 crates/prepress/tests/font_reembed.rs create mode 100644 vendor/krilla/patches/14-devicen-pdf-version.patch create mode 100644 vendor/krilla/patches/15-pdfx-conformance.patch create mode 100644 vendor/krilla/patches/16-modification-date.patch diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 355f52d2..45b2b363 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -106,5 +106,5 @@ jobs: python scripts/test-fetch-pdf-test-fonts.py python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts" echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV" - - run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --locked -- -D warnings - - run: cargo test -p craft-plate-test -p craft-prepress --locked + - run: cargo clippy -p craft-plate-test -p craft-prepress --all-targets --all-features --locked -- -D warnings + - run: cargo test -p craft-plate-test -p craft-prepress --all-features --locked diff --git a/Cargo.lock b/Cargo.lock index 22bd07cf..c089cc7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,9 @@ name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] [[package]] name = "as-raw-xcb-connection" @@ -927,7 +930,11 @@ version = "0.1.0" dependencies = [ "craft-cms", "craft-pdf", + "craft-plate-test", + "kurbo 0.13.1", + "linesweeper", "thiserror 2.0.21", + "ttf-parser", ] [[package]] @@ -2719,6 +2726,7 @@ dependencies = [ "arrayvec", "euclid", "polycool", + "serde", "smallvec", ] @@ -2762,6 +2770,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "linesweeper" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" +dependencies = [ + "arrayvec", + "kurbo 0.13.1", + "polycool", + "rustc-hash 2.1.3", + "smallvec", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" diff --git a/Cargo.toml b/Cargo.toml index 532aa47a..01c390df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ hayro = { version = "=0.7.1", default-features = false } hayro-interpret = { version = "=0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] } bytemuck = "1" +kurbo = "0.13" thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 8e654138..4a73fdc6 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -146,7 +146,8 @@ let writer = Writer::new(SerializeSettings { configuration, ..settings }, metada For another profile, fill `PrintColor` with its condition identifier (`Custom` when it is not registered) and `info`. The settings claim no PDF/X version and are not checked against -PDF/X; apps that claim conformance set `pdfx_version` and keep their own checks. Writing these +PDF/X; apps that claim conformance set `pdfx_version` (and `pdfx_conformance` for PDF/X-1a +and X-3) and keep their own checks. Writing these settings needs the patched Krilla (`CRAFT_PRINT_COLOR`, see [`vendor/krilla/README.craft.md`](../../vendor/krilla/README.craft.md)). diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 057e1559..8114327d 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -84,6 +84,8 @@ pub struct Metadata { pub description: Option, pub keywords: Vec, pub created: Option, + /// Written as the modification date; `created` is written there when this is `None`. + pub modified: Option, } impl Metadata { @@ -99,6 +101,9 @@ impl Metadata { if let Some(created) = self.created { metadata = metadata.creation_date(created); } + if let Some(modified) = self.modified { + metadata = metadata.modification_date(modified); + } metadata } } diff --git a/crates/pdf/tests/print_color.rs b/crates/pdf/tests/print_color.rs index 2a9dd63f..c1f2ccc9 100644 --- a/crates/pdf/tests/print_color.rs +++ b/crates/pdf/tests/print_color.rs @@ -630,6 +630,33 @@ fn device_n_mixing_spots_and_process_writes_nchannel_attributes() { } } +/// NChannel's `/Subtype` and `/Process` are PDF 1.6: a 1.4 file (PDF/X-1a) gets only +/// `/Colorants`, a 1.7 file the NChannel attributes. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn device_n_attributes_follow_the_pdf_version() { + for version in [PdfVersion::Pdf14, PdfVersion::Pdf17] { + let settings = SerializeSettings { + configuration: craft_pdf::write::configuration(version, None).unwrap(), + compress_content_streams: false, + ..cmyk_settings() + }; + let color = device_n(vec![Colorant::Process(Process::Cyan), spot("Gold", gold())], vec![255, 128]); + let bytes = device_n_sample(settings, vec![color]).unwrap(); + let nchannel = version >= PdfVersion::Pdf16; + assert_eq!(bytes.windows(8).any(|w| w == b"NChannel"), nchannel, "{version:?}"); + let mut found = 0; + device_n_spaces(&bytes, |_, _, _, attrs| { + found += 1; + let attrs = attrs.expect("attributes"); + assert!(attrs.get::>(b"Colorants".as_slice()).unwrap().get::>(b"Gold".as_slice()).is_some()); + assert_eq!(attrs.contains_key(b"Subtype".as_slice()), nchannel, "{version:?}"); + assert_eq!(attrs.contains_key(b"Process".as_slice()), nchannel, "{version:?}"); + }); + assert_eq!(found, 1); + } +} + /// A spot name keeps one alternate across Separation and DeviceN; PDF/A-2 rejects a second. #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] @@ -1084,16 +1111,23 @@ fn print_settings_reject_a_profile_that_is_not_cmyk() { } /// `/Trapped` (`Unknown` included) and `GTS_PDFXVersion` go to the information dictionary and -/// to XMP; without settings neither appears. +/// to XMP, `GTS_PDFXConformance` to the dictionary only; without settings none appears. #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn trapped_and_pdfx_version_are_written_to_info_and_xmp() { - let settings = SerializeSettings { trapped: Some(Trapped::True), pdfx_version: Some("PDF/X-4".to_owned()), ..Default::default() }; + let settings = SerializeSettings { + trapped: Some(Trapped::True), + pdfx_version: Some("PDF/X-1:2003".to_owned()), + pdfx_conformance: Some("PDF/X-1a:2003".to_owned()), + ..Default::default() + }; let marked = String::from_utf8_lossy(&sample(settings, None).unwrap()).into_owned(); assert!(marked.contains("/Trapped/True"), "Info /Trapped"); - assert!(marked.contains("/GTS_PDFXVersion(PDF/X-4)"), "Info /GTS_PDFXVersion"); + assert!(marked.contains("/GTS_PDFXVersion(PDF/X-1:2003)"), "Info /GTS_PDFXVersion"); + assert!(marked.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "Info /GTS_PDFXConformance"); + assert!(!marked.contains("pdfxid:GTS_PDFXConformance"), "no XMP conformance property"); assert!(marked.contains("True"), "XMP pdf:Trapped"); - assert!(marked.contains("PDF/X-4"), "XMP GTS_PDFXVersion"); + assert!(marked.contains("PDF/X-1:2003"), "XMP GTS_PDFXVersion"); let unknown = SerializeSettings { trapped: Some(Trapped::Unknown), ..Default::default() }; let unknown = String::from_utf8_lossy(&sample(unknown, None).unwrap()).into_owned(); diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index 9d4e0fe9..de77d490 100644 --- a/crates/pdf/tests/writing.rs +++ b/crates/pdf/tests/writing.rs @@ -16,6 +16,7 @@ fn document(compress: bool) -> Vec { description: Some("Original synthetic artwork".into()), keywords: vec!["craft".into(), "pdf".into()], created: Some(backend::metadata::DateTime::new(2026).month(10).day(9)), + modified: Some(backend::metadata::DateTime::new(2026).month(10).day(12)), }; let settings = backend::SerializeSettings { compress_content_streams: compress, @@ -49,9 +50,25 @@ fn writes_pages_metadata_and_selected_version_without_a_renderer() { let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes.clone())).unwrap(); let sizes: Vec<_> = pdf.pages().iter().map(|page| page.render_dimensions()).collect(); assert_eq!(sizes, vec![(80.0, 40.0), (30.0, 60.0)]); + let day = |d: &Option| d.as_ref().map(|d| (d.year, d.month, d.day)); + assert_eq!(day(&pdf.metadata().creation_date), Some((2026, 10, 9))); + assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 12))); + assert!(text.contains("2026-10-09") && text.contains("2026-10-12"), "XMP dates"); } } +/// Without a modification date, the creation date is written as both. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn the_creation_date_is_the_modification_date_when_none_is_given() { + let created = Some(backend::metadata::DateTime::new(2026).month(10).day(9)); + let mut writer = Writer::new(Default::default(), Metadata { created, ..Default::default() }); + writer.document_mut().start_page_with(PageSettings::from_wh(40.0, 20.0).unwrap()).finish(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(writer.finish().unwrap())).unwrap(); + let day = |d: &Option| d.as_ref().map(|d| (d.year, d.month, d.day)); + assert_eq!(day(&pdf.metadata().modification_date), Some((2026, 10, 9))); +} + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] #[cfg_attr(not(target_arch = "wasm32"), test)] fn fixed_input_does_not_read_a_clock_or_change_pdf_bytes() { diff --git a/crates/plate-test/README.md b/crates/plate-test/README.md index 6a376f19..8b1f7056 100644 --- a/crates/plate-test/README.md +++ b/crates/plate-test/README.md @@ -17,6 +17,18 @@ and the PDF structure a correct print export has. `tests/scenes.rs` parses every scene, writes every scene without `pending`, and checks the structure. It runs in CI in seconds, with the unit tests of the TIFF reader and the judge. +## Scenes + +The file names say what each scene tests; `title` and `source` say more. + +| IDs | Cases | +|---|---| +| `S01`–`S17` | Print-color research cases: overprint, spots, blending spaces, knockout and isolated groups, masks, placed PDFs, gradients | +| `D01`–`D10` | DesignCraft print cases (its DC list) and a GWG idea (D04) | +| `M1`, `M2`, `N1` | Luminosity mask backdrop and transfer; DeviceN overprint | +| `F01` | Flattener: a semi-transparent image over a flat fill; where the image's alpha is 0 the fill prints unchanged (vectorcraft#2160) | +| `F02` | Flattener: art hidden under opaque fills, with transparency over them, leaves no trace (vectorcraft#2161) | + ## Scene format Coordinates are points from the page's top-left corner; rectangles are `[x, y, width, height]`. @@ -57,7 +69,9 @@ Paints: Images are synthetic: `{"px": [w, h], "ramp": [paint, paint]}` (left to right), `"split": [paint, paint]` (left and right halves) or `"feather": pt` (a gray soft-mask image, white inside and fading to black at the edges). Shadows and feathers are luminosity masks over -such an image. +such an image. A ramp or split image can carry `"alpha": {"rect": [x, y, w, h], "opacity": 0.6}`, +written as its `/SMask` like a PNG with transparency: pixels whose centers lie in `rect` (page +pt) have that opacity, the rest 0. Tolerances: ±2 for device, spot and overprint values; ±6 for blends and ICC; ±10 for shadows and gradients. @@ -131,6 +145,13 @@ is reported as such: its plates don't show the missing feature under Ghostscript `structure` block is the check that does. The test fails when a scene without `pending` fails or the harness errors. +`scripts/plate-check.sh --flatten` runs the same check on each scene's page flattened by +craft-prepress (`flatten_page` at 300 dpi; the test is `flatten_plates` in craft-prepress, since +craft-prepress dev-depends on this crate). The flattened page must also have no transparency +left (`structure::transparency`: transparency groups, soft masks in graphics states, blend modes +other than Normal, `ca`/`CA` below 1). Its report goes to `plate-test/flattened/` and shows both +pages side by side, with the flattener's time per scene. + The results go to `plate-test/report.md` and `results.json` in Cargo's target directory (`CARGO_TARGET_DIR` is respected). The script sets `CRAFT_PDF_TEST_FONT_DIR` to `~/.cache/craft-pdf-test-fonts` when the font is there; without the font, text points are skipped. diff --git a/crates/plate-test/scenes/F01-image-alpha-over-fill.json b/crates/plate-test/scenes/F01-image-alpha-over-fill.json new file mode 100644 index 00000000..5aac7245 --- /dev/null +++ b/crates/plate-test/scenes/F01-image-alpha-over-fill.json @@ -0,0 +1,48 @@ +{ + "id": "F01", + "title": "Semi-transparent image over a flat process fill: where the image's alpha is 0, the fill prints unchanged", + "source": "vectorcraft#2160 (X-1a flattening washed out a fill behind a transparent PNG)", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + {"type": "rect", "rect": [20, 20, 320, 176], "fill": {"cmyk": [100, 6, 41, 3]}}, + { + "type": "image", + "rect": [60, 48, 290, 120], + "image": { + "px": [290, 120], + "split": [{"cmyk": [0, 100, 0, 0]}, {"cmyk": [0, 0, 0, 100]}], + "alpha": {"rect": [100, 78, 160, 60], "opacity": 0.6} + }, + "note": "image alpha 0.6 inside x 100–260, y 78–138; 0 in the rest of the image, which reaches past the fill on the right" + } + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [40, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "fill clear of the image"}, + {"at": [80, 108], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"}, + {"at": [180, 60], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"}, + {"at": [300, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "image alpha 0 over the fill"}, + {"at": [345, 108], "inks": {}, "tol": 0.5, "note": "image alpha 0 over paper"}, + { + "at": [150, 108], + "inks": {"Cyan": 40, "Magenta": 62.4, "Yellow": 16.4, "Black": 1.2}, + "tol": 6, + "note": "M100 at 60% over the fill, CMYK blending" + }, + { + "at": [230, 108], + "inks": {"Cyan": 40, "Magenta": 2.4, "Yellow": 16.4, "Black": 61.2}, + "tol": 6, + "note": "K100 at 60% over the fill, CMYK blending" + } + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/scenes/F02-hidden-art.json b/crates/plate-test/scenes/F02-hidden-art.json new file mode 100644 index 00000000..bd856de3 --- /dev/null +++ b/crates/plate-test/scenes/F02-hidden-art.json @@ -0,0 +1,63 @@ +{ + "id": "F02", + "title": "Art hidden under opaque fills, with transparency over them: nothing of it shows", + "source": "vectorcraft#2161 (old art under opaque layers showed through an X-1a export)", + "page": [360, 216], + "settings": {"group_cs": "cmyk"}, + "objects": [ + { + "type": "group", + "note": "an old copy of the page, still visible and fully covered", + "objects": [ + {"type": "rect", "rect": [190, 30, 140, 156], "fill": {"cmyk": [0, 30, 0, 0]}}, + {"type": "text", "at": [200, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}}, + {"type": "rect", "rect": [200, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}}, + {"type": "rect", "rect": [200, 115, 120, 0.5], "fill": {"cmyk": [0, 0, 0, 100]}}, + {"type": "rect", "rect": [200, 130, 12, 12], "stroke": {"paint": {"cmyk": [0, 0, 0, 100]}, "width": 1}}, + { + "type": "image", + "rect": [260, 140, 60, 40], + "image": {"px": [6, 4], "split": [{"cmyk": [100, 0, 0, 0]}, {"cmyk": [0, 0, 100, 0]}]} + }, + {"type": "rect", "rect": [200, 150, 50, 30], "fill": {"cmyk": [0, 100, 0, 0]}, "opacity": 0.5}, + {"type": "text", "at": [30, 80], "size": 40, "text": "HHH", "fill": {"cmyk": [0, 0, 0, 100]}}, + {"type": "rect", "rect": [30, 100, 120, 6], "fill": {"cmyk": [0, 0, 0, 100]}} + ] + }, + {"type": "rect", "rect": [20, 20, 150, 176], "fill": {"cmyk": [0, 0, 0, 0]}, "note": "white card"}, + {"type": "rect", "rect": [180, 20, 160, 176], "fill": {"cmyk": [100, 6, 41, 3]}, "note": "teal box"}, + {"type": "rect", "rect": [100, 160, 200, 20], "fill": {"cmyk": [0, 0, 0, 100]}, "opacity": 0.3, "note": "a 30% black band over both"} + ], + "expect": { + "plates": ["Cyan", "Magenta", "Yellow", "Black"], + "points": [ + {"at": [230, 103], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden K bar"}, + {"at": [215, 70], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over hidden text"}, + {"at": [290, 150], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden image"}, + {"at": [206, 136], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over a hidden checkbox"}, + {"at": [335, 25], "inks": {"Cyan": 100, "Magenta": 6, "Yellow": 41, "Black": 3}, "tol": 0.5, "note": "teal over nothing"}, + {"at": [60, 103], "inks": {}, "tol": 0.5, "note": "white card over a hidden K bar"}, + {"at": [45, 70], "inks": {}, "tol": 0.5, "note": "white card over hidden text"}, + {"at": [175, 108], "inks": {}, "tol": 0.5, "note": "paper between the card and the box"}, + { + "at": [220, 170], + "inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1}, + "tol": 2, + "note": "band over teal over a hidden 50% magenta fill" + }, + { + "at": [290, 170], + "inks": {"Cyan": 70, "Magenta": 4.2, "Yellow": 28.7, "Black": 32.1}, + "tol": 2, + "note": "band over teal over a hidden image" + }, + {"at": [130, 170], "inks": {"Black": 30}, "tol": 2, "note": "band over the white card"} + ] + }, + "structure": { + "page_group": "DeviceCMYK", + "color_spaces": ["DeviceCMYK"], + "device_rgb": false, + "output_intents": [] + } +} diff --git a/crates/plate-test/src/reference.rs b/crates/plate-test/src/reference.rs index a9623d64..2bae10ff 100644 --- a/crates/plate-test/src/reference.rs +++ b/crates/plate-test/src/reference.rs @@ -411,7 +411,7 @@ impl Resolver<'_, '_> { DeviceNSpace::new(colorants).ok_or_else(|| self.bad("bad DeviceN ink list")) } - fn image(&self, source: &ImageSource, [_, _, w, h]: [f32; 4]) -> Result { + fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result { let [px, py] = source.px; let (width, height) = (px as usize, py as usize); let image = if let Some(radius) = source.feather { @@ -423,7 +423,7 @@ impl Resolver<'_, '_> { data.push(channel(255.0 * (edge / radius.max(f32::EPSILON)).min(1.0))); } } - Synthetic { data, size: (px, py), space: ImageColorspace::Luma, device_n: None } + Synthetic { data, alpha: None, size: (px, py), space: ImageColorspace::Luma, device_n: None } } else { let (pair, ramp) = match (&source.ramp, &source.split) { (Some(pair), _) => (pair, true), @@ -456,7 +456,20 @@ impl Resolver<'_, '_> { data.extend(a.iter().zip(&b).map(|(a, b)| channel(a + (b - a) * t))); } } - Synthetic { data, size: (px, py), space, device_n } + let alpha = source.alpha.as_ref().map(|a| { + let [ax, ay, aw, ah] = a.rect; + let value = channel(a.opacity * 255.0); + let mut alpha = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let (fx, fy) = (x0 + (x as f32 + 0.5) * w / px as f32, y0 + (y as f32 + 0.5) * h / py as f32); + let inside = (ax..ax + aw).contains(&fx) && (ay..ay + ah).contains(&fy); + alpha.push(if inside { value } else { 0 }); + } + } + alpha + }); + Synthetic { data, alpha, size: (px, py), space, device_n } }; Image::from_custom(image, false).map_err(|e| self.bad(&e)) } @@ -574,6 +587,7 @@ fn ellipse_path(x: f32, y: f32, w: f32, h: f32) -> Option { #[derive(Clone, Hash)] struct Synthetic { data: Vec, + alpha: Option>, size: (u32, u32), space: ImageColorspace, device_n: Option, @@ -584,7 +598,7 @@ impl CustomImage for Synthetic { &self.data } fn alpha_channel(&self) -> Option<&[u8]> { - None + self.alpha.as_deref() } fn bits_per_component(&self) -> BitsPerComponent { BitsPerComponent::Eight diff --git a/crates/plate-test/src/report.rs b/crates/plate-test/src/report.rs index 405d754e..246400c4 100644 --- a/crates/plate-test/src/report.rs +++ b/crates/plate-test/src/report.rs @@ -2,16 +2,21 @@ //! //! Pending scenes are written as far as the writer can ([`reference::write_partial`]) and are //! expected to fail; their results show what the writer prints today. +//! +//! [`run_flattened`] also flattens each scene's PDF with a caller's flattener and checks the +//! flattened page the same way, plus [`structure::transparency`]. The flattener comes from the +//! caller so that this crate doesn't depend on it. use std::fmt::Write as _; use std::path::{Path, PathBuf}; +use std::time::Instant; use serde::Serialize; use crate::judge::{self, Judgement, Verdict}; use crate::reference::{self, Context}; use crate::scene::{Level, PROCESS, Scene}; -use crate::{Error, gs}; +use crate::{Error, gs, structure}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -57,8 +62,26 @@ pub struct SceneResult { /// Why the scene was not written or judged. pub error: Option, pub judgement: Option, + /// The flattened page's result, from [`run_flattened`]. + pub flattened: Option, } +/// One scene's flattened page. +#[derive(Debug, Clone, Serialize)] +pub struct FlatResult { + /// As for the source page; a pass also needs `transparency` empty. + pub status: Status, + /// What [`structure::transparency`] found in the flattened page. + pub transparency: Vec, + /// Time spent in the flattener. + pub millis: f64, + pub error: Option, + pub judgement: Option, +} + +/// Flattens a scene's PDF: the flattened PDF, or why it failed. +pub type Flatten<'a> = &'a dyn Fn(&[u8]) -> Result, String>; + #[derive(Debug, Clone, Serialize)] pub struct Report { pub ghostscript: String, @@ -66,13 +89,40 @@ pub struct Report { pub scenes: Vec, } +impl Report { + /// Scenes whose source or flattened result is not what their `pending` field says. + pub fn unexpected(&self) -> Vec { + let mut out = Vec::new(); + for r in &self.scenes { + if !r.status.as_expected() { + out.push(format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default())); + } + if let Some(f) = r.flattened.as_ref().filter(|f| !f.status.as_expected()) { + let why = f.error.clone().or_else(|| (!f.transparency.is_empty()).then(|| f.transparency.join(", "))); + out.push(format!("{} flattened {:?}{}", r.id, f.status, why.map(|e| format!(": {e}")).unwrap_or_default())); + } + } + out + } +} + /// Write, separate and judge every scene in `ctx.scenes`. pub fn run(ctx: &Context<'_>, ghostscript: String) -> Report { Report { ghostscript, resolution: gs::RESOLUTION, scenes: ctx.scenes.iter().map(|scene| check_scene(scene, ctx)).collect() } } +/// [`run`], and each scene's page flattened by `flatten`, separated and judged as well. +pub fn run_flattened(ctx: &Context<'_>, ghostscript: String, flatten: Flatten<'_>) -> Report { + let scenes = ctx.scenes.iter().map(|scene| check_scene_with(scene, ctx, Some(flatten))).collect(); + Report { ghostscript, resolution: gs::RESOLUTION, scenes } +} + /// Write, separate and judge one scene. pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult { + check_scene_with(scene, ctx, None) +} + +fn check_scene_with(scene: &Scene, ctx: &Context<'_>, flatten: Option>) -> SceneResult { let mut result = SceneResult { id: scene.id.clone(), title: scene.title.clone(), @@ -81,6 +131,7 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult { notes: Vec::new(), error: None, judgement: None, + flattened: None, }; let written = if scene.pending.is_some() { reference::write_partial(scene, ctx) } else { reference::write(scene, ctx) }; let output = match written { @@ -96,22 +147,58 @@ pub fn check_scene(scene: &Scene, ctx: &Context<'_>) -> SceneResult { } }; result.notes = output.notes; - let plates = match gs::separate(&output.pdf, &scene.id) { - Ok(plates) => plates, + result.flattened = flatten.map(|f| check_flattened(scene, ctx, &output.pdf, f)); + match separate_and_judge(scene, ctx, &output.pdf, &scene.id) { + Ok(judgement) => { + result.status = status(scene, judgement.passed()); + result.judgement = Some(judgement); + } + Err(e) => result.error = Some(e.to_string()), + } + result +} + +fn check_flattened(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], flatten: Flatten<'_>) -> FlatResult { + let mut result = FlatResult { status: Status::Error, transparency: Vec::new(), millis: 0.0, error: None, judgement: None }; + let start = Instant::now(); + let flat = flatten(pdf); + result.millis = start.elapsed().as_secs_f64() * 1000.0; + let flat = match flat { + Ok(flat) => flat, + Err(e) => { + result.error = Some(format!("flatten: {e}")); + return result; + } + }; + match structure::summarize(&flat) { + Ok(summary) => result.transparency = structure::transparency(&summary), Err(e) => { result.error = Some(e.to_string()); return result; } - }; - let judgement = judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some()); - result.status = match (scene.pending.is_some(), judgement.passed()) { + } + match separate_and_judge(scene, ctx, &flat, &format!("{}-flat", scene.id)) { + Ok(judgement) => { + result.status = status(scene, judgement.passed() && result.transparency.is_empty()); + result.judgement = Some(judgement); + } + Err(e) => result.error = Some(e.to_string()), + } + result +} + +fn separate_and_judge(scene: &Scene, ctx: &Context<'_>, pdf: &[u8], label: &str) -> Result { + let plates = gs::separate(pdf, label)?; + Ok(judge::judge(scene, &plates, gs::RESOLUTION, ctx.font.is_some())) +} + +fn status(scene: &Scene, passed: bool) -> Status { + match (scene.pending.is_some(), passed) { (false, true) => Status::Pass, (false, false) => Status::Fail, (true, false) => Status::ExpectedFail, (true, true) => Status::PendingPasses, - }; - result.judgement = Some(judgement); - result + } } /// Cargo's target directory: `CARGO_TARGET_DIR` (relative to the workspace root) or `target`. @@ -143,6 +230,7 @@ pub fn write(report: &Report, dir: &Path) -> Result { /// The report as Markdown. pub fn markdown(report: &Report) -> String { + let flat = report.scenes.iter().any(|s| s.flattened.is_some()); let mut md = String::new(); let _ = writeln!( md, @@ -151,20 +239,36 @@ pub fn markdown(report: &Report) -> String { they wait for and are expected to fail. Generated by `scripts/plate-check.sh`.\n", report.ghostscript, report.resolution ); - md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n"); + if flat { + md.push_str( + "Each scene's page is also flattened and separated the same way. \"Transparency left\" lists transparency \ + groups, graphics-state soft masks, blend modes and `ca`/`CA` below 1 in the flattened page.\n\n", + ); + md.push_str( + "| Scene | Status | Pending | Failing points | Plates | Flattened | Failing points | Plates | Transparency left | Flatten ms |\n\ + |---|---|---|---|---|---|---|---|---|---|\n", + ); + } else { + md.push_str("| Scene | Status | Pending | Failing points | Plates |\n|---|---|---|---|---|\n"); + } for scene in &report.scenes { - let (points, plates) = match &scene.judgement { - Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())), - None => ("–".into(), "–".into()), - }; + let (points, plates) = summary_cells(scene.judgement.as_ref()); let pending = scene.pending.as_deref().unwrap_or(""); - let _ = writeln!( + let _ = write!( md, "| [{id}](#{anchor}) | {} | {pending} | {points} | {plates} |", scene.status.label(), id = scene.id, anchor = scene.id.to_lowercase() ); + if let Some(f) = &scene.flattened { + let (points, plates) = summary_cells(f.judgement.as_ref()); + let left = if f.transparency.is_empty() { "none".to_owned() } else { f.transparency.join(", ") }; + let _ = write!(md, " {} | {points} | {plates} | {left} | {:.0} |", f.status.label(), f.millis); + } else if flat { + md.push_str(" – | – | – | – | – |"); + } + md.push('\n'); } for scene in &report.scenes { let _ = writeln!(md, "\n## {}\n\n{}. Status: {}.", scene.id, scene.title, scene.status.label()); @@ -177,34 +281,71 @@ pub fn markdown(report: &Report) -> String { for note in &scene.notes { let _ = writeln!(md, "- {note}"); } + let flat_judgement = scene.flattened.as_ref().and_then(|f| f.judgement.as_ref()); + if let Some(f) = &scene.flattened { + let _ = writeln!(md, "\nFlattened: {}, {:.0} ms.", f.status.label(), f.millis); + if let Some(error) = &f.error { + let _ = writeln!(md, "\n{error}"); + } + if !f.transparency.is_empty() { + let _ = writeln!(md, "Transparency left: {}.", f.transparency.join(", ")); + } + if let Some(j) = flat_judgement { + let _ = writeln!(md, "Flattened plates: {}.{}", j.plates.join(", "), plate_problems(j).map(|p| format!(" {p}.")).unwrap_or_default()); + } + } let Some(judgement) = &scene.judgement else { continue }; let _ = writeln!(md, "\nPlates: {}.{}", judgement.plates.join(", "), plate_problems(judgement).map(|p| format!(" {p}.")).unwrap_or_default()); - md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n"); + if scene.flattened.is_some() { + md.push_str("\n| # | Point | Expected | Measured | Verdict | Flattened | Verdict |\n|---|---|---|---|---|---|---|\n"); + } else { + md.push_str("\n| # | Point | Expected | Measured | Verdict |\n|---|---|---|---|---|\n"); + } for (i, point) in judgement.points.iter().enumerate() { - let verdict = match point.verdict { - Verdict::Pass => "pass".to_owned(), - Verdict::Info => "info".to_owned(), - Verdict::Skipped => "skipped".to_owned(), - Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(), - Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::>().join(" ")), - }; let how = point.how.as_deref().map(|h| format!("; {h}")).unwrap_or_default(); let note = point.note.as_deref().map(|n| format!("{n} ")).unwrap_or_default(); - let _ = writeln!( + let _ = write!( md, - "| {} | {note}({}, {}){how} | {} ±{} | {} | {verdict} |", + "| {} | {note}({}, {}){how} | {} ±{} | {} | {} |", i + 1, point.at[0], point.at[1], expected(&point.expected), point.tol, measured(&point.measured), + verdict(point), ); + if scene.flattened.is_some() { + match flat_judgement.and_then(|j| j.points.get(i)) { + Some(p) => { + let _ = write!(md, " {} | {} |", measured(&p.measured), verdict(p)); + } + None => md.push_str(" – | – |"), + } + } + md.push('\n'); } } md } +fn summary_cells(judgement: Option<&Judgement>) -> (String, String) { + match judgement { + Some(j) => (format!("{}/{}", j.failed_points(), j.judged_points()), plate_problems(j).unwrap_or_else(|| "ok".into())), + None => ("–".into(), "–".into()), + } +} + +fn verdict(point: &judge::PointResult) -> String { + match point.verdict { + Verdict::Pass => "pass".to_owned(), + Verdict::Info => "info".to_owned(), + Verdict::Skipped => "skipped".to_owned(), + Verdict::Fail if point.off.is_empty() => "**fail**".to_owned(), + Verdict::Fail => format!("**fail** ({})", point.off.iter().map(|p| short(p)).collect::>().join(" ")), + } +} + fn plate_problems(judgement: &Judgement) -> Option { let mut parts = Vec::new(); if !judgement.missing_plates.is_empty() { diff --git a/crates/plate-test/src/scene.rs b/crates/plate-test/src/scene.rs index ad065764..422db7dd 100644 --- a/crates/plate-test/src/scene.rs +++ b/crates/plate-test/src/scene.rs @@ -232,6 +232,17 @@ pub struct ImageSource { /// Gray image for soft masks: white inside, fading to black over this many pt at the edges. #[serde(default)] pub feather: Option, + /// An alpha channel, written as the image's `/SMask` (a PNG with transparency). + #[serde(default)] + pub alpha: Option, +} + +/// Image alpha: `opacity` (0–1) for pixels whose centers lie inside `rect` (page pt), 0 for the rest. +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ImageAlpha { + pub rect: [f32; 4], + pub opacity: f32, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -463,6 +474,9 @@ impl Scene { if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 { out.push("image px must be 1–4096".into()); } + if image.alpha.as_ref().is_some_and(|a| image.feather.is_some() || !(0.0..=1.0).contains(&a.opacity)) { + out.push("image alpha needs a color image and an opacity of 0–1".into()); + } } if (kind == Kind::Pdf) != object.scene.is_some() { out.push(format!("{kind:?}: scene belongs to (and is required by) pdf objects")); diff --git a/crates/plate-test/src/structure.rs b/crates/plate-test/src/structure.rs index d551cb67..c51ebf0b 100644 --- a/crates/plate-test/src/structure.rs +++ b/crates/plate-test/src/structure.rs @@ -52,6 +52,12 @@ pub struct ExtGState { pub opm: Option, #[serde(rename = "BM", skip_serializing_if = "Option::is_none")] pub bm: Option, + /// `/ca` (fill and text alpha). + #[serde(rename = "ca", skip_serializing_if = "Option::is_none")] + pub alpha_fill: Option, + /// `/CA` (stroke alpha). + #[serde(rename = "CA", skip_serializing_if = "Option::is_none")] + pub alpha_stroke: Option, /// The soft mask's `/S`, when the state sets a mask. #[serde(rename = "SMask", skip_serializing_if = "Option::is_none")] pub smask: Option, @@ -208,6 +214,8 @@ impl Walk { op_stroke: gs.get::(b"OP".as_slice()), op_fill: gs.get::(b"op".as_slice()), opm: gs.get::(b"OPM".as_slice()), + alpha_fill: gs.get::(b"ca".as_slice()), + alpha_stroke: gs.get::(b"CA".as_slice()), bm: gs.get::>(b"BM".as_slice()).and_then(|bm| match bm { Object::Name(name) => Some(name.as_str().to_owned()), Object::Array(names) => names.iter::>().next().map(|n| n.as_str().to_owned()), @@ -308,6 +316,29 @@ fn describe(cs: &Object<'_>, depth: usize) -> String { } } +/// Live transparency left in a page that should have none, as a flattener's output must: transparency +/// groups, soft masks in graphics states, blend modes other than Normal, and `ca` or `CA` below 1. +/// Soft masks of images are not graphics states and are allowed. Empty when there is none. +pub fn transparency(summary: &Summary) -> Vec { + let mut out: Vec = summary.groups.iter().filter(|g| g.s == "Transparency").map(|g| format!("transparency group ({})", g.at)).collect(); + for gs in &summary.ext_g_states { + if let Some(mask) = gs.smask.as_deref().filter(|m| !m.is_empty() && *m != "None") { + out.push(format!("soft mask /{mask}")); + } + if let Some(bm) = gs.bm.as_deref().filter(|b| *b != "Normal" && *b != "Compatible") { + out.push(format!("blend mode /{bm}")); + } + for (key, alpha) in [("ca", gs.alpha_fill), ("CA", gs.alpha_stroke)] { + if let Some(a) = alpha.filter(|a| *a < 1.0) { + out.push(format!("/{key} {a}")); + } + } + } + out.sort(); + out.dedup(); + out +} + /// A scene's `structure` block. Every field is optional; only the fields present are checked. #[derive(Debug, Clone, Default, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/crates/plate-test/tests/ghostscript.rs b/crates/plate-test/tests/ghostscript.rs index f0ca9b71..ae2cd38a 100644 --- a/crates/plate-test/tests/ghostscript.rs +++ b/crates/plate-test/tests/ghostscript.rs @@ -26,12 +26,7 @@ fn every_scene_separates_as_expected() { println!("{} {:?}{pending}", result.id, result.status); } println!("report: {}", path.display()); - let wrong: Vec = report - .scenes - .iter() - .filter(|r| !r.status.as_expected()) - .map(|r| format!("{} {:?}{}", r.id, r.status, r.error.as_deref().map(|e| format!(": {e}")).unwrap_or_default())) - .collect(); + let wrong = report.unexpected(); assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n")); assert!(report.scenes.iter().any(|r| r.status == Status::Pass)); } diff --git a/crates/prepress/Cargo.toml b/crates/prepress/Cargo.toml index d27432ff..e78fbbd8 100644 --- a/crates/prepress/Cargo.toml +++ b/crates/prepress/Cargo.toml @@ -14,3 +14,14 @@ workspace = true craft-pdf = { workspace = true, features = ["raster", "write"] } craft-cms = { workspace = true } thiserror = { workspace = true } +kurbo = { workspace = true, optional = true } +linesweeper = { version = "0.5", optional = true } +ttf-parser = { version = "=0.25.1", optional = true } + +[dev-dependencies] +craft-plate-test = { workspace = true } +ttf-parser = "=0.25.1" + +[features] +# The transparency flattener. +flatten = ["craft-pdf/write-pdf", "dep:kurbo", "dep:linesweeper", "dep:ttf-parser"] diff --git a/crates/prepress/README.md b/crates/prepress/README.md index 6ebc7644..49beb635 100644 --- a/crates/prepress/README.md +++ b/crates/prepress/README.md @@ -14,7 +14,24 @@ Prepress for the Craft apps: separations, ink limits and the ink manager. - `composite`: an sRGB preview of chosen plates. - `cms`: process color conversion into a CMYK printing condition through craft-cms. -A transparency flattener is planned as a module behind a `flatten` feature. +- `flatten` (feature `flatten`): the display list a transparency flattener reads and writes: + fills, text runs, images and transparency groups in page space, with ink colors and overprint + per item. Text runs keep their font (bytes, face index, a dedup key), glyph ids and positions, + and Unicode text, so text without transparency stays live text; outlines are kept as the + fallback for fonts that can't be embedded again. `flatten` turns the transparent parts into + opaque region fills, compositing one value per ink (process and spot) with blend modes, group + opacity, isolated and knockout groups, and overprint per plate. Items without transparency + come back unchanged (JPEG images in DeviceGray or DeviceCMYK keep their DCT data; other images + are written as 8-bit Flate samples); regions a gradient, image or soft mask reaches are listed + for rasterizing. `flatten::raster` rasterizes them: each region's items are written alone, + separated with `plates` and written back as a DeviceN image of the page's inks, clipped to the + region, snapped to the device grid and a pixel larger than the clip so no paper shows at its + edges. `flatten_document` flattens every page of a PDF, or a range of pages, into one PDF with + the source's output intent, page boxes, title, author, creation date and modification date; a + font used on several pages is embedded once. The caller sets the PDF version and the PDF/X + version and conformance keys (for PDF/X-1a:2003, 1.4, `PDF/X-1:2003` and `PDF/X-1a:2003`); + krilla has no PDF/X validator, so the app keeps its own PDF/X checks. `flatten_page` is its + one-page case. ## Plates from an exported PDF @@ -193,5 +210,15 @@ if let Some(color) = to_pdf_color(&printed, &inks, &cms) { (guillehr2), with craft-cms in place of moxcms. - Knockout groups in the renderer come from [storytold/pdfcraft#2974](https://github.com/storytold/pdfcraft/pull/2974) by mtlcl410. +- The flattener's planar regions (`flatten::geom`) are ported from VectorCraft at commit + `c2b15982` by the VectorCraft contributors: `crates/pathops/src/pathfinder.rs`, + `crates/pathops/src/boolean.rs` and `crates/engine/src/cmd/flatten.rs`. +- The flattener's compositor (`flatten::compose`) is ported from VectorCraft at commit + `c2b15982` by the VectorCraft contributors: `Composer`, `region_fill` and the raster/vector + balance in `crates/engine/src/cmd/flatten.rs`, and the blend formulas in + `crates/color/src/blend.rs`. +- The flattener's raster step (`flatten::raster`) follows VectorCraft's `render_raster` in + `crates/engine/src/cmd/flatten.rs` at commit `c2b15982`: the image reaching a pixel past its + area, and the coverage of the art drawn opaque as the image's alpha. -Both MIT OR Apache-2.0, as this crate. +All MIT OR Apache-2.0, as this crate. diff --git a/crates/prepress/src/flatten/compose.rs b/crates/prepress/src/flatten/compose.rs new file mode 100644 index 00000000..ba0cceef --- /dev/null +++ b/crates/prepress/src/flatten/compose.rs @@ -0,0 +1,1161 @@ +//! The transparency flattener: transparent art becomes opaque fills that print the same. +//! +//! Items split into transitive groups of overlapping items ([`geom::transparent_groups`]). Groups +//! without transparency come back unchanged, so their text stays live. The items of the other +//! groups are cut into planar regions ([`geom::regions`]); the same paints cover all of a region, +//! so one composited color is exact for it. Colors composite as ink vectors, one subtractive value +//! per ink of the list's [`InkSet`], process and spot inks alike (ISO 32000-2 §11.3). Overprint +//! applies per ink while compositing (§11.7.4, `CompatibleOverprint`), never through blend modes or +//! knockout groups. Regions a gradient, image or soft mask reaches have no single color: they are +//! listed in [`Flattened::complex`] for the raster step. +//! +//! Overprint after flattening: a region fill has nothing under it, and its color already holds +//! every plate's result, so it does not overprint. The exception keeps a paint as it was: where a +//! region's color equals its top paint painted alone, the fill takes that paint's color and +//! overprint attributes, which print the same over paper. Kept items keep their attributes, since +//! what they overprint is still under them. +//! +//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: `Composer`, `region_fill` and +//! the raster/vector balance (`rasterize_all`) in `crates/engine/src/cmd/flatten.rs`, and the +//! blend formulas in `crates/color/src/blend.rs`. VectorCraft composites RGB or CMY+K planes; +//! here each ink is a plane, and groups follow the §11.4 formulas. + +use std::collections::HashMap; +use std::ops::Range; + +use kurbo::{BezPath, Rect, Shape as _}; + +use super::geom::{self, ClipPath, GeomError, Shape}; +use super::{BlendMode, DisplayList, FillItem, FillRule, GroupItem, Item, Paint}; +use crate::ink::{InkColor, InkId, InkSet, Overprint, Process}; + +/// How [`flatten`] cuts transparent art. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FlattenOptions { + /// Regions narrower than this (in points) are dropped ([`geom::regions_with`]). + pub sliver: f64, + /// A group that cuts into more regions than this goes to [`Flattened::complex`] whole, + /// as VectorCraft's raster/vector balance rasterizes it. + pub max_regions: usize, + /// The resolution complex regions are rasterized at ([`raster`](super::raster)). + pub raster_dpi: f32, +} + +impl Default for FlattenOptions { + fn default() -> Self { + // VectorCraft's medium preset (balance 75) allows 10^(75/20), about 5600 regions. + Self { sliver: geom::DEFAULT_SLIVER, max_regions: 5000, raster_dpi: 300.0 } + } +} + +/// A page with no transparency left. +#[derive(Clone, Debug, PartialEq)] +pub struct Flattened { + /// Kept items and region fills, in painting order. No group in it is transparent. + pub items: Vec, + /// Areas left for rasterizing, each disjoint from every item but those it paints before. + pub complex: Vec, + pub report: FlattenReport, +} + +/// An area without one flat color. +#[derive(Clone, Debug, PartialEq)] +pub struct ComplexRegion { + /// The area, nonzero rule. + pub path: BezPath, + /// Indices into [`DisplayList::items`] of the top-level items painting in the area, in + /// painting order. Rendered alone and clipped to `path`, they give its look. + pub items: Vec, + /// Paints before [`Flattened::items`]`[at]` (after all of them when `at` is their count). + pub at: usize, + /// Set when the region is a whole group ([`FlattenReport::complex_groups`]). + pub whole: Option, +} + +/// What [`flatten`] did. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct FlattenReport { + /// Top-level items returned unchanged. + pub kept: usize, + /// Top-level items cut into regions. + pub flattened: usize, + /// Regions computed. + pub regions: usize, + /// Region fills written. + pub fills: usize, + /// Entries of [`Flattened::complex`]. + pub complex_regions: usize, + /// Groups that went to [`Flattened::complex`] whole. + pub complex_groups: Vec, +} + +/// A group of items rasterized whole. +#[derive(Clone, Debug, PartialEq)] +pub struct ComplexGroup { + /// Indices into [`DisplayList::items`]. + pub items: Vec, + pub reason: ComplexReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ComplexReason { + /// The region sweep failed ([`GeomError::Degenerate`]). + Degenerate, + /// More regions than [`FlattenOptions::max_regions`]. + TooManyRegions(usize), +} + +#[derive(Clone, Debug, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum FlattenError { + #[error("a color names ink {0:?}, which is not in the list's ink set")] + UnknownInk(InkId), + #[error(transparent)] + Geometry(#[from] GeomError), +} + +/// `list` with its transparency flattened. Items that neither involve transparency nor overlap +/// an item that does come back unchanged, and so do opaque items with no transparency painted +/// above them where they paint. +pub fn flatten(list: &DisplayList, opts: &FlattenOptions) -> Result { + let nan = Rect::new(f64::NAN, f64::NAN, f64::NAN, f64::NAN); + let bounds: Vec = list.items.iter().map(|i| bounds(i).unwrap_or(nan)).collect(); + let transparent: Vec = list.items.iter().map(is_transparent).collect(); + + #[derive(Clone, Copy)] + enum Plan { + Keep, + Drop, + Emit(usize), + } + let groups = geom::transparent_groups(&bounds, &transparent); + let mut plan = vec![Plan::Keep; list.items.len()]; + let mut members: Vec> = Vec::with_capacity(groups.len()); + for g in &groups { + // Opaque items above the group's last transparent item paint over flat regions as they + // would over the composited art, overprint included: they stay as they are. + let Some(&last) = g.iter().rev().find(|&&i| transparent[i]) else { continue }; + let m: Vec = g.iter().copied().filter(|&i| i <= last).collect(); + for &i in &m { + plan[i] = Plan::Drop; + } + plan[last] = Plan::Emit(members.len()); + members.push(m); + } + + let mut out = Flattened { items: Vec::new(), complex: Vec::new(), report: FlattenReport::default() }; + for (i, item) in list.items.iter().enumerate() { + match plan[i] { + Plan::Keep => { + out.items.push(without_groups(item)); + out.report.kept += 1; + } + Plan::Drop => {} + Plan::Emit(g) => flatten_group(list, &members[g], &bounds, &transparent, opts, &mut out)?, + } + } + out.report.complex_regions = out.complex.len(); + Ok(out) +} + +fn flatten_group( + list: &DisplayList, + members: &[usize], + bounds: &[Rect], + transparent: &[bool], + opts: &FlattenOptions, + out: &mut Flattened, +) -> Result<(), FlattenError> { + let mut b = Builder::new(&list.inks); + let mut nodes = Vec::new(); + for &i in members { + b.top = i; + if let Some(n) = b.node(&list.items[i], false)? { + nodes.push(n); + } + } + let whole = |out: &mut Flattened, path: BezPath, reason| { + out.complex.push(ComplexRegion { path, items: members.to_vec(), at: out.items.len(), whole: Some(reason) }); + out.report.complex_groups.push(ComplexGroup { items: members.to_vec(), reason }); + }; + let regions = match geom::regions_with(&b.shapes, &b.clips, opts.sliver) { + Ok(r) => r, + Err(GeomError::Degenerate) => { + // Each member's box is clear of every other group's items. + let mut path = BezPath::new(); + for &i in members { + if !bounds[i].is_nan() { + path.extend(bounds[i].abs().path_elements(0.1)); + } + } + whole(out, path, ComplexReason::Degenerate); + out.report.flattened += members.len(); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + out.report.regions += regions.len(); + if regions.len() > opts.max_regions { + let mut path = BezPath::new(); + for r in ®ions { + path.extend(r.path.iter()); + } + whole(out, path, ComplexReason::TooManyRegions(regions.len())); + out.report.flattened += members.len(); + return Ok(()); + } + + // Members drawn as they are, over the region fills of the others: opaque, with only kept + // members above them wherever they paint. Painting them in order over the composite of what + // is under them gives the same page, and text among them stays live text. + let kept = kept_members(members, transparent, &b.owners, ®ions); + let is_kept = |s: usize| kept.binary_search(&b.owners[s]).is_ok(); + + // A region's look depends only on its visible sources: the last one that covers what is + // under it ([`Builder::covers`]) and those above it. Regions with the same visible sources + // are one area of one look, so they are written as one: art hidden under an opaque fill + // must not cut it into pieces, whose edges anti-aliasing viewers show (vectorcraft#2161). + let mut by_visible: HashMap, BezPath> = HashMap::new(); + let mut order = Vec::new(); + for r in regions { + // Kept members come last in a region's sources. The fill is the look of what is under + // them, also where one covers it: the region then joins its neighbors of that look + // instead of leaving a hole whose edge anti-aliasing viewers show. + let below = r.sources.iter().position(|&s| is_kept(s)).unwrap_or(r.sources.len()); + if below == 0 { + continue; + } + let sources = &r.sources[..below]; + let from = sources.iter().rposition(|&s| b.covers[s]).unwrap_or(0); + let visible = sources[from..].to_vec(); + let path = oriented(r.path); + match by_visible.get_mut(&visible) { + Some(p) => p.extend(path), + None => { + order.push(visible.clone()); + by_visible.insert(visible, path); + } + } + } + + let mut complex = Vec::new(); + let mut fills: Vec<(Option, FillItem)> = Vec::new(); + for sources in order { + let Some(path) = by_visible.remove(&sources) else { continue }; + let r = geom::Region { path, area: 0.0, sources }; + if r.sources.iter().any(|&s| b.paints[s].is_none()) { + let mut items: Vec = r.sources.iter().map(|&s| b.owners[s]).collect(); + items.dedup(); + complex.push(ComplexRegion { path: r.path, items, at: 0, whole: None }); + continue; + } + let c = Composer { sources: &r.sources, paints: &b.paints }; + if nodes.iter().fold(0.0, |a, n| union(a, c.alpha(n))) < 0.5 / 255.0 { + continue; + } + // The page group composites over paper: no ink, opaque. + let paper = Px { c: vec![0.0; list.inks.len()], a: 1.0 }; + let px = nodes.iter().fold(paper, |acc, n| c.draw(n, acc)); + let top = r.sources.last().and_then(|&s| b.paints[s].as_ref()); + let (color, overprint) = match top { + Some(t) if px.c.iter().zip(&t.value).all(|(a, b)| (a - b).abs() < 1e-4) => (t.color.clone(), t.overprint), + _ => (ink_color(&px.c, &list.inks), Overprint::default()), + }; + let tag = r.sources.iter().rev().find_map(|&s| b.tags[s]); + fills.push((tag, FillItem { path: r.path, rule: FillRule::NonZero, paint: Paint::Solid(color), overprint })); + } + // Region fills are disjoint and nothing else of the page is under them, so they can be + // reordered: those showing tagged text go into one plain group per text. + out.report.fills += fills.len(); + let mut tagged: Vec> = vec![Vec::new(); b.texts.len()]; + for (tag, fill) in fills { + match tag.and_then(|t| tagged.get_mut(t)) { + Some(children) => children.push(Item::Fill(fill)), + None => out.items.push(Item::Fill(fill)), + } + } + for (text, children) in b.texts.into_iter().zip(tagged).filter(|(_, c)| !c.is_empty()) { + out.items.push(Item::Group(GroupItem { + opacity: 1.0, + blend: BlendMode::Normal, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: Some(text), + children, + })); + } + let at = out.items.len(); + out.complex.extend(merge_touching(complex).into_iter().map(|c| ComplexRegion { at, ..c })); + for &i in &kept { + out.items.push(without_groups(&list.items[i])); + } + out.report.kept += kept.len(); + out.report.flattened += members.len() - kept.len(); + Ok(()) +} + +/// The members of a group that can be drawn as they are ([`flatten_group`]), ascending: not +/// transparent, and in every region a member's shapes are in, only kept members are above it. +fn kept_members(members: &[usize], transparent: &[bool], owners: &[usize], regions: &[geom::Region]) -> Vec { + let mut regions_of: HashMap> = HashMap::new(); + for (r, region) in regions.iter().enumerate() { + let mut last = None; + for &s in ®ion.sources { + let o = owners[s]; + if last != Some(o) { + regions_of.entry(o).or_default().push(r); + last = Some(o); + } + } + } + // From the top down, so that a member's decision sees every member above it decided. + // `clear[r]`: every member decided so far that is in region `r` is kept. + let mut clear = vec![true; regions.len()]; + let mut kept = Vec::new(); + for &m in members.iter().rev() { + let rs = regions_of.get(&m).map_or(&[][..], Vec::as_slice); + let keep = !transparent[m] && rs.iter().all(|&r| clear[r]); + if keep { + kept.push(m); + } else { + for &r in rs { + clear[r] = false; + } + } + } + kept.reverse(); + kept +} + +/// `path` with its subpaths reversed when its signed area is negative. Faces of one look are +/// written as one path; with one orientation, their shared edges cancel in an anti-aliasing +/// rasterizer's coverage instead of leaving a seam. +fn oriented(path: BezPath) -> BezPath { + if path.area() < 0.0 { path.reverse_subpaths() } else { path } +} + +/// Complex regions whose boxes touch, merged, so that one image spans their shared edges. A +/// merged region lists the items of all its parts: an item paints nothing in a part that +/// doesn't list it, so rendering it there changes nothing. +fn merge_touching(regions: Vec) -> Vec { + if regions.len() < 2 { + return regions; + } + let boxes: Vec = regions.iter().map(|r| r.path.bounding_box().inflate(1e-6, 1e-6)).collect(); + geom::overlap_groups(&boxes) + .into_iter() + .map(|g| { + let (mut path, mut items) = (BezPath::new(), Vec::new()); + for i in g { + path.extend(regions[i].path.iter()); + items.extend_from_slice(®ions[i].items); + } + items.sort_unstable(); + items.dedup(); + ComplexRegion { path, items, at: 0, whole: None } + }) + .collect() +} + +/// Composited ink values as a color: the inks with ink on them, or no ink on the process plates. +fn ink_color(c: &[f32], inks: &InkSet) -> InkColor { + let mut tints: Vec<(InkId, f32)> = inks.iter().zip(c).map(|((id, _), v)| (id, unit(*v))).filter(|(_, v)| *v > 1e-6).collect(); + if tints.is_empty() { + tints = Process::ALL.iter().map(|p| (InkId::process(*p), 0.0)).collect(); + } + InkColor::Tints(tints) +} + +fn bounds(item: &Item) -> Option { + let path = |p: &BezPath| (!p.elements().is_empty()).then(|| p.bounding_box()); + match item { + Item::Fill(f) => path(&f.path), + Item::Text(t) => path(&t.outlines), + Item::Image(im) => Some(im.transform.transform_rect_bbox(Rect::new(0.0, 0.0, 1.0, 1.0))), + Item::Group(g) => { + let inner = g.children.iter().filter_map(bounds).reduce(|a, b| a.union(b))?; + match g.clip.as_ref().and_then(path) { + Some(clip) => Some(inner.intersect(clip)), + None if g.clip.is_some() => None, + None => Some(inner), + } + } + } +} + +/// Whether `item` paints differently composited than painted opaquely in order. +fn is_transparent(item: &Item) -> bool { + match item { + Item::Fill(_) | Item::Text(_) => false, + Item::Image(im) => im.smask.is_some(), + Item::Group(g) => { + g.opacity < 1.0 + || g.blend != BlendMode::Normal + || g.mask.is_some() + || ((g.isolated || g.knockout) && g.children.iter().any(overprints)) + || g.children.iter().any(is_transparent) + } + } +} + +/// A kept item with its isolated and knockout groups made plain groups: without transparency +/// inside ([`is_transparent`]), they paint the same, and a flattened page has no transparency +/// groups. +fn without_groups(item: &Item) -> Item { + match item { + Item::Group(g) => { + Item::Group(GroupItem { isolated: false, knockout: false, children: g.children.iter().map(without_groups).collect(), ..g.clone() }) + } + other => other.clone(), + } +} + +fn overprints(item: &Item) -> bool { + match item { + Item::Fill(f) => f.overprint.fill, + Item::Text(t) => t.overprint.fill, + Item::Image(im) => im.overprint.fill, + Item::Group(g) => g.children.iter().any(overprints), + } +} + +/// A flat paint as the compositor uses it. +#[derive(Debug)] +struct Source { + /// One value per ink, `0.0..=1.0`; unnamed inks 0. + value: Vec, + /// With overprint: which inks the paint names. `None` when it names all of them. + named: Option>, + color: InkColor, + overprint: Overprint, +} + +impl Source { + /// Fills, outlined strokes included, overprint by `overprint.fill`. An overprinting paint + /// names the inks it lists; with nonzero overprint mode a process-only color (DeviceCMYK) + /// leaves out its zero components too. `None` for the `None` colorant, which paints nothing. + fn new(color: &InkColor, overprint: Overprint, inks: &InkSet) -> Result, FlattenError> { + let n = inks.len(); + let (value, named) = match color { + InkColor::None => return Ok(None), + InkColor::All(t) => (vec![unit(*t); n], vec![true; n]), + InkColor::Tints(tints) => { + let (mut value, mut named) = (vec![0.0; n], vec![false; n]); + for &(id, t) in tints { + let (v, k) = value.get_mut(id.index()).zip(named.get_mut(id.index())).ok_or(FlattenError::UnknownInk(id))?; + *v = 1.0 - (1.0 - *v) * (1.0 - unit(t)); + *k = true; + } + if overprint.nonzero && color.as_cmyk().is_some() { + for (k, v) in named.iter_mut().zip(&value).take(4) { + *k &= *v > 0.0; + } + } + (value, named) + } + }; + let named = overprint.fill.then_some(named); + Ok(Some(Self { value, named, color: color.clone(), overprint })) + } +} + +enum Node { + /// A shape and its paint (`Builder::paints`). + Leaf(usize), + Group(GroupNode), +} + +struct GroupNode { + opacity: f32, + blend: BlendMode, + isolated: bool, + knockout: bool, + /// [`is_plain`]. + plain: bool, + /// The shapes of every leaf inside, which come in this range by construction. + shapes: Range, + children: Vec, +} + +impl GroupNode { + /// The children, with those of plain groups ([`is_plain`]) in their place, recursively. + fn elements(self) -> Vec { + let mut out = Vec::with_capacity(self.children.len()); + for c in self.children { + match c { + Node::Group(g) if g.plain => out.extend(g.elements()), + n => out.push(n), + } + } + out + } +} + +/// A group that only clips: it composites nothing of its own. +fn is_plain(item: &Item) -> bool { + matches!(item, Item::Group(g) if g.opacity >= 1.0 && g.blend == BlendMode::Normal && !g.isolated && !g.knockout && g.mask.is_none()) +} + +/// Turns items into the region sweep's shapes and the compositor's tree. +struct Builder<'a> { + inks: &'a InkSet, + shapes: Vec, + clips: Vec, + clip_stack: Vec, + /// Per shape: its flat paint, `None` for art without one (gradients, images, masked art). + paints: Vec>, + /// Per shape: the top-level item it is part of. + owners: Vec, + top: usize, + /// Per shape: the text it shows, an index into `texts`. The outermost source wins: a group's + /// `actual_text`, else a text run's own text. + tags: Vec>, + texts: Vec, + tag: Option, + /// Per shape: it paints its color over whatever is under it, so nothing under it shows. + covers: Vec, + /// Every open group composites at full opacity with Normal blending. + plain_path: bool, + /// Some open group is a knockout group. + knockout_path: bool, +} + +impl<'a> Builder<'a> { + fn new(inks: &'a InkSet) -> Self { + Self { + inks, + shapes: Vec::new(), + clips: Vec::new(), + clip_stack: Vec::new(), + paints: Vec::new(), + owners: Vec::new(), + top: 0, + tags: Vec::new(), + texts: Vec::new(), + tag: None, + covers: Vec::new(), + plain_path: true, + knockout_path: false, + } + } + + fn leaf(&mut self, path: BezPath, rule: FillRule, paint: Option) -> Option { + if path.elements().is_empty() { + return None; + } + self.shapes.push(Shape::new(path, rule).clipped(self.clip_stack.iter().copied())); + // An opaque paint without overprint in groups that composite it unchanged (an isolated + // group too: the paint replaces its backdrop either way). Not in a knockout group: a later + // element there composites over the group's backdrop, so what is under the paint shows. + self.covers.push(self.plain_path && !self.knockout_path && paint.as_ref().is_some_and(|p| p.named.is_none())); + self.paints.push(paint); + self.owners.push(self.top); + self.tags.push(self.tag); + Some(Node::Leaf(self.shapes.len() - 1)) + } + + fn painted(&mut self, path: &BezPath, rule: FillRule, paint: &Paint, overprint: Overprint, masked: bool) -> Result, FlattenError> { + let source = match paint { + Paint::Solid(color) => match Source::new(color, overprint, self.inks)? { + Some(s) => (!masked).then_some(s), + None => return Ok(None), + }, + Paint::Gradient { .. } => None, + }; + Ok(self.leaf(path.clone(), rule, source)) + } + + /// Makes `text` the current tag unless one is set; returns the tag to restore. + fn push_tag(&mut self, text: Option<&str>) -> Option { + let outer = self.tag; + if let (None, Some(text)) = (outer, text) { + self.texts.push(text.to_string()); + self.tag = Some(self.texts.len() - 1); + } + outer + } + + fn node(&mut self, item: &Item, masked: bool) -> Result, FlattenError> { + match item { + Item::Fill(f) => self.painted(&f.path, f.rule, &f.paint, f.overprint, masked), + Item::Text(t) => { + let outer = self.push_tag(t.text.as_deref()); + let node = self.painted(&t.outlines, FillRule::NonZero, &t.paint, t.overprint, masked); + self.tag = outer; + node + } + Item::Image(im) => Ok(self.leaf(im.transform * Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1), FillRule::NonZero, None)), + Item::Group(g) => { + let start = self.shapes.len(); + let outer = self.push_tag(g.actual_text.as_deref()); + if let Some(clip) = &g.clip { + self.clip_stack.push(self.clips.len()); + self.clips.push(ClipPath { path: clip.clone(), rule: FillRule::NonZero }); + } + let masked = masked || g.mask.is_some(); + let (plain_path, knockout_path) = (self.plain_path, self.knockout_path); + self.plain_path &= g.opacity >= 1.0 && g.blend == BlendMode::Normal; + self.knockout_path |= g.knockout; + let mut children = Vec::with_capacity(g.children.len()); + for c in &g.children { + match self.node(c, masked)? { + // A clip (or a plain form) is not an element of a knockout group: the + // elements inside it are, and knock each other out. + Some(Node::Group(inner)) if g.knockout && is_plain(c) => children.extend(inner.elements()), + Some(n) => children.push(n), + None => {} + } + } + if g.clip.is_some() { + self.clip_stack.pop(); + } + (self.plain_path, self.knockout_path) = (plain_path, knockout_path); + self.tag = outer; + Ok((!children.is_empty()).then(|| { + Node::Group(GroupNode { + opacity: unit(g.opacity), + blend: g.blend, + isolated: g.isolated, + knockout: g.knockout, + plain: is_plain(item), + shapes: start..self.shapes.len(), + children, + }) + })) + } + } + } +} + +/// A color and its alpha at one point: one subtractive value per ink (0 = no ink). +#[derive(Clone, Debug)] +struct Px { + c: Vec, + a: f32, +} + +/// Composites the items over one region (ISO 32000-2 §11.3–11.4). Shapes are 0 or 1 across a +/// region, so a knockout group shows its last element that covers it. +struct Composer<'a> { + /// The shapes covering the region, ascending. + sources: &'a [usize], + paints: &'a [Option], +} + +impl Composer<'_> { + fn covers(&self, n: &Node) -> bool { + match n { + Node::Leaf(s) => self.sources.binary_search(s).is_ok(), + Node::Group(g) => { + let i = self.sources.partition_point(|&s| s < g.shapes.start); + self.sources.get(i).is_some_and(|&s| s < g.shapes.end) + } + } + } + + /// `n`'s alpha as an element of its parent group. + fn alpha(&self, n: &Node) -> f32 { + match n { + _ if !self.covers(n) => 0.0, + Node::Leaf(_) => 1.0, + Node::Group(g) => g.opacity * self.group_alpha(g), + } + } + + /// The group's own alpha, without its backdrop (§11.4.8, α_gn). + fn group_alpha(&self, g: &GroupNode) -> f32 { + if g.knockout { + g.children.iter().rev().find(|c| self.covers(c)).map_or(0.0, |c| self.alpha(c)) + } else { + g.children.iter().fold(0.0, |a, c| union(a, self.alpha(c))) + } + } + + /// `n` composited over `acc`. + fn draw(&self, n: &Node, acc: Px) -> Px { + if !self.covers(n) { + return acc; + } + let g = match n { + Node::Leaf(s) => match &self.paints[*s] { + Some(p) => return composite(&acc, &p.value, p.named.as_deref(), 1.0, BlendMode::Normal), + None => return acc, + }, + Node::Group(g) => g, + }; + if g.opacity >= 1.0 && g.blend == BlendMode::Normal && !g.isolated && !g.knockout { + return g.children.iter().fold(acc, |acc, c| self.draw(c, acc)); + } + let init = if g.isolated { Px { c: vec![0.0; acc.c.len()], a: 0.0 } } else { acc.clone() }; + let cn = if g.knockout { + match g.children.iter().rev().find(|c| self.covers(c)) { + Some(c) => self.draw(c, init.clone()), + None => init.clone(), + } + } else { + g.children.iter().fold(init.clone(), |acc, c| self.draw(c, acc)) + }; + let ag = self.group_alpha(g); + if ag <= 0.0 { + return acc; + } + let c = if g.isolated { + cn.c + } else { + // Take the backdrop's share out of a non-isolated group's result (§11.4.8). + let k = init.a / ag - init.a; + cn.c.iter().zip(&init.c).map(|(n, b)| unit(n + (n - b) * k)).collect() + }; + composite(&acc, &c, None, ag * g.opacity, g.blend) + } +} + +/// `s` with alpha `a_s` composited over `b` with blend mode `mode` (§11.3.3). Inks that `named` +/// leaves out keep the backdrop's value (`CompatibleOverprint`, §11.7.4.3). +fn composite(b: &Px, s: &[f32], named: Option<&[bool]>, a_s: f32, mode: BlendMode) -> Px { + let a_r = union(b.a, a_s); + if a_r <= 0.0 { + return b.clone(); + } + let mixed = blend(mode, &b.c, s); + let t = a_s / a_r; + let c = (0..b.c.len()) + .map(|i| { + let (cb, cs) = (b.c[i], s[i]); + if named.is_some_and(|n| !n[i]) { cb } else { unit((1.0 - t) * cb + t * ((1.0 - b.a) * cs + b.a * mixed[i])) } + }) + .collect(); + Px { c, a: a_r } +} + +/// B(c_b, c_s) per ink, on subtractive values: the additive formulas on complemented values +/// (§11.3.5). Non-separable modes blend C, M and Y as RGB, keep the backdrop's K (the source's +/// for Luminosity), and act as Normal on spot inks (§11.3.5.3). +fn blend(mode: BlendMode, b: &[f32], s: &[f32]) -> Vec { + if mode == BlendMode::Normal { + return s.to_vec(); + } + if mode.is_non_separable() { + let mut out = s.to_vec(); + if let ([b0, b1, b2, bk, ..], [s0, s1, s2, sk, ..]) = (b, s) { + let m = non_separable(mode, [1.0 - b0, 1.0 - b1, 1.0 - b2], [1.0 - s0, 1.0 - s1, 1.0 - s2]); + for (o, v) in out.iter_mut().zip(m) { + *o = 1.0 - v; + } + out[3] = if mode == BlendMode::Luminosity { *sk } else { *bk }; + } + return out; + } + let f = separable(mode); + b.iter().zip(s).map(|(b, s)| 1.0 - f(1.0 - unit(*b), 1.0 - unit(*s))).collect() +} + +fn separable(mode: BlendMode) -> fn(f32, f32) -> f32 { + use BlendMode as B; + match mode { + B::Multiply => |b, s| b * s, + B::Screen => screen, + B::Overlay => |b, s| hard_light(s, b), + B::Darken => f32::min, + B::Lighten => f32::max, + B::ColorDodge => |b, s| { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } + }, + B::ColorBurn => |b, s| { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } + }, + B::HardLight => hard_light, + B::SoftLight => |b, s| { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } + }, + B::Difference => |b, s| (b - s).abs(), + B::Exclusion => |b, s| b + s - 2.0 * b * s, + B::Normal | B::Hue | B::Saturation | B::Color | B::Luminosity => |_, s| s, + } +} + +fn non_separable(mode: BlendMode, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + match mode { + BlendMode::Hue => set_lum(set_sat(s, sat(b)), lum(b)), + BlendMode::Saturation => set_lum(set_sat(b, sat(s)), lum(b)), + BlendMode::Color => set_lum(s, lum(b)), + BlendMode::Luminosity => set_lum(b, lum(s)), + _ => s, + } +} + +fn screen(b: f32, s: f32) -> f32 { + b + s - b * s +} + +fn hard_light(b: f32, s: f32) -> f32 { + if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } +} + +fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +/// `c` moved back into gamut keeping its luminance. A gray is clamped instead of scaled by 0/0. +fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut c = c; + if n < 0.0 { + c = if l - n > f32::EPSILON { c.map(|v| l + (v - l) * l / (l - n)) } else { c.map(|v| v.max(0.0)) }; + } + if x > 1.0 { + c = if x - l > f32::EPSILON { c.map(|v| l + (v - l) * (1.0 - l) / (x - l)) } else { c.map(|v| v.min(1.0)) }; + } + c +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + if x <= n { + return [0.0; 3]; + } + c.map(|v| (v - n) * s / (x - n)) +} + +fn union(a: f32, b: f32) -> f32 { + a + b - a * b +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use kurbo::{Affine, Point}; + + use super::*; + use crate::flatten::{GradientGeometry, GroupItem, PlacedGlyph, TextFont, TextItem}; + use crate::ink::{Alternate, Ink, Spot}; + + const C: InkId = InkId::process(Process::Cyan); + const M: InkId = InkId::process(Process::Magenta); + const Y: InkId = InkId::process(Process::Yellow); + const K: InkId = InkId::process(Process::Black); + + fn fill(r: Rect, tints: &[(InkId, f32)], overprint: Overprint) -> Item { + Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(InkColor::Tints(tints.to_vec())), overprint }) + } + + fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item { + fill(r, tints, Overprint::default()) + } + + fn group(opacity: f32, blend: BlendMode, children: Vec) -> GroupItem { + GroupItem { opacity, blend, isolated: false, knockout: false, clip: None, mask: None, actual_text: None, children } + } + + fn list(inks: InkSet, items: Vec) -> DisplayList { + DisplayList { page: Rect::new(0.0, 0.0, 600.0, 800.0), inks, items } + } + + fn flat(list: &DisplayList) -> Flattened { + flatten(list, &FlattenOptions::default()).unwrap() + } + + /// The fill painting `(x, y)` and its tint of each of `inks`. + fn at<'a>(f: &'a Flattened, x: f64, y: f64, inks: &[InkId]) -> (&'a FillItem, Vec) { + let p = Point::new(x, y); + let hit = f.items.iter().rev().find_map(|i| match i { + Item::Fill(fi) if fi.path.winding(p) != 0 => Some(fi), + _ => None, + }); + let fi = hit.unwrap_or_else(|| panic!("nothing paints {p:?}")); + let Paint::Solid(InkColor::Tints(t)) = &fi.paint else { panic!("{:?}", fi.paint) }; + let tint = |id: InkId| t.iter().find(|(i, _)| *i == id).map_or(0.0, |(_, v)| (v * 1000.0).round() / 1000.0); + (fi, inks.iter().map(|&id| tint(id)).collect()) + } + + #[test] + fn half_black_over_cyan_composites_per_ink() { + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])])), + ], + ); + let f = flat(&l); + assert_eq!(at(&f, 75.0, 50.0, &[C, M, Y, K]).1, [0.5, 0.0, 0.0, 0.5]); + assert_eq!(at(&f, 25.0, 50.0, &[C, K]).1, [1.0, 0.0]); + assert_eq!(at(&f, 125.0, 50.0, &[C, K]).1, [0.0, 0.5]); + assert_eq!((f.report.kept, f.report.flattened, f.report.regions, f.report.fills), (0, 2, 3, 3)); + assert!(f.complex.is_empty()); + } + + #[test] + fn multiply_adds_spot_inks_and_non_separable_modes_are_normal_on_spots() { + let mut inks = InkSet::new(); + let spot = |name: &str| Ink::Spot(Spot { name: name.into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None }); + let gold = inks.add(spot("Gold")).unwrap(); + let silver = inks.add(spot("Silver")).unwrap(); + let scene = |blend| { + list( + inks.clone(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(gold, 0.5)]), + Item::Group(group(1.0, blend, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(gold, 0.4), (silver, 0.6)])])), + ], + ) + }; + let f = flat(&scene(BlendMode::Multiply)); + assert_eq!(at(&f, 75.0, 50.0, &[gold, silver, K]).1, [0.7, 0.6, 0.0]); + let f = flat(&scene(BlendMode::Hue)); + assert_eq!(at(&f, 75.0, 50.0, &[gold, silver, K]).1, [0.4, 0.6, 0.0]); + } + + #[test] + fn overprint_keeps_the_inks_a_paint_leaves_out() { + let op = Overprint { fill: true, ..Overprint::default() }; + let opm = Overprint { nonzero: true, ..op }; + let cyan = || solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]); + // A 50% yellow strip over both, so that neither is kept as it is. + let veil = || Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(0.0, 90.0, 150.0, 100.0), &[(Y, 1.0)])])); + let top = |tints: &[(InkId, f32)], o| list(InkSet::new(), vec![cyan(), fill(Rect::new(50.0, 0.0, 150.0, 100.0), tints, o), veil()]); + + let f = flat(&top(&[(K, 1.0)], op)); + let (fi, v) = at(&f, 75.0, 50.0, &[C, M, Y, K]); + assert_eq!(v, [1.0, 0.0, 0.0, 1.0], "K100 overprints cyan"); + assert_eq!(fi.overprint, Overprint::default(), "the composite needs no overprint"); + let (fi, _) = at(&f, 125.0, 50.0, &[K]); + assert_eq!((fi.paint.clone(), fi.overprint), (Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), op), "a paint shown as it is stays"); + assert_eq!(at(&flat(&top(&[(K, 1.0)], Overprint::default())), 75.0, 50.0, &[C, K]).1, [0.0, 1.0], "knocks out"); + + let magenta = [(C, 0.0), (M, 1.0), (Y, 0.0), (K, 0.0)]; + assert_eq!(at(&flat(&top(&magenta, opm)), 75.0, 50.0, &[C, M]).1, [1.0, 1.0], "OPM 1: a zero component keeps the backdrop"); + assert_eq!(at(&flat(&top(&magenta, op)), 75.0, 50.0, &[C, M]).1, [0.0, 1.0], "OPM 0: zero is a value"); + } + + #[test] + fn knockout_group_replaces_what_is_under_the_top_element() { + let scene = |knockout| { + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let g = + GroupItem { knockout, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), half_magenta]) }; + list(InkSet::new(), vec![Item::Group(g)]) + }; + assert_eq!(at(&flat(&scene(true)), 75.0, 50.0, &[C, M]).1, [0.0, 0.5]); + assert_eq!(at(&flat(&scene(false)), 75.0, 50.0, &[C, M]).1, [0.5, 0.5]); + } + + #[test] + fn knockout_group_shows_its_backdrop_under_a_later_element() { + let half_yellow = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(Y, 1.0)])])); + let g = + GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(M, 1.0)]), half_yellow]) }; + let l = list(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), Item::Group(g)]); + assert_eq!(at(&flat(&l), 50.0, 50.0, &[C, M, Y]).1, [0.5, 0.0, 0.5]); + } + + #[test] + fn knockout_reaches_elements_inside_a_clip() { + // A form's bounding box arrives as a clip around the knockout group's elements. + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let clip = GroupItem { + clip: Some(Rect::new(0.0, 0.0, 150.0, 100.0).to_path(0.1)), + ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), half_magenta]) + }; + let g = GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![Item::Group(clip)]) }; + assert_eq!(at(&flat(&list(InkSet::new(), vec![Item::Group(g)])), 75.0, 50.0, &[C, M]).1, [0.0, 0.5]); + } + + #[test] + fn isolated_group_blends_without_the_backdrop() { + let scene = |isolated| { + let multiply = Item::Group(group(1.0, BlendMode::Multiply, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let outer = GroupItem { isolated, ..group(1.0, BlendMode::Normal, vec![multiply]) }; + list(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), Item::Group(outer)]) + }; + assert_eq!(at(&flat(&scene(false)), 75.0, 50.0, &[C, M]).1, [1.0, 1.0]); + assert_eq!(at(&flat(&scene(true)), 75.0, 50.0, &[C, M]).1, [0.0, 1.0]); + } + + #[test] + fn opaque_items_away_from_transparency_and_above_it_stay_as_they_are() { + let font = Arc::new(TextFont { data: Arc::new(vec![0_u8; 8]), index: 0, key: 7 }); + let text = |x: f64| { + Item::Text(TextItem { + font: Some(font.clone()), + size: 12.0, + transform: Affine::translate((x, 50.0)), + glyphs: vec![PlacedGlyph { id: 3, origin: Point::ZERO, text: 0..1 }], + text: Some("A".into()), + outlines: Rect::new(x, 40.0, x + 8.0, 50.0).to_path(0.1), + paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint { fill: true, ..Overprint::default() }, + }) + }; + let far = solid(Rect::new(300.0, 300.0, 400.0, 400.0), &[(M, 1.0)]); + let l = list( + InkSet::new(), + vec![ + far.clone(), + text(500.0), + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])])), + text(60.0), + ], + ); + let f = flat(&l); + assert_eq!(f.items[0], far); + assert_eq!(f.items[1], l.items[1]); + assert_eq!(f.items.last(), Some(&l.items[4]), "text above the transparency, painted last"); + let Some(Item::Text(t)) = f.items.last() else { panic!() }; + assert!(Arc::ptr_eq(t.font.as_ref().unwrap(), &font)); + assert_eq!((f.report.kept, f.report.flattened, f.report.fills), (3, 2, 3)); + } + + #[test] + fn opaque_members_with_no_transparency_above_them_stay_as_they_are() { + // A background frame, body text on it, and a transparent object over the frame only: the + // boxes put all three in one group, but only the frame is under transparency. + let text = solid(Rect::new(10.0, 10.0, 50.0, 20.0), &[(K, 1.0)]); + let veiled = |items: Vec| { + let mut items = items; + items.push(Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(100.0, 100.0, 150.0, 150.0), &[(M, 1.0)])]))); + list(InkSet::new(), items) + }; + let f = flat(&veiled(vec![solid(Rect::new(0.0, 0.0, 200.0, 200.0), &[(C, 0.2)]), text.clone()])); + assert_eq!((f.report.kept, f.report.flattened), (1, 2)); + assert_eq!(f.items.last(), Some(&text), "the text is drawn as it is, over the fills"); + assert_eq!(at(&f, 125.0, 125.0, &[C, M]).1, [0.1, 0.5]); + assert_eq!(at(&f, 5.0, 5.0, &[C, K]).1, [0.2, 0.0]); + // Under the text, the frame's fill goes on. + let fills = Flattened { items: f.items[..f.items.len() - 1].to_vec(), ..f.clone() }; + assert_eq!(at(&fills, 30.0, 15.0, &[C, K]).1, [0.2, 0.0]); + + // Under an opaque rectangle that is itself under the transparency, the text is flattened. + let over = solid(Rect::new(40.0, 0.0, 120.0, 120.0), &[(Y, 1.0)]); + let f = flat(&veiled(vec![text.clone(), over])); + assert_eq!((f.report.kept, f.report.flattened), (0, 3)); + assert_eq!(at(&f, 20.0, 15.0, &[Y, K]).1, [0.0, 1.0]); + assert_eq!(at(&f, 45.0, 15.0, &[Y, K]).1, [1.0, 0.0]); + } + + #[test] + fn art_hidden_under_an_opaque_fill_does_not_cut_it() { + // K bars under a cyan fill, one where a half magenta overlaps it: three looks, three fills. + let half_magenta = Item::Group(group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(M, 1.0)])])); + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(10.0, 10.0, 20.0, 90.0), &[(K, 1.0)]), + solid(Rect::new(60.0, 10.0, 70.0, 90.0), &[(K, 1.0)]), + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + half_magenta, + ], + ); + let f = flat(&l); + assert_eq!(f.report.fills, 3, "{:?}", f.items); + assert_eq!(at(&f, 15.0, 50.0, &[C, K]).1, [1.0, 0.0]); + assert_eq!(at(&f, 65.0, 50.0, &[C, M, K]).1, [0.5, 0.5, 0.0]); + } + + #[test] + fn kept_isolated_and_knockout_groups_come_back_plain() { + // A placed page: an isolated group of opaque art, away from any transparency. + let inner = GroupItem { knockout: true, ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 10.0, 10.0), &[(C, 1.0)])]) }; + let placed = GroupItem { isolated: true, ..group(1.0, BlendMode::Normal, vec![Item::Group(inner)]) }; + let f = flat(&list(InkSet::new(), vec![Item::Group(placed)])); + let Some(Item::Group(g)) = f.items.first() else { panic!("{:?}", f.items) }; + let Some(Item::Group(inner)) = g.children.first() else { panic!("{g:?}") }; + assert!(!g.isolated && !inner.knockout); + } + + #[test] + fn gradients_and_crowded_groups_go_to_complex() { + let gradient = Item::Fill(FillItem { + path: Rect::new(50.0, 0.0, 150.0, 100.0).to_path(0.1), + rule: FillRule::NonZero, + paint: Paint::Gradient { + stops: vec![(0.0, InkColor::Tints(vec![(C, 0.0)])), (1.0, InkColor::Tints(vec![(C, 1.0)]))], + geometry: GradientGeometry::Axial { + start: Point::new(50.0, 0.0), + end: Point::new(150.0, 0.0), + extend: [true; 2], + transform: Affine::IDENTITY, + }, + }, + overprint: Overprint::default(), + }); + let l = list( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(M, 1.0)]), + Item::Group(group(0.5, BlendMode::Normal, vec![gradient])), + solid(Rect::new(140.0, 0.0, 200.0, 100.0), &[(Y, 1.0)]), + ], + ); + let f = flat(&l); + assert_eq!(f.report.complex_regions, 1, "touching regions merge: {:?}", f.complex); + let both = f.complex.iter().find(|c| c.path.winding(Point::new(75.0, 50.0)) != 0).unwrap(); + assert_eq!(both.items, [0, 1]); + assert_eq!(both.at, f.items.len() - 1, "before the yellow kept on top"); + assert_eq!(at(&f, 25.0, 50.0, &[M]).1, [1.0]); + + let f = flatten(&l, &FlattenOptions { max_regions: 2, ..FlattenOptions::default() }).unwrap(); + assert_eq!(f.report.complex_groups, [ComplexGroup { items: vec![0, 1], reason: ComplexReason::TooManyRegions(3) }]); + assert_eq!((f.complex.len(), f.items.len()), (1, 1)); + } + + #[test] + fn flattened_fills_keep_the_actual_text_they_show() { + let tagged = GroupItem { + actual_text: Some("fi".into()), + ..group(1.0, BlendMode::Normal, vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)])]) + }; + let veil = group(0.5, BlendMode::Normal, vec![solid(Rect::new(50.0, 0.0, 150.0, 100.0), &[(K, 1.0)])]); + let f = flat(&list(InkSet::new(), vec![Item::Group(tagged), Item::Group(veil)])); + let groups: Vec<&GroupItem> = f + .items + .iter() + .filter_map(|i| match i { + Item::Group(g) => Some(g), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 1, "{:?}", f.items); + assert_eq!(groups[0].actual_text.as_deref(), Some("fi")); + assert_eq!(groups[0].children.len(), 2, "the cyan alone and under the veil"); + assert_eq!((f.items.len(), f.report.fills), (2, 3)); + assert!(!is_transparent(&f.items[1])); + } +} diff --git a/crates/prepress/src/flatten/document.rs b/crates/prepress/src/flatten/document.rs new file mode 100644 index 00000000..0c0b31f4 --- /dev/null +++ b/crates/prepress/src/flatten/document.rs @@ -0,0 +1,387 @@ +//! [`flatten_document`]: every page of a PDF flattened into one PDF. +//! +//! - Output intent: written once, the first flattened page's as [`flatten_page`] picks it (the +//! page's own `/OutputIntents`, PDF 2.0, else the catalog's), else [`ReadOptions::fallback`]'s +//! profile, else FOGRA39. Each page is read and its complex regions separated with its own +//! intent; krilla writes no page-level `/OutputIntents`, so pages whose intent differs from the +//! one written are listed in [`FlattenDocumentReport::other_intents`]. +//! - Page boxes: the page is the source's visible area (CropBox within MediaBox, rotated), written +//! as the MediaBox. TrimBox, BleedBox and ArtBox carry over, mapped onto it and clipped to it. +//! - Metadata: title, author, creation date and modification date carry over; the producer is +//! the caller's. +//! - Version: [`FlattenDocumentOptions::version`], [`FlattenDocumentOptions::pdfx_version`] +//! and [`FlattenDocumentOptions::pdfx_conformance`] set the header, `pdf:PDFVersion` and the +//! PDF/X version and conformance keys. krilla has no PDF/X validator, so a caller claiming +//! PDF/X runs its own conformance checks. +//! - Fonts: prepared per page ([`FontCache`](super::font::FontCache)); pages share a krilla font +//! only when the font bytes are equal ([`FontMap`]). +//! - The source is opened once, and each output intent and printing condition parsed once. + +use std::cell::Cell; +use std::collections::HashMap; +use std::ops::Range; +use std::rc::Rc; +use std::sync::{Arc, OnceLock}; + +use craft_pdf::interpret::TransformExt as _; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::dict::keys::{ART_BOX, BLEED_BOX, OUTPUT_INTENTS, TRIM_BOX}; +use craft_pdf::syntax::object::{Array, DateTime as PdfDate, Dict, Rect as PdfRect}; +use craft_pdf::syntax::page::Page; +use craft_pdf::write::backend::SerializeSettings; +use craft_pdf::write::backend::configure::PdfVersion; +use craft_pdf::write::backend::geom::{Rect as KRect, Size, Transform}; +use craft_pdf::write::backend::metadata::DateTime; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::backend::pdf::PdfDocument; +use craft_pdf::write::print::{self as print_settings, PrintColor}; +use craft_pdf::write::{self as pdf_write, Metadata, Writer}; +use craft_pdf::{OpenError, open_slice}; +use kurbo::Rect; + +use super::pdf::{ReadColor, ReadError, ReadOptions, read_color, read_page_in}; +use super::raster::{FlattenPageReport, Rasterizer}; +use super::write::{FontMap, WriteError, write_flattened_with_fonts}; +use super::{DisplayList, FlattenError, FlattenOptions, flatten}; +use crate::cms::CmsConversion; +use crate::plates::{self, Fallback, pdf_text}; + +/// How [`flatten_document`] reads, flattens and writes. +#[derive(Clone, Debug, Default)] +pub struct FlattenDocumentOptions { + pub read: ReadOptions, + pub flatten: FlattenOptions, + /// The pages to flatten, from 0; all when `None`. + pub pages: Option>, + /// Written as `/Producer`. + pub producer: String, + pub unreadable: Unreadable, + /// The output's PDF version. Below 1.6, DeviceN spaces get no NChannel attributes; at 1.4, + /// krilla also enforces the 1.4 implementation limits (reals, array and dictionary sizes), + /// and a DeviceN space of more than 8 colorants is a [`WriteError::Colorants`]. + pub version: PdfVersion, + /// The PDF/X version claimed: `/GTS_PDFXVersion` in the information dictionary and + /// `pdfxid:GTS_PDFXVersion` in XMP. For PDF/X-1a:2003 it is `PDF/X-1:2003`. Not checked. + pub pdfx_version: Option, + /// The PDF/X conformance level claimed, `/GTS_PDFXConformance` in the information + /// dictionary: `PDF/X-1a:2003` for PDF/X-1a:2003. Not checked. + pub pdfx_conformance: Option, +} + +/// What [`flatten_document`] does with a page the PDF interpreter fails on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum Unreadable { + /// Fail with [`FlattenDocumentError::Read`]. + #[default] + Fail, + /// Copy the page unflattened, as a form XObject, and list it in + /// [`FlattenDocumentReport::unflattened`]. Its transparency stays. + Keep, +} + +/// What [`flatten_document`] did. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct FlattenDocumentReport { + /// Each flattened page (from 0) and its report. + pub pages: Vec<(usize, FlattenPageReport)>, + /// Pages copied unflattened ([`Unreadable::Keep`]) and why they didn't read. + pub unflattened: Vec<(usize, String)>, + /// Pages read and separated with an output intent other than the one written. + pub other_intents: Vec, + pub totals: FlattenTotals, +} + +/// [`FlattenDocumentReport::pages`] summed. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct FlattenTotals { + /// Top-level items cut into regions. + pub flattened: usize, + pub complex_regions: usize, + /// Groups rasterized whole. + pub complex_groups: usize, + pub raster_images: usize, + pub raster_pixels: u64, + /// The lowest resolution a complex region was rasterized at, when one needed less than + /// [`FlattenOptions::raster_dpi`]. + pub lowest_dpi: Option, + /// Raster images read. + pub images: usize, + pub unreadable_images: usize, + pub dropped_tilings: usize, + pub dropped_inks: usize, + pub dropped_annotations: usize, +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FlattenDocumentError { + #[error(transparent)] + Open(#[from] OpenError), + #[error("pages {start}..{end} are not pages of the document's {count}")] + PageRange { start: usize, end: usize, count: usize }, + #[error("page {page} (from 0): {source}")] + Read { page: usize, source: ReadError }, + #[error("page {page} (from 0): {source}")] + Flatten { page: usize, source: FlattenError }, + #[error("page {page} (from 0): {source}")] + Write { page: usize, source: WriteError }, + /// Serializing the document failed. + #[error(transparent)] + Serialize(#[from] pdf_write::WriteError), +} + +/// `pdf` with the transparency of every page in [`FlattenDocumentOptions::pages`] flattened, as +/// one PDF. See the [module docs](self) for what carries over. +pub fn flatten_document(pdf: &[u8], opts: &FlattenDocumentOptions) -> Result<(Vec, FlattenDocumentReport), FlattenDocumentError> { + flatten_document_with_progress(pdf, opts, &mut |_, _| {}) +} + +/// [`flatten_document`], calling `progress(done, total)` after each page. +pub fn flatten_document_with_progress( + pdf: &[u8], + opts: &FlattenDocumentOptions, + progress: &mut dyn FnMut(usize, usize), +) -> Result<(Vec, FlattenDocumentReport), FlattenDocumentError> { + let doc = Arc::new(open_slice(pdf, None)?); + let count = doc.pages().len(); + let range = opts.pages.clone().unwrap_or(0..count); + if range.is_empty() || range.end > count { + return Err(FlattenDocumentError::PageRange { start: range.start, end: range.end, count }); + } + let mut colors = Colors { doc: &doc, fallback: &opts.read.fallback, cache: HashMap::new() }; + let first = colors.get(range.start)?; + let mut report = FlattenDocumentReport::default(); + // The page being written, to name it when krilla panics. + let current = Cell::new(None); + let written = pdf_write::guard(|| { + let settings = SerializeSettings { + configuration: pdf_write::configuration(opts.version, None)?, + pdfx_version: opts.pdfx_version.clone(), + pdfx_conformance: opts.pdfx_conformance.clone(), + ..first.settings.clone() + }; + let mut writer = Writer::new(settings, metadata(&doc, &opts.producer)); + let mut fonts = FontMap::new(); + let mut source = None; + let total = range.len(); + for (done, page) in range.clone().enumerate() { + current.set(Some(page)); + let color = colors.get(page)?; + if !Rc::ptr_eq(&color, &first) && !same_condition(&color.condition, &first.condition) { + report.other_intents.push(page); + } + let p = doc.pages().get(page).ok_or(FlattenDocumentError::Read { page, source: ReadError::NoPage(page) })?; + match read_page_in(&doc, page, &opts.read, color.read.clone()) { + Ok((list, read)) => { + let flat = flatten(&list, &opts.flatten).map_err(|source| FlattenDocumentError::Flatten { page, source })?; + let mut raster = Rasterizer::new(&list, color.settings.clone(), color.condition.clone(), &color.cms, opts.flatten.raster_dpi); + let at = |source| FlattenDocumentError::Write { page, source }; + let settings = page_settings(p, list.page).ok_or(at(WriteError::Page))?; + write_page(&mut writer, settings, &list, |surface| { + write_flattened_with_fonts(surface, &list, &flat, &color.cms, opts.version, &mut fonts, &mut |r| raster.region(r)) + }) + .map_err(at)?; + report.pages.push((page, FlattenPageReport { read, flatten: flat.report, raster: raster.report().clone() })); + } + Err(e) if opts.unreadable == Unreadable::Keep => { + let (w, h) = p.render_dimensions(); + let bbox = Rect::new(0.0, 0.0, f64::from(w), f64::from(h)); + let at = |source| FlattenDocumentError::Write { page, source }; + let settings = page_settings(p, bbox).ok_or(at(WriteError::Page))?; + let size = Size::from_wh(w, h).ok_or(at(WriteError::Page))?; + let source = source.get_or_insert_with(|| PdfDocument::new(doc.clone())); + let mut page_builder = writer.document_mut().start_page_with(settings); + let mut surface = page_builder.surface(); + surface.draw_pdf_page(source, size, page); + surface.finish(); + page_builder.finish(); + report.unflattened.push((page, e.to_string())); + } + Err(source) => return Err(FlattenDocumentError::Read { page, source }), + } + progress(done + 1, total); + } + current.set(None); + Ok(writer.finish()?) + }); + let bytes = written.map_err(|e| match (e, current.get()) { + (FlattenDocumentError::Serialize(source), Some(page)) => FlattenDocumentError::Write { page, source: WriteError::Pdf(source) }, + (e, _) => e, + })?; + report.totals = totals(&report.pages); + Ok((bytes, report)) +} + +/// Draws one page of `list.page`'s size with `draw`, in page space. +fn write_page( + writer: &mut Writer, + settings: PageSettings, + list: &DisplayList, + draw: impl FnOnce(&mut craft_pdf::write::backend::surface::Surface<'_>) -> Result<(), WriteError>, +) -> Result<(), WriteError> { + let box_ = list.page; + let mut page_builder = writer.document_mut().start_page_with(settings); + let mut surface = page_builder.surface(); + let shifted = box_.x0 != 0.0 || box_.y0 != 0.0; + if shifted { + surface.push_transform(&Transform::from_translate(-box_.x0 as f32, -box_.y0 as f32)); + } + let drawn = draw(&mut surface); + if shifted { + surface.pop(); + } + surface.finish(); + page_builder.finish(); + drawn +} + +/// A page of `bbox`'s size (page space) with the source page's TrimBox, BleedBox and ArtBox, +/// mapped into page space and clipped to `bbox`. +fn page_settings(p: &Page<'_>, bbox: Rect) -> Option { + let size = PageSettings::from_wh(bbox.width() as f32, bbox.height() as f32).filter(|_| bbox.is_finite())?; + let to_page = p.initial_transform(true).to_kurbo(); + let mapped = |key: &[u8]| { + let r = p.raw().get::(key)?; + let r = to_page.transform_rect_bbox(Rect::new(r.x0, r.y0, r.x1, r.y1)).intersect(bbox); + if !(r.is_finite() && r.width() > 0.0 && r.height() > 0.0) { + return None; + } + KRect::from_ltrb((r.x0 - bbox.x0) as f32, (r.y0 - bbox.y0) as f32, (r.x1 - bbox.x0) as f32, (r.y1 - bbox.y0) as f32) + }; + Some(size.with_trim_box(mapped(TRIM_BOX)).with_bleed_box(mapped(BLEED_BOX)).with_art_box(mapped(ART_BOX))) +} + +/// The source's title, author, creation and modification dates, and `producer`. +fn metadata(doc: &Pdf, producer: &str) -> Metadata { + let info = doc.metadata(); + let text = |bytes: &Option>| bytes.as_deref().map(|b| pdf_text(b).trim().to_string()).filter(|s| !s.is_empty()); + Metadata { + producer: producer.to_string(), + title: text(&info.title), + authors: text(&info.author).into_iter().collect(), + created: info.creation_date.as_ref().map(date), + modified: info.modification_date.as_ref().map(date), + ..Metadata::default() + } +} + +fn date(d: &PdfDate) -> DateTime { + DateTime::new(d.year) + .month(d.month) + .day(d.day) + .hour(d.hour) + .minute(d.minute) + .second(d.second) + .utc_offset_hour(d.utc_offset_hour) + .utc_offset_minute(d.utc_offset_minute) +} + +fn totals(pages: &[(usize, FlattenPageReport)]) -> FlattenTotals { + let mut t = FlattenTotals::default(); + for (_, r) in pages { + t.flattened += r.flatten.flattened; + t.complex_regions += r.flatten.complex_regions; + t.complex_groups += r.flatten.complex_groups.len(); + t.raster_images += r.raster.images; + t.raster_pixels += r.raster.pixels; + t.lowest_dpi = match (t.lowest_dpi, r.raster.lowest_dpi) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + t.images += r.read.images; + t.unreadable_images += r.read.unreadable_images; + t.dropped_tilings += r.read.dropped_tilings; + t.dropped_inks += r.read.dropped_inks; + t.dropped_annotations += r.read.dropped_annotations; + } + t +} + +/// A page's printing condition, for reading, separating and writing. +struct PageColor { + /// With the output intent. + settings: SerializeSettings, + cms: CmsConversion, + condition: Fallback, + read: ReadColor, +} + +/// The [`PageColor`]s of a document: one for the pages without their own output intents, one per +/// page with them. +struct Colors<'d> { + doc: &'d Pdf, + fallback: &'d Fallback, + cache: HashMap, Rc>, +} + +impl Colors<'_> { + fn get(&mut self, page: usize) -> Result, FlattenDocumentError> { + let own = + self.doc.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS)).is_some_and(|a| a.iter::>().next().is_some()); + let key = own.then_some(page); + if let Some(color) = self.cache.get(&key) { + return Ok(color.clone()); + } + let read = read_color(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Read { page, source })?; + let (settings, cms, condition) = printing(self.doc, page, self.fallback).map_err(|source| FlattenDocumentError::Write { page, source })?; + let color = Rc::new(PageColor { settings, cms, condition, read }); + self.cache.insert(key, color.clone()); + Ok(color) + } +} + +fn same_condition(a: &Fallback, b: &Fallback) -> bool { + match (a, b) { + (Fallback::Profile(a), Fallback::Profile(b)) => a == b, + _ => std::mem::discriminant(a) == std::mem::discriminant(b), + } +} + +/// The output's serialize settings and printing condition for page `page`: its output intent +/// written again, else `fallback`'s profile, else FOGRA39. +fn printing(doc: &Pdf, page: usize, fallback: &Fallback) -> Result<(SerializeSettings, CmsConversion, Fallback), WriteError> { + let fogra39 = || -> Result<_, WriteError> { + // Reading the profile is the slow part of writing a small page; it is the usual one. + static SETTINGS: OnceLock> = OnceLock::new(); + let settings = SETTINGS.get_or_init(|| print_settings::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).ok()); + let settings = settings.clone().ok_or(WriteError::Pdf(pdf_write::WriteError::InvalidProfile))?; + Ok((settings, CmsConversion::fogra39(), Fallback::Fogra39)) + }; + let custom = |icc: &[u8], color: PrintColor<'_>| { + if icc == craft_cms::builtin::COATED_FOGRA39_ICC { + return fogra39().ok(); + } + let cms = CmsConversion::new(icc).ok()?; + let settings = print_settings::settings(color).ok()?; + Some((settings, cms, Fallback::Profile(icc.into()))) + }; + let found = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plates::output_intent_profile(doc, page))).ok().flatten(); + if let Some(intent) = found { + let color = PrintColor { + profile: &intent.icc, + condition_identifier: intent.identifier.as_deref().unwrap_or("Custom"), + condition: intent.condition.as_deref(), + registry_name: intent.registry_name.as_deref(), + info: intent.info.as_deref().or(intent.condition.as_deref()), + }; + if let Some(found) = custom(&intent.icc, color) { + return Ok(found); + } + } + if let Fallback::Profile(icc) = fallback { + let color = PrintColor { + profile: icc, + condition_identifier: "Custom", + condition: None, + registry_name: None, + info: Some("Custom CMYK printing condition"), + }; + if let Some(found) = custom(icc, color) { + return Ok(found); + } + } + fogra39() +} diff --git a/crates/prepress/src/flatten/font.rs b/crates/prepress/src/flatten/font.rs new file mode 100644 index 00000000..1b9d5a6b --- /dev/null +++ b/crates/prepress/src/flatten/font.rs @@ -0,0 +1,209 @@ +//! Source fonts prepared for embedding again. The PDF-in adapter notes every outline glyph it +//! reads; after the page, [`FontCache::finish`] gives one [`TextFont`] per source font, or +//! `None` where the font's runs are drawn from their outlines. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use craft_pdf::interpret::font::OutlineGlyph; +use craft_pdf::write::backend::geom::Point; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph}; +use craft_pdf::write::backend::{Data, SerializeSettings}; +use craft_pdf::write::{Metadata, WriteError, Writer, guard}; + +use super::{FontBytes, TextFont}; + +/// The fonts of one page, collected glyph by glyph. +#[derive(Default)] +pub struct FontCache { + fonts: HashMap, +} + +struct Noted { + /// The font's own bytes and face index; `None` when the PDF doesn't embed the font or + /// hayro has no bytes for it. + source: Option<(FontBytes, u32)>, + /// Glyph id to advance in glyph space (1000 units per em), from the PDF's widths. Ordered, + /// so the trial embedding is deterministic. + advances: BTreeMap, +} + +impl FontCache { + pub fn new() -> Self { + Self::default() + } + + /// Records a glyph seen on the page (font key, glyph id, PDF width) and returns the key. + pub fn note(&mut self, glyph: &OutlineGlyph) -> u128 { + let key = glyph.font_cache_key(); + let noted = self.fonts.entry(key).or_insert_with(|| Noted { + // A substitute from the font resolver is not the file's font. + source: glyph.font_data().filter(|d| d.embedded).map(|d| (d.data, d.index)), + advances: BTreeMap::new(), + }); + if noted.source.is_some() { + noted.advances.insert(glyph.glyph_id().to_u32(), glyph.advance_width().unwrap_or(0.0)); + } + key + } + + /// After the page is read: sniff, wrap bare CFF, trial-embed. `None` means: draw from + /// outlines. Every noted key is present. + pub fn finish(self) -> HashMap>> { + self.fonts.into_iter().map(|(key, noted)| (key, noted.prepare(key).map(Arc::new))).collect() + } +} + +impl Noted { + fn prepare(self, key: u128) -> Option { + let (data, index) = self.source?; + let magic: [u8; 4] = (*data).as_ref().get(..4)?.try_into().ok()?; + let font = match &magic { + [0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf" => TextFont { data, index, key }, + // The CFF wrap needs the widths of all glyphs used, hence once per page. + [1, _, _, _] => TextFont { data: Arc::new(wrap_cff((*data).as_ref(), &self.advances)?), index: 0, key }, + _ => return None, + }; + embeds(&font, self.advances.keys().copied()).then_some(font) + } +} + +/// Whether krilla embeds `glyphs` of `font`. `Font::new` accepts fonts whose subsetting fails +/// later, in `Writer::finish`, which is too late to fall back for one font; a throwaway +/// one-page document finds those failures here. +fn embeds(font: &TextFont, glyphs: impl Iterator) -> bool { + let glyphs: Vec = glyphs.map(|id| KrillaGlyph::new(GlyphId::new(id), 0.0, 0.0, 0.0, 0.0, 0..0, None)).collect(); + let trial = || { + let font = Font::new(Data::from(font.data.clone()), font.index).ok_or(WriteError::InvalidFont)?; + let mut writer = Writer::new(SerializeSettings::default(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(10.0, 10.0).ok_or(WriteError::InvalidFont)?); + let mut surface = page.surface(); + surface.draw_glyphs(Point::from_xy(0.0, 0.0), &glyphs, font, "", 10.0, false); + surface.finish(); + page.finish(); + writer.finish() + }; + guard(trial).is_ok() +} + +/// Wraps bare CFF (`FontFile3`) in the smallest OpenType font krilla loads: `CFF `, `head`, +/// `hhea`, `hmtx`, `maxp` 0.5 and `post` 3. `hmtx` holds `advances` (glyph space, 1000 units +/// per em) and 0 for the glyphs not listed; `unitsPerEm` follows the CFF `FontMatrix`. +pub(crate) fn wrap_cff(cff: &[u8], advances: &BTreeMap) -> Option> { + let table = ttf_parser::cff::Table::parse(cff)?; + let glyphs = table.number_of_glyphs(); + let upem = (1.0 / table.matrix().sx).round().clamp(16.0, 16384.0) as u16; + let scale = f32::from(upem) / 1000.0; + let be16 = |v: &mut Vec, x: u16| v.extend_from_slice(&x.to_be_bytes()); + let be32 = |v: &mut Vec, x: u32| v.extend_from_slice(&x.to_be_bytes()); + + let mut head = Vec::new(); + be32(&mut head, 0x0001_0000); + be32(&mut head, 0x0001_0000); + be32(&mut head, 0); + be32(&mut head, 0x5F0F_3CF5); + be16(&mut head, 0x000B); + be16(&mut head, upem); + head.extend_from_slice(&[0; 16]); + for v in [0, 0, upem, upem] { + be16(&mut head, v); + } + for v in [0, 8, 2, 0, 0] { + be16(&mut head, v); + } + + let mut hmtx = Vec::new(); + let mut max_advance = 0; + for gid in 0..u32::from(glyphs) { + let advance = advances.get(&gid).map_or(0, |a| (a * scale).round().clamp(0.0, 65535.0) as u16); + max_advance = max_advance.max(advance); + be16(&mut hmtx, advance); + be16(&mut hmtx, 0); + } + + // krilla tells fonts apart by their metrics and `checksumAdjustment`, and two pages' wraps + // of one CFF differ only in `hmtx`: a hash of it keeps them apart in one document. + let hash = hmtx.iter().fold(0x811C_9DC5_u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(0x0100_0193)); + head[8..12].copy_from_slice(&hash.to_be_bytes()); + + let mut hhea = Vec::new(); + be32(&mut hhea, 0x0001_0000); + be16(&mut hhea, (u32::from(upem) * 4 / 5) as u16); + be16(&mut hhea, (-(i32::from(upem) / 5)) as i16 as u16); + be16(&mut hhea, 0); + be16(&mut hhea, max_advance); + hhea.extend_from_slice(&[0; 4]); + be16(&mut hhea, max_advance); + be16(&mut hhea, 1); + hhea.extend_from_slice(&[0; 14]); + be16(&mut hhea, glyphs); + + let mut maxp = Vec::new(); + be32(&mut maxp, 0x0000_5000); + be16(&mut maxp, glyphs); + + let mut post = Vec::new(); + be32(&mut post, 0x0003_0000); + post.extend_from_slice(&[0; 28]); + + // Tables in tag order, as the sfnt directory requires. + let tables: [(&[u8; 4], &[u8]); 6] = [(b"CFF ", cff), (b"head", &head), (b"hhea", &hhea), (b"hmtx", &hmtx), (b"maxp", &maxp), (b"post", &post)]; + let mut out = Vec::new(); + out.extend_from_slice(b"OTTO"); + for v in [6, 64, 2, 32] { + be16(&mut out, v); + } + let mut offset = 12 + 16 * tables.len(); + for (tag, data) in &tables { + out.extend_from_slice(*tag); + be32(&mut out, 0); + be32(&mut out, u32::try_from(offset).ok()?); + be32(&mut out, u32::try_from(data.len()).ok()?); + offset += data.len().next_multiple_of(4); + } + for (_, data) in &tables { + out.extend_from_slice(data); + out.resize(out.len().next_multiple_of(4), 0); + } + Some(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use craft_pdf::interpret::font::StandardFont; + + #[test] + fn bare_cff_is_wrapped_in_an_opentype_font_with_the_pdf_widths() { + // hayro's Foxit Sans substitute is bare CFF, as `FontFile3/Type1C` holds it. + let (data, _) = StandardFont::Helvetica.get_font_data(); + let cff = (*data).as_ref(); + assert_eq!(cff[0], 1); + assert!(Font::new(Data::from(cff.to_vec()), 0).is_none(), "krilla takes OpenType only"); + + let advances = BTreeMap::from([(1, 278.0), (36, 667.4)]); + let wrapped = wrap_cff(cff, &advances).unwrap(); + let face = ttf_parser::Face::parse(&wrapped, 0).unwrap(); + assert_eq!(face.units_per_em(), 1000); + assert_eq!(face.number_of_glyphs(), ttf_parser::cff::Table::parse(cff).unwrap().number_of_glyphs()); + let advance = |id| face.glyph_hor_advance(ttf_parser::GlyphId(id)); + assert_eq!((advance(1), advance(36), advance(2)), (Some(278), Some(667), Some(0))); + + let font = TextFont { data: Arc::new(wrapped.clone()), index: 0, key: 1 }; + assert!(embeds(&font, advances.keys().copied())); + + // Another page's glyphs with the same width sum: krilla must not take it for the same font. + let other = wrap_cff(cff, &BTreeMap::from([(1, 278.0), (37, 667.4)])).unwrap(); + assert_ne!(Font::new(Data::from(wrapped), 0), Font::new(Data::from(other), 0)); + } + + #[test] + fn unreadable_bytes_draw_from_outlines() { + let noted = Noted { source: Some((Arc::new(b"%!PS-AdobeFont-1.0".to_vec()), 0)), advances: BTreeMap::from([(1, 500.0)]) }; + assert!(noted.prepare(7).is_none()); + let noted = Noted { source: Some((Arc::new(vec![1, 0, 4, 1, 0, 0]), 0)), advances: BTreeMap::new() }; + assert!(noted.prepare(7).is_none(), "broken CFF"); + } +} diff --git a/crates/prepress/src/flatten/geom.rs b/crates/prepress/src/flatten/geom.rs new file mode 100644 index 00000000..f84bf9ca --- /dev/null +++ b/crates/prepress/src/flatten/geom.rs @@ -0,0 +1,1016 @@ +//! Planar regions for the transparency flattener: a page's filled shapes cut into the faces of +//! their arrangement, each tagged with the shapes that cover it. The same shapes cover every +//! point of a region, so one composited color is exact for all of it. +//! +//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: +//! `crates/pathops/src/pathfinder.rs` (`regions`, `Region`, `Shape`), +//! `crates/pathops/src/boolean.rs` (the arrangement, per-input winding numbers, horizontal +//! snapping, contour repair and cleanup) and `overlapping` in +//! `crates/engine/src/cmd/flatten.rs`. The sweep is `linesweeper`'s, on `kurbo` curves. +//! +//! Clips go into the same sweep as the shapes instead of being applied first: one sweep replaces +//! a boolean per clipped shape, and a clip edge is then the same edge in every region it bounds. + +use std::collections::HashMap; + +use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Rect, Shape as _, Vec2}; +use linesweeper::topology::{ContourIdx, Contours, Topology, WindingNumber}; + +/// Contours narrower than this on average (2 × area / perimeter, in input units) are dropped as +/// numerical debris, such as the lens between two nearly coincident curves. +pub const DEFAULT_SLIVER: f64 = 0.01; + +/// How a path's winding number decides what is inside it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum FillRule { + #[default] + NonZero, + EvenOdd, +} + +impl FillRule { + /// Whether a point of winding number `winding` is inside. + pub fn inside(self, winding: i32) -> bool { + match self { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + } + } +} + +/// A filled shape. Open subpaths are closed, as a fill closes them. +#[derive(Clone, Debug, PartialEq)] +pub struct Shape { + pub path: BezPath, + pub rule: FillRule, + /// Indices into the clip list: the shape covers only what all of them cover. + pub clips: Vec, +} + +impl Shape { + pub fn new(path: BezPath, rule: FillRule) -> Self { + Self { path, rule, clips: Vec::new() } + } + + pub fn clipped(mut self, clips: impl IntoIterator) -> Self { + self.clips.extend(clips); + self + } +} + +/// A clipping path. +#[derive(Clone, Debug, PartialEq)] +pub struct ClipPath { + pub path: BezPath, + pub rule: FillRule, +} + +/// One face of the arrangement: an area where the covering shapes don't change. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + /// Outer contours and holes, oriented so that the nonzero rule fills the region. + pub path: BezPath, + /// Unsigned area. + pub area: f64, + /// Indices of the shapes covering the region, in input (paint) order. + pub sources: Vec, +} + +impl Region { + /// The front-most shape covering the region. + pub fn top(&self) -> Option { + self.sources.last().copied() + } + + pub fn contains(&self, p: Point) -> bool { + self.path.winding(p) != 0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum GeomError { + #[error("input contains NaN or infinite coordinates")] + NonFinite, + #[error("shape {shape} names clip {clip}, which does not exist")] + UnknownClip { shape: usize, clip: usize }, + /// The sweep failed on near-degenerate input; the caller rasterizes instead. + #[error("the shapes are too degenerate to sweep")] + Degenerate, +} + +/// The regions of `shapes` (back to front), each clipped by the `clips` it names. They tile the +/// union of the clipped shapes without overlaps; regions narrower than [`DEFAULT_SLIVER`] are +/// left out. +pub fn regions(shapes: &[Shape], clips: &[ClipPath]) -> Result, GeomError> { + regions_with(shapes, clips, DEFAULT_SLIVER) +} + +/// [`regions`] with contours narrower than `sliver` dropped. +pub fn regions_with(shapes: &[Shape], clips: &[ClipPath], sliver: f64) -> Result, GeomError> { + if shapes.is_empty() { + return Ok(Vec::new()); + } + Ok(Arrangement::new(shapes, clips, sliver)?.regions()) +} + +/// Indices of `bounds` grouped by overlap, transitively, each group in index order. Touching boxes +/// overlap; NaN boxes overlap nothing. +pub fn overlap_groups(bounds: &[Rect]) -> Vec> { + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + let mut parent: Vec = (0..bounds.len()).collect(); + let mut order: Vec<(usize, Rect)> = bounds.iter().enumerate().filter(|(_, b)| !b.is_nan()).map(|(i, b)| (i, b.abs())).collect(); + order.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + // Sweep left to right, comparing each box with those still open at its left edge. + let mut open: Vec<(usize, Rect)> = Vec::new(); + for (i, b) in order { + open.retain(|(_, o)| o.x1 >= b.x0); + for (j, o) in &open { + if o.y0 <= b.y1 && b.y0 <= o.y1 { + let (a, c) = (find(&mut parent, i), find(&mut parent, *j)); + parent[a.max(c)] = a.min(c); + } + } + open.push((i, b)); + } + let mut groups: Vec> = Vec::new(); + let mut slot: Vec> = vec![None; bounds.len()]; + for i in 0..bounds.len() { + let r = find(&mut parent, i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups +} + +/// The [`overlap_groups`] that hold a transparent item. Only their items need [`regions`]; every +/// other item paints as it is. Groups are transitive so that an opaque item keeps its place in +/// paint order above or below everything it overlaps. +pub fn transparent_groups(bounds: &[Rect], transparent: &[bool]) -> Vec> { + let mut groups = overlap_groups(bounds); + groups.retain(|g| g.iter().any(|&i| transparent.get(i).copied().unwrap_or(false))); + groups +} + +/// Winding numbers for any number of inputs: the non-zero ones as (tag, winding), sorted by tag. +/// A face is covered by a few inputs, so this stays short where a vector of every input's winding +/// would cost one step per input. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct Multi(Vec<(usize, i32)>); + +impl Multi { + fn get(&self, tag: usize) -> i32 { + self.0.binary_search_by_key(&tag, |e| e.0).ok().and_then(|k| self.0.get(k)).map_or(0, |e| e.1) + } +} + +impl std::ops::Add for Multi { + type Output = Multi; + fn add(mut self, rhs: Self) -> Multi { + self += rhs; + self + } +} + +impl std::ops::AddAssign for Multi { + fn add_assign(&mut self, rhs: Self) { + // The sweep mostly adds one input's ±1 to a short list: add in place, insert in tag order, + // drop a winding that reaches zero. + for (t, k) in rhs.0 { + match self.0.binary_search_by_key(&t, |e| e.0) { + Ok(i) => { + if let Some(e) = self.0.get_mut(i) { + e.1 += k; + if e.1 == 0 { + self.0.remove(i); + } + } + } + Err(i) if k != 0 => self.0.insert(i, (t, k)), + Err(_) => {} + } + } + } +} + +impl WindingNumber for Multi { + type Tag = usize; + fn single(tag: usize, positive: bool) -> Self { + Multi(vec![(tag, if positive { 1 } else { -1 })]) + } + fn of_tag(&self, tag: usize) -> Self { + let w = self.get(tag); + Multi(if w == 0 { vec![] } else { vec![(tag, w)] }) + } +} + +/// The sweep of shapes and clips. Tags `0..n` are shapes, `n + c` is clip `c`. +struct Arrangement { + top: Topology, + rules: Vec, + n: usize, + /// Each shape's clip tags. + clips: Vec>, + tidy: Tidy, +} + +impl Arrangement { + fn new(shapes: &[Shape], clips: &[ClipPath], sliver: f64) -> Result { + let n = shapes.len(); + let mut used = vec![false; clips.len()]; + let mut shape_clips = Vec::with_capacity(n); + for (shape, s) in shapes.iter().enumerate() { + let mut tags = Vec::with_capacity(s.clips.len()); + for &clip in &s.clips { + *used.get_mut(clip).ok_or(GeomError::UnknownClip { shape, clip })? = true; + tags.push(n + clip); + } + tags.sort_unstable(); + tags.dedup(); + shape_clips.push(tags); + } + let mut paths: Vec<(BezPath, usize)> = shapes + .iter() + .enumerate() + .map(|(i, s)| (closed(&s.path), i)) + .chain(clips.iter().enumerate().filter(|(c, _)| used[*c]).map(|(c, p)| (closed(&p.path), n + c))) + .collect(); + let eps = eps_for(paths.iter().map(|p| &p.0))?; + snap_horizontals(&mut paths.iter_mut().map(|p| &mut p.0).collect::>(), eps); + // The sweep can panic on rare near-degenerate input; the flattener then rasterizes. + let sweep = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::::from_paths(paths.iter().map(|(p, t)| (p, *t)), eps))); + // Every subpath is closed, so a path error is a degenerate input too. + let top = sweep.map_err(|_| GeomError::Degenerate)?.map_err(|_| GeomError::Degenerate)?; + let rules = shapes.iter().map(|s| s.rule).chain(clips.iter().map(|c| c.rule)).collect(); + let tidy = Tidy::keeping(sliver, paths.iter().map(|p| &p.0)); + Ok(Self { top, rules, n, clips: shape_clips, tidy }) + } + + /// The shapes covering a face of winding `w`, inside all their clips. + fn covering(&self, w: &Multi) -> Vec { + let inside: Vec = w.0.iter().filter(|&&(t, k)| self.rules.get(t).is_some_and(|r| r.inside(k))).map(|&(t, _)| t).collect(); + inside.iter().copied().take_while(|&t| t < self.n).filter(|&s| self.clips[s].iter().all(|c| inside.binary_search(c).is_ok())).collect() + } + + /// Every face, with the faces of one coverage set merged where they touch. + /// + /// A contour pass walks the whole arrangement, so one pass per coverage set would cost + /// sets × edges. Instead the sets go into batches whose faces share no vertex (so no edge + /// either): one pass per batch walks each face's contours as its own set's pass would, and the + /// set a contour bounds is the one of its batch at its first point. + fn regions(&self) -> Vec { + let top = &self.top; + // The distinct non-empty coverage sets, numbered in the order met, and the set of each + // side of each segment. Faces have many sides, so a set is worked out once per winding. + let mut ids: HashMap, usize> = HashMap::new(); + let mut sets: Vec> = Vec::new(); + let mut by_winding: HashMap<&Multi, Option> = HashMap::new(); + // The contour passes hand their predicate references into the topology's windings, so + // the winding's address finds its set without hashing the winding. + let mut by_addr: HashMap> = HashMap::new(); + let mut sides = Vec::new(); + for s in top.segment_indices() { + let h = s.first_half(); + let mut pair = [None; 2]; + for (side, w) in pair.iter_mut().zip([top.winding_clockwise(h), top.winding_counter_clockwise(h)]) { + let m = *by_winding.entry(w).or_insert_with(|| { + let set = self.covering(w); + let next = sets.len(); + (!set.is_empty()).then(|| { + *ids.entry(set.clone()).or_insert_with(|| { + sets.push(set); + next + }) + }) + }); + by_addr.insert(std::ptr::from_ref(w).addr(), m); + *side = m; + } + sides.push((s, pair)); + } + let id = |w: &Multi| match by_addr.get(&std::ptr::from_ref(w).addr()) { + Some(m) => *m, + None => ids.get(&self.covering(w)).copied(), + }; + // The sets of the faces around each vertex. + let mut around: HashMap<(u64, u64), Vec> = HashMap::new(); + for (s, pair) in sides { + for end in [s.first_half(), s.second_half()] { + let at = around.entry(vertex_key(top.point(end).to_kurbo())).or_default(); + for m in pair.into_iter().flatten() { + if !at.contains(&m) { + at.push(m); + } + } + } + } + let mut near = vec![Vec::new(); sets.len()]; + for at in around.values() { + for &a in at { + near[a].extend(at.iter().copied().filter(|&b| b != a)); + } + } + // Greedy coloring: each set takes the first batch none of its neighbors is in. + let mut batch = vec![usize::MAX; sets.len()]; + let mut batches = 0; + for m in 0..sets.len() { + let mut taken = vec![false; batches]; + for &n in &near[m] { + if let Some(t) = taken.get_mut(batch[n]) { + *t = true; + } + } + batch[m] = taken.iter().position(|t| !t).unwrap_or(batches); + batches = batches.max(batch[m] + 1); + } + let mut faces: Vec> = vec![Vec::new(); sets.len()]; + for b in 0..batches { + let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b)); + let owner: Option> = c + .contours() + .map(|ct| { + let Some(PathEl::MoveTo(p)) = ct.path.elements().first() else { return None }; + around.get(&vertex_key(*p))?.iter().copied().find(|&m| batch[m] == b) + }) + .collect(); + let Some(owner) = owner else { + // Not expected: every contour starts at a vertex of the arrangement. Fall back to + // a pass per set for this batch. + for m in (0..sets.len()).filter(|&m| batch[m] == b) { + let c = top.contours(|w| id(w) == Some(m)); + faces[m].extend(c.grouped().into_iter().map(|g| self.outline(&c, g))); + } + continue; + }; + // A contour belongs under the nearest enclosing contour of the same set, as in that + // set's own pass. + let mut children = vec![Vec::new(); owner.len()]; + let mut roots = Vec::new(); + for (i, &m) in owner.iter().enumerate() { + let mut up = c[ContourIdx(i)].parent; + while let Some(p) = up.filter(|p| owner.get(p.0) != Some(&m)) { + up = c[p].parent; + } + match up { + Some(p) => children[p.0].push(i), + None => roots.push(i), + } + } + for r in roots { + let mut group = Vec::new(); + let mut stack = vec![r]; + while let Some(i) = stack.pop() { + group.push(ContourIdx(i)); + stack.extend(children[i].iter().rev()); + } + faces[owner[r]].push(self.outline(&c, group)); + } + } + let mut out = Vec::new(); + for (sources, paths) in sets.into_iter().zip(faces) { + for path in paths.into_iter().filter(|p| !p.elements().is_empty()) { + let area = path.area().abs(); + out.push(Region { path, area, sources: sources.clone() }); + } + } + out + } + + /// The contours `idx` of `c` as one path, repaired, without slivers, and with the pieces the + /// sweep split from one input curve or line joined again. + fn outline(&self, c: &Contours, idx: impl IntoIterator) -> BezPath { + let mut out = BezPath::new(); + for i in idx { + let path = self.tidy.repair(&c[i].path); + if is_sliver(&path, self.tidy.sliver) { + continue; + } + let segs = tidy_segments(path.segments().map(to_seg).collect(), &self.tidy); + let Some(first) = segs.first() else { continue }; + out.move_to(first.c.p0); + for s in &segs { + if s.line { + out.line_to(s.c.p3); + } else { + out.curve_to(s.c.p1, s.c.p2, s.c.p3); + } + } + out.close_path(); + } + out + } +} + +/// A point as a map key (the arrangement's vertices are exact). +fn vertex_key(p: Point) -> (u64, u64) { + (p.x.to_bits(), p.y.to_bits()) +} + +/// `bp` with every subpath closed. +fn closed(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut open, mut start) = (false, Point::ZERO); + for el in bp.iter() { + match el { + PathEl::MoveTo(p) => { + if open { + out.close_path(); + } + out.move_to(p); + (open, start) = (true, p); + } + PathEl::ClosePath => { + if open { + out.close_path(); + } + open = false; + } + _ => { + // Drawing on after a close starts again at the subpath's start. + if !open { + out.move_to(start); + open = true; + } + out.push(el); + } + } + } + if open { + out.close_path(); + } + out +} + +/// The sweep tolerance for a set of paths (linesweeper's own `binary_op` scaling). +fn eps_for<'a>(paths: impl IntoIterator) -> Result { + let mut m: f64 = 0.0; + for p in paths { + for el in p.elements() { + for pt in el_points(el) { + if !pt.x.is_finite() || !pt.y.is_finite() { + return Err(GeomError::NonFinite); + } + m = m.max(pt.x.abs()).max(pt.y.abs()); + } + } + } + Ok((m * f64::EPSILON * 64.0).max(1e-6)) +} + +fn el_points(el: &PathEl) -> impl Iterator { + let v: [Option; 3] = match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => [Some(p), None, None], + PathEl::QuadTo(a, b) => [Some(a), Some(b), None], + PathEl::CurveTo(a, b, c) => [Some(a), Some(b), Some(c)], + PathEl::ClosePath => [None, None, None], + }; + v.into_iter().flatten() +} + +/// Make edges that rise by less than the sweep tolerance `eps` exactly horizontal. +/// +/// The sweep orders a curve against another segment over a y-span shorter than `eps` by bounding +/// boxes alone, so an almost-horizontal edge can be mis-ordered against a curve it passes and a +/// corner is lost; exact horizontals are handled exactly. So on-curve y coordinates within `eps` +/// of each other snap to one value (the lowest of each cluster, keeping distinct values at least +/// `eps` apart), handles move with their anchors, and a handle within `eps` of its anchor's +/// height is leveled with it. Anchors move by less than `eps` and handles by less than `2·eps`. +fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) { + let mut ys: Vec = paths.iter().flat_map(|p| p.elements().iter().filter_map(PathEl::end_point)).map(|p| p.y).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut start = f64::NEG_INFINITY; + let levels: Vec = ys + .iter() + .map(|&y| { + if y - start >= eps { + start = y; + } + start + }) + .collect(); + let snap = |p: &mut Point| { + let y = levels.get(ys.partition_point(|&v| v < p.y)).copied().unwrap_or(p.y); + let moved = y - p.y; + p.y = y; + moved + }; + // Shift a handle with its anchor, then level it with the anchor when within `eps`. + let follow = |h: &mut Point, anchor: Point, moved: f64| { + h.y += moved; + if (h.y - anchor.y).abs() < eps { + h.y = anchor.y; + } + }; + for p in paths.iter_mut() { + // The current point (snapped), how far snapping moved it, and the subpath's start. + let (mut cur, mut moved, mut first) = (Point::ZERO, 0.0, Point::ZERO); + for el in p.elements_mut() { + match el { + PathEl::MoveTo(q) => { + moved = snap(q); + (cur, first) = (*q, *q); + } + PathEl::LineTo(q) => { + moved = snap(q); + cur = *q; + } + PathEl::QuadTo(c, q) => { + let m = snap(q); + follow(c, cur, moved); + follow(c, *q, 0.0); + (cur, moved) = (*q, m); + } + PathEl::CurveTo(c1, c2, q) => { + let m = snap(q); + follow(c1, cur, moved); + follow(c2, *q, m); + (cur, moved) = (*q, m); + } + PathEl::ClosePath => { + (cur, moved) = (first, 0.0); + } + } + } + } +} + +/// Contours whose mean width (2 × area / perimeter) is below `sliver` are debris. +fn is_sliver(bp: &BezPath, sliver: f64) -> bool { + let a = bp.area().abs(); + let per = bp.perimeter(1e-6); + per <= 0.0 || 2.0 * a / per < sliver +} + +/// One segment of a contour: a cubic, and whether it is a straight line. +#[derive(Clone, Copy, Debug)] +struct Seg { + c: CubicBez, + line: bool, +} + +impl Seg { + fn line(a: Point, b: Point) -> Self { + Self { c: CubicBez::new(a, a, b, b), line: true } + } +} + +fn to_seg(s: PathSeg) -> Seg { + match s { + PathSeg::Line(l) => Seg::line(l.p0, l.p1), + PathSeg::Quad(q) => { + let c = q.raise(); + Seg { c, line: is_straight(&c, 1e-9) } + } + PathSeg::Cubic(c) => Seg { c, line: is_straight(&c, 1e-9 * (1.0 + c.p0.distance(c.p3))) }, + } +} + +/// Whether the cubic is (within `tol`) a straight line with its handles inside the chord. +fn is_straight(c: &CubicBez, tol: f64) -> bool { + let chord = c.p3 - c.p0; + let len = chord.hypot(); + if len < 1e-12 { + return c.p1.distance(c.p0) <= tol && c.p2.distance(c.p0) <= tol; + } + let dir = chord / len; + [c.p1, c.p2].iter().all(|&h| { + let v = h - c.p0; + let along = v.dot(dir); + v.cross(dir).abs() <= tol && along >= -tol && along <= len + tol + }) +} + +fn unit(v: Vec2) -> Option { + let l = v.hypot(); + (l > 1e-12 && l.is_finite()).then(|| v / l) +} + +/// The direction the curve leaves `p0`. +fn start_tangent(c: &CubicBez) -> Vec2 { + unit(c.p1 - c.p0).or_else(|| unit(c.p2 - c.p0)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// The direction the curve arrives at `p3`. +fn end_tangent(c: &CubicBez) -> Vec2 { + unit(c.p3 - c.p2).or_else(|| unit(c.p3 - c.p1)).or_else(|| unit(c.p3 - c.p0)).unwrap_or(Vec2::new(1.0, 0.0)) +} + +/// The distance two points must be within to count as one, at `p`'s magnitude. +fn tol_at(p: Point) -> f64 { + 1e-6 * (1.0 + p.x.abs().max(p.y.abs())) +} + +/// Output cleanup: the inputs' on-curve points (joints there stay), and their curves, to rebuild +/// split pieces exactly. +struct Tidy { + sliver: f64, + /// Sorted by x. + keep: Vec, + /// Input curves with slightly inflated bounds. + curves: Vec<(Rect, CubicBez)>, + /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose + /// bounds can hold a point, without testing them all. + by_x: Vec, + max_width: f64, +} + +impl Tidy { + fn keeping<'a>(sliver: f64, paths: impl IntoIterator) -> Self { + let mut keep = Vec::new(); + let mut curves = Vec::new(); + for p in paths { + for s in p.segments() { + let seg = to_seg(s); + keep.push(seg.c.p0); + if !seg.line { + curves.push((seg.c.bounding_box().inflate(1e-6, 1e-6), seg.c)); + } + } + } + keep.sort_by(|a, b| a.x.total_cmp(&b.x)); + let mut by_x: Vec = (0..curves.len()).collect(); + by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); + let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); + Self { sliver, keep, curves, by_x, max_width } + } + + fn is_kept(&self, p: Point) -> bool { + let tol = tol_at(p); + let lo = self.keep.partition_point(|q| q.x < p.x - tol); + self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + } + + /// The input curves whose bounds hold both `a` and `b`. + fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let x = a.x.min(b.x); + let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); + let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); + self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + } + + /// `bp` with each curve piece that strays from the one input curve through both its ends + /// rebuilt as that curve's exact sub-curve. linesweeper 0.5 can emit a piece that doesn't + /// follow its curve; out and back between two points of one curve, such pieces make a lens + /// instead of nothing (VectorCraft issue #1028). A piece on a curve, or whose ends two + /// different input curves pass through (a real lens between them), is left as it is. + fn repair(&self, bp: &BezPath) -> BezPath { + if self.curves.is_empty() { + return bp.clone(); + } + let mut out = BezPath::new(); + let (mut cur, mut start) = (Point::ZERO, Point::ZERO); + for el in bp.iter() { + let piece = match el { + PathEl::QuadTo(c, p) => Some(kurbo::QuadBez::new(cur, c, p).raise()), + PathEl::CurveTo(c1, c2, p) => Some(CubicBez::new(cur, c1, c2, p)), + _ => None, + }; + match (el, piece) { + (_, Some(raw)) => { + let (a, b) = (raw.p0, raw.p3); + let tol = tol_at(Point::new(a.x.abs().max(b.x.abs()), a.y.abs().max(b.y.abs()))); + let mut subs: Vec = Vec::new(); + let mut follows = false; + for c in self.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if [0.25, 0.5, 0.75].iter().all(|&t| c.nearest(raw.eval(t), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + follows = true; + break; + } + subs.push(sub_curve(c, na.t, nb.t, a, b)); + } + let same = |p: &CubicBez, q: &CubicBez| p.p1.distance(q.p1) <= tol * 10.0 && p.p2.distance(q.p2) <= tol * 10.0; + match subs.split_first() { + Some((sub, rest)) if !follows && rest.iter().all(|q| same(sub, q)) => out.curve_to(sub.p1, sub.p2, b), + _ => out.push(el), + } + cur = b; + } + (PathEl::MoveTo(p), None) => { + out.push(el); + (cur, start) = (p, p); + } + (PathEl::LineTo(p), None) => { + out.push(el); + cur = p; + } + (PathEl::ClosePath, None) => { + out.push(el); + cur = start; + } + (_, None) => out.push(el), + } + } + out + } +} + +/// The part of `c` from parameter `ta` to `tb` (either order), with its ends at `a` and `b`. +fn sub_curve(c: &CubicBez, ta: f64, tb: f64, a: Point, b: Point) -> CubicBez { + let sub = if ta < tb { + c.subsegment(ta..tb) + } else { + let r = c.subsegment(tb..ta); + CubicBez::new(r.p3, r.p2, r.p1, r.p0) + }; + CubicBez::new(a, sub.p1, sub.p2, b) +} + +/// The closed contour `segs` with the pieces the sweep split from one input line or curve joined +/// again, and degenerate pieces dropped. Joints at input points stay. Only exact joins are made, +/// so neighboring regions keep the same edges. +fn tidy_segments(segs: Vec, tidy: &Tidy) -> Vec { + let scale = segs.iter().map(|s| s.c.p0.distance(s.c.p3)).fold(0.0, f64::max); + let tiny = (scale * 1e-9).max(1e-9); + let mut segs: Vec = segs + .into_iter() + .filter(|s| { + let r = Rect::from_points(s.c.p0, s.c.p3).union_pt(s.c.p1).union_pt(s.c.p2); + r.width() + r.height() > tiny + }) + .collect(); + let n = segs.len(); + if n < 2 { + return segs; + } + // Joint i sits at the start of segment i. + let mergeable = |a: &Seg, b: &Seg| -> bool { + if a.line != b.line || tidy.is_kept(b.c.p0) { + return false; + } + let ta = end_tangent(&a.c); + let tb = start_tangent(&b.c); + ta.dot(tb) > 0.9998 && ta.cross(tb).abs() < 0.02 + }; + // Start at a real corner, if there is one. + if let Some(k) = (0..n).find(|&i| !mergeable(&segs[(i + n - 1) % n], &segs[i])) { + segs.rotate_left(k); + } + let mut out: Vec = Vec::with_capacity(n); + let mut i = 0; + while i < n { + let mut cur = segs[i]; + let mut j = i + 1; + while j < n && mergeable(&segs[j - 1], &segs[j]) { + match join(&segs[i..=j], tidy) { + Some(m) => { + cur = m; + j += 1; + } + None => break, + } + } + out.push(cur); + i = j; + } + out +} + +/// `run` as one segment, when its pieces lie on one line or one input curve. +fn join(run: &[Seg], tidy: &Tidy) -> Option { + let (first, last) = (run.first()?.c, run.last()?.c); + let (a, b) = (first.p0, last.p3); + let tol = tol_at(Point::new(a.x.abs().max(b.x.abs()), a.y.abs().max(b.y.abs()))); + if run[0].line { + let chord = b - a; + let len = chord.hypot(); + if len < 1e-12 { + return None; + } + let ok = run.iter().all(|s| ((s.c.p3 - a).cross(chord) / len).abs() <= tol); + return ok.then(|| Seg::line(a, b)); + } + for c in tidy.curves_around(a, b) { + let (na, nb) = (c.nearest(a, 1e-12), c.nearest(b, 1e-12)); + if na.distance_sq.sqrt() > tol || nb.distance_sq.sqrt() > tol || (na.t - nb.t).abs() < 1e-12 { + continue; + } + if run.iter().all(|s| c.nearest(s.c.eval(0.5), 1e-12).distance_sq.sqrt() <= tol * 10.0) { + return Some(Seg { c: sub_curve(c, na.t, nb.t, a, b), line: false }); + } + } + None +} + +#[cfg(test)] +mod tests { + use kurbo::Circle; + + use super::*; + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + Rect::new(x0, y0, x1, y1).to_path(0.1) + } + + fn fill(path: BezPath) -> Shape { + Shape::new(path, FillRule::NonZero) + } + + /// The regions with their sources and areas rounded to 1e-6, sorted. + fn summary(regions: &[Region]) -> Vec<(Vec, f64)> { + let mut v: Vec<(Vec, f64)> = regions.iter().map(|r| (r.sources.clone(), (r.area * 1e6).round() / 1e6)).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + v + } + + /// Each grid point inside the union is in exactly one region, whose sources are the shapes + /// holding the point. + fn assert_tiles(shapes: &[Shape], regions: &[Region], area: Rect, step: f64) { + let mut y = area.y0 + step * 0.37; + while y < area.y1 { + let mut x = area.x0 + step * 0.41; + while x < area.x1 { + let p = Point::new(x, y); + let covering: Vec = shapes.iter().enumerate().filter(|(_, s)| s.rule.inside(s.path.winding(p))).map(|(i, _)| i).collect(); + let hits: Vec<&Region> = regions.iter().filter(|r| r.contains(p)).collect(); + if covering.is_empty() { + assert!(hits.is_empty(), "{p:?} is in no shape but in {} regions", hits.len()); + } else { + assert_eq!(hits.len(), 1, "{p:?} is in {} regions", hits.len()); + assert_eq!(hits[0].sources, covering, "at {p:?}"); + } + x += step; + } + y += step; + } + } + + #[test] + fn two_overlapping_rectangles_make_three_regions() { + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(5.0, 5.0, 15.0, 15.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(summary(&r), vec![(vec![0], 75.0), (vec![0, 1], 25.0), (vec![1], 75.0)]); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 16.0, 16.0), 0.5); + // The pieces the sweep split rectangle edges into are joined again. + let overlap = r.iter().find(|r| r.sources == [0, 1]).unwrap(); + assert_eq!(overlap.path.segments().count(), 4); + assert_eq!(overlap.top(), Some(1)); + } + + #[test] + fn coincident_edges_make_no_slivers() { + // Side by side, sharing an edge. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(10.0, 0.0, 20.0, 10.0))]; + assert_eq!(summary(®ions(&shapes, &[]).unwrap()), vec![(vec![0], 100.0), (vec![1], 100.0)]); + // Sharing three edges. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(0.0, 0.0, 10.0, 5.0))]; + assert_eq!(summary(®ions(&shapes, &[]).unwrap()), vec![(vec![0], 50.0), (vec![0, 1], 50.0)]); + // The same circle twice, one reversed. + let circle = Circle::new((5.0, 5.0), 5.0).to_path(0.01); + let shapes = [fill(circle.clone()), fill(circle.reverse_subpaths())]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(r.len(), 1); + assert_eq!(r[0].sources, [0, 1]); + assert!((r[0].area - circle.area().abs()).abs() < 1e-9); + } + + #[test] + fn fill_rules_differ_on_self_overlap() { + // Two squares in one path, wound the same way: the overlap has winding 2. + let mut path = rect(0.0, 0.0, 10.0, 10.0); + path.extend(rect(5.0, 5.0, 15.0, 15.0)); + let center = Point::new(7.5, 7.5); + let nonzero = regions(&[Shape::new(path.clone(), FillRule::NonZero)], &[]).unwrap(); + assert_eq!(summary(&nonzero), vec![(vec![0], 175.0)]); + assert!(nonzero[0].contains(center)); + let shapes = [Shape::new(path, FillRule::EvenOdd)]; + let evenodd = regions(&shapes, &[]).unwrap(); + assert!((evenodd.iter().map(|r| r.area).sum::() - 150.0).abs() < 1e-9); + assert!(evenodd.iter().all(|r| r.sources == [0] && !r.contains(center))); + assert_tiles(&shapes, &evenodd, Rect::new(-1.0, -1.0, 16.0, 16.0), 0.5); + } + + #[test] + fn a_hole_is_part_of_its_region_and_a_shape_in_it_is_its_own() { + // Nonzero hole by reversal, even-odd hole by nesting. + let mut nonzero = rect(0.0, 0.0, 10.0, 10.0); + nonzero.extend(rect(3.0, 3.0, 7.0, 7.0).reverse_subpaths()); + let mut evenodd = rect(0.0, 0.0, 10.0, 10.0); + evenodd.extend(rect(3.0, 3.0, 7.0, 7.0)); + for donut in [Shape::new(nonzero, FillRule::NonZero), Shape::new(evenodd, FillRule::EvenOdd)] { + let shapes = [donut, fill(rect(4.0, 4.0, 6.0, 6.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert_eq!(summary(&r), vec![(vec![0], 84.0), (vec![1], 4.0)]); + let ring = r.iter().find(|r| r.sources == [0]).unwrap(); + assert_eq!(ring.path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 2); + assert!(!ring.contains(Point::new(3.5, 3.5))); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 11.0, 11.0), 0.25); + } + } + + #[test] + fn slivers_below_the_threshold_are_dropped() { + // Overlapping by 0.001. + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)), fill(rect(9.999, 0.0, 20.0, 10.0))]; + let r = regions(&shapes, &[]).unwrap(); + assert!(r.iter().all(|r| r.sources != [0, 1])); + assert_eq!(r.len(), 2); + let kept = regions_with(&shapes, &[], 0.0).unwrap(); + let sliver = kept.iter().find(|r| r.sources == [0, 1]).unwrap(); + assert!((sliver.area - 0.01).abs() < 1e-9, "{}", sliver.area); + } + + #[test] + fn region_areas_sum_to_the_union_area() { + let shapes = [ + fill(rect(0.0, 0.0, 40.0, 30.0)), + fill(Circle::new((35.0, 25.0), 12.0).to_path(0.01)), + fill(rect(10.0, 10.0, 60.0, 20.0)), + Shape::new(Circle::new((20.0, 35.0), 8.0).to_path(0.01), FillRule::EvenOdd), + fill(rect(70.0, 0.0, 80.0, 10.0)), + ]; + let r = regions(&shapes, &[]).unwrap(); + let sum: f64 = r.iter().map(|r| r.area).sum(); + let union = union_area(&shapes); + assert!((sum - union).abs() < 1e-6 * union, "{sum} vs {union}"); + assert_tiles(&shapes, &r, Rect::new(-1.0, -1.0, 81.0, 44.0), 0.7); + } + + /// The area of the union of `shapes` (all positively wound), from a sweep of them as one path. + fn union_area(shapes: &[Shape]) -> f64 { + let mut all = BezPath::new(); + for s in shapes { + all.extend(s.path.iter()); + } + let top = Topology::::from_path(&all, 1e-6).unwrap(); + top.contours(|w| *w != 0).contours().map(|c| c.path.area()).sum::().abs() + } + + #[test] + fn clips_limit_their_shapes_only() { + let clips = [ClipPath { path: rect(5.0, 0.0, 25.0, 10.0), rule: FillRule::NonZero }]; + let shapes = [fill(rect(0.0, 0.0, 10.0, 10.0)).clipped([0]), fill(rect(20.0, 0.0, 30.0, 10.0))]; + let r = regions(&shapes, &clips).unwrap(); + // The clip's edge at x = 25 crosses only the unclipped shape, which stays one region. + assert_eq!(summary(&r), vec![(vec![0], 50.0), (vec![1], 100.0)]); + assert!(r.iter().any(|r| r.sources == [0] && r.contains(Point::new(7.0, 5.0)))); + assert_eq!(regions(&[fill(rect(0.0, 0.0, 1.0, 1.0)).clipped([3])], &clips), Err(GeomError::UnknownClip { shape: 0, clip: 3 })); + } + + #[test] + fn open_and_non_finite_input() { + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 0.0)); + open.line_to((10.0, 10.0)); + assert_eq!(summary(®ions(&[fill(open)], &[]).unwrap()), vec![(vec![0], 50.0)]); + assert_eq!(regions(&[fill(rect(0.0, 0.0, f64::NAN, 1.0))], &[]), Err(GeomError::NonFinite)); + assert_eq!(regions(&[], &[]), Ok(vec![])); + } + + #[test] + fn only_groups_with_transparency_need_regions() { + let bounds = [ + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(100.0, 100.0, 110.0, 110.0), + Rect::new(10.0, 5.0, 20.0, 15.0), // touches 0 + Rect::new(18.0, 14.0, 30.0, 30.0), + Rect::new(200.0, 0.0, 210.0, 10.0), + ]; + assert_eq!(overlap_groups(&bounds), vec![vec![0, 2, 3], vec![1], vec![4]]); + assert_eq!(transparent_groups(&bounds, &[false, false, false, true, false]), vec![vec![0, 2, 3]]); + assert!(transparent_groups(&bounds, &[false; 5]).is_empty()); + } + + /// About 1,000 rectangles on a letter page. Run with `--ignored --nocapture`. + #[test] + #[ignore] + fn timing_on_a_thousand_rectangles() { + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + let shapes: Vec = (0..1000) + .map(|_| { + let (x, y) = (next() * 560.0, next() * 740.0); + let (w, h) = (2.0 + next() * 50.0, 2.0 + next() * 50.0); + fill(rect(x, y, x + w, y + h)) + }) + .collect(); + let t = std::time::Instant::now(); + let r = regions(&shapes, &[]).unwrap(); + let elapsed = t.elapsed(); + eprintln!("{} shapes -> {} regions in {:?}", shapes.len(), r.len(), elapsed); + let union = union_area(&shapes); + let sum: f64 = r.iter().map(|r| r.area).sum(); + // Less the slivers left out. + assert!(sum <= union * (1.0 + 1e-9) && sum > union * (1.0 - 1e-4), "{sum} vs {union}"); + } +} diff --git a/crates/prepress/src/flatten/mod.rs b/crates/prepress/src/flatten/mod.rs new file mode 100644 index 00000000..860d9ed7 --- /dev/null +++ b/crates/prepress/src/flatten/mod.rs @@ -0,0 +1,235 @@ +//! The display list the transparency flattener reads and writes. +//! +//! Coordinates are page space in points, origin at the top left and y down, as krilla draws. +//! Opacity, blend modes, soft masks and clips live on [`GroupItem`]s only: a transparent or +//! clipped paint arrives as a group with one child. Strokes arrive outlined as fills. +//! Overprint stays a per-item attribute; the flattener applies it per plate. + +use std::fmt; +use std::ops::Range; +use std::sync::Arc; + +pub use kurbo; +use kurbo::{Affine, BezPath, Point, Rect}; + +use crate::ink::{InkColor, InkId, InkSet, Overprint}; + +pub use compose::{ComplexGroup, ComplexReason, ComplexRegion, FlattenError, FlattenOptions, FlattenReport, Flattened, flatten}; +pub use document::{ + FlattenDocumentError, FlattenDocumentOptions, FlattenDocumentReport, FlattenTotals, Unreadable, flatten_document, flatten_document_with_progress, +}; +pub use geom::FillRule; +pub use raster::{FlattenPageError, FlattenPageOptions, FlattenPageReport, flatten_page}; + +mod compose; +mod document; +pub mod font; +pub mod geom; +pub mod pdf; +pub mod raster; +mod shade; +pub mod write; + +/// One page's content as the flattener sees it. +#[derive(Clone, Debug, PartialEq)] +pub struct DisplayList { + /// The page box in page space. + pub page: Rect, + /// The inks the items' colors refer to. + pub inks: InkSet, + /// Items in painting order. + pub items: Vec, +} + +/// A painted element. +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Fill(FillItem), + Text(TextItem), + Image(ImageItem), + Group(GroupItem), +} + +/// A filled path. +#[derive(Clone, Debug, PartialEq)] +pub struct FillItem { + pub path: BezPath, + pub rule: FillRule, + pub paint: Paint, + pub overprint: Overprint, +} + +/// A run of glyphs from one font at one size and transform, kept as live text when +/// written out. +#[derive(Clone, Debug, PartialEq)] +pub struct TextItem { + /// The font to embed again; `None` when the source font can't be re-embedded, and the + /// run is then written as `outlines`. + pub font: Option>, + /// The em size in text space units. + pub size: f32, + /// Text space to page space. Text space is y up, as in a PDF text matrix. + pub transform: Affine, + pub glyphs: Vec, + /// The Unicode text of the run, when the source had it; [`PlacedGlyph::text`] indexes it. + pub text: Option, + /// The glyphs' filled outlines in page space. Always present: the flattener's geometry for + /// the run, and what is drawn when `font` is `None`. + pub outlines: BezPath, + pub paint: Paint, + pub overprint: Overprint, +} + +/// A glyph of a [`TextItem`]. +#[derive(Clone, Debug, PartialEq)] +pub struct PlacedGlyph { + /// The glyph id in [`TextFont::data`]. + pub id: u32, + /// The glyph origin in text space. + pub origin: Point, + /// The bytes of [`TextItem::text`] this glyph shows; empty when it shows none. + pub text: Range, +} + +/// Font bytes shared without copying, as hayro-interpret and krilla hold them. +pub type FontBytes = Arc + Send + Sync>; + +/// An OpenType (TrueType or CFF outlines) font that krilla can embed. +#[derive(Clone)] +pub struct TextFont { + pub data: FontBytes, + /// The face index in a font collection; 0 for a single font. + pub index: u32, + /// Identifies the source font: runs with equal keys embed one font, so equal keys mean + /// equal bytes. + pub key: u128, +} + +impl fmt::Debug for TextFont { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TextFont") + .field("bytes", &(*self.data).as_ref().len()) + .field("index", &self.index) + .field("key", &format_args!("{:032x}", self.key)) + .finish() + } +} + +impl PartialEq for TextFont { + fn eq(&self, other: &Self) -> bool { + self.key == other.key && self.index == other.index + } +} + +/// A sampled image, 8 bits per component, rows top to bottom. +#[derive(Clone, Debug, PartialEq)] +pub struct ImageItem { + pub space: ImageSpace, + /// Width and height in samples. + pub size: (u32, u32), + /// Interleaved components, `size.0 * size.1 * components` bytes. + pub samples: Arc<[u8]>, + /// The unit square to page space; (0, 0) is the top left sample. + pub transform: Affine, + /// Alpha, one byte per sample, at `size`. + pub smask: Option>, + pub overprint: Overprint, + /// The source's JPEG (DCTDecode) data, when `samples` are its decoding and the writer can + /// write it as it is: [`ImageSpace::Gray`] or [`ImageSpace::Cmyk`], no `smask`. + pub jpeg: Option>, +} + +/// The components of an [`ImageItem`]'s samples. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageSpace { + Gray, + Rgb, + Cmyk, + /// Tints of these inks, 255 = 100% ink, in this order. + Inks(Vec), +} + +/// A transparency group, or a plain clip when it is opaque, Normal and unmasked. +#[derive(Clone, Debug, PartialEq)] +pub struct GroupItem { + pub opacity: f32, + pub blend: BlendMode, + pub isolated: bool, + pub knockout: bool, + /// Nonzero clip in page space. + pub clip: Option, + pub mask: Option>, + /// The Unicode text the children show, for content drawn as items that is text in the + /// source, such as a colored Type 3 glyph. + pub actual_text: Option, + pub children: Vec, +} + +/// A soft mask (ISO 32000-2 §11.6.5). +#[derive(Clone, Debug, PartialEq)] +pub struct Mask { + pub kind: MaskKind, + pub group: GroupItem, + /// The luminosity mask's backdrop (`/BC`), what the group composites over; `None` for an + /// alpha mask. + pub backdrop: Option, + /// The transfer function (`/TR`) sampled at 256 evenly spaced mask values from 0 to 1, as + /// bytes; `None` for the identity. + pub transfer: Option>, +} + +/// Which value of the mask group masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MaskKind { + Alpha, + Luminosity, +} + +/// What an item paints with. +#[derive(Clone, Debug, PartialEq)] +pub enum Paint { + Solid(InkColor), + /// Stops are offsets `0.0..=1.0` in increasing order. Function-based and mesh shadings + /// arrive as images. + Gradient { + stops: Vec<(f32, InkColor)>, + geometry: GradientGeometry, + }, +} + +/// Where a gradient runs, in gradient space. +#[derive(Clone, Debug, PartialEq)] +pub enum GradientGeometry { + Axial { start: Point, end: Point, extend: [bool; 2], transform: Affine }, + Radial { start: Point, start_radius: f64, end: Point, end_radius: f64, extend: [bool; 2], transform: Affine }, +} + +/// PDF blend modes (ISO 32000-2 §11.3.5). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BlendMode { + #[default] + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +impl BlendMode { + /// Hue, Saturation, Color and Luminosity; on spot inks they act as Normal + /// (ISO 32000-1 §11.3.5.3). + pub fn is_non_separable(self) -> bool { + matches!(self, Self::Hue | Self::Saturation | Self::Color | Self::Luminosity) + } +} diff --git a/crates/prepress/src/flatten/pdf.rs b/crates/prepress/src/flatten/pdf.rs new file mode 100644 index 00000000..f6d67a9e --- /dev/null +++ b/crates/prepress/src/flatten/pdf.rs @@ -0,0 +1,1335 @@ +//! The PDF-in adapter: a page read through hayro-interpret into a [`DisplayList`]. +//! +//! - Coordinates are page space (`Page::initial_transform(true)`): rotation and the crop box +//! are applied. +//! - Colors stay inks, as plate mode separates them (`InterpreterSettings::plate`): +//! `Separation` and `DeviceN` colorants are their inks, DeviceCMYK its four process tints, +//! DeviceGray black only (C, M and Y at 0), and RGB, Lab and ICC colors convert to CMYK through +//! the page's output intent, else [`ReadOptions::fallback`]. Spot inks join +//! [`DisplayList::inks`] with their alternate: CMYK when the space's alternate is DeviceCMYK, +//! else Lab measured from the sRGB appearance. +//! - Overprint: an item's [`Overprint::fill`] and [`Overprint::stroke`] both hold the flag that +//! governed its paint (`op` for fills, glyphs and images, `OP` for strokes). +//! [`Overprint::nonzero`] is `OPM 1` for colors set in DeviceCMYK only, where it applies. +//! - Constant opacity, blend modes and soft masks wrap each paint in a group of its own; a text +//! run shares one wrapper. +//! - Images keep their own space where it is inks: DeviceGray, DeviceCMYK, and the colorants of +//! `Separation` and `DeviceN` (Indexed images take their base's). Other spaces convert to CMYK +//! as colors do. `/Decode` and `/Matte` are applied; transfer functions are not, as on a plate. +//! - Stencil masks become fills traced from the mask, or images when the mask is too detailed. +//! - Axial and radial shadings become [`Paint::Gradient`]s with stops sampled in ink space. +//! Function-based and mesh shadings, and tiling patterns past [`MAX_TILING_CELLS`] cells or +//! [`MAX_TILING_ITEMS`] items, become images at [`ReadOptions::raster_dpi`]; other tiling patterns expand into their cells. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use craft_pdf::interpret::color::{CmykSimulation, ColorSpace, Ink as PdfInk, InkPaint, InkSpace, SpotAlternate}; +use craft_pdf::interpret::font::{Glyph, OutlineGlyph}; +use craft_pdf::interpret::hayro_cmap::BfString; +use craft_pdf::interpret::pattern::{Pattern, ShadingPattern, TilingPattern}; +use craft_pdf::interpret::shading::ShadingType; +use craft_pdf::interpret::{ + BlendMode as PdfBlend, CacheKey, ClipPath, Context, Device, FillRule as PdfFillRule, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, + MaskType, Paint as PdfPaint, PathDrawMode, RasterImage, SoftMask, StencilImage, StrokeProps, TransformExt, interpret_page, +}; +use craft_pdf::syntax::Filter; +use craft_pdf::syntax::Pdf; +use craft_pdf::syntax::object::dict::keys::{ANNOTS, AP, BITS_PER_COMPONENT, COLORSPACE, DECODE, DECODE_PARMS, F, HEIGHT, N, WIDTH}; +use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; +use craft_pdf::syntax::page::Page; +use craft_pdf::{OpenError, open_slice}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Stroke, StrokeOpts}; + +use super::font::FontCache; +use super::shade::{self, Canvas, StopInks}; +use super::{ + BlendMode, DisplayList, FillItem, FillRule, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Mask, MaskKind, Paint, PlacedGlyph, + TextFont, TextItem, +}; +use crate::cms::{ProfileError, srgb8_to_lab}; +use crate::ink::{Alternate, Ink, InkColor, InkId, InkSet, Overprint, Process, Spot}; +use crate::plates::{self, Conversion, Fallback}; + +/// Curve flattening tolerance of outlined strokes, in points. +const STROKE_TOLERANCE: f64 = 0.01; + +/// The width of a `0 w` stroke, the thinnest line a device can draw, in points. +const HAIRLINE: f64 = 0.1; + +/// Dashes one stroke may be cut into; past this it is stroked solid, as hayro draws it. +const MAX_DASHES_PER_STROKE: f64 = 250_000.0; + +/// Tiling cells one pattern fill expands into; past this the fill is read as an image. +pub const MAX_TILING_CELLS: usize = 10_000; + +/// Items one pattern fill expands into, counting those inside groups and nested pattern fills; +/// past this the fill is read as an image. A hundred per cell at [`MAX_TILING_CELLS`]. +pub const MAX_TILING_ITEMS: usize = 1_000_000; + +/// Rectangles a stencil mask may trace into; past this it is read as an image. +const MAX_STENCIL_RECTS: usize = 4_096; + +/// Pixels one image read from a shading or tiling pattern may hold; a larger area is sampled +/// at a lower resolution. +const MAX_RASTER_PIXELS: f64 = 16_777_216.0; + +/// How much finer than the image a tiling cell is drawn, so that small cells keep their shapes. +const CELL_OVERSAMPLE: f64 = 4.0; + +/// Pixels the drawing of one tiling cell may hold. +const MAX_CELL_PIXELS: f64 = 4_194_304.0; + +/// Cell samples one image read from a tiling pattern may take; a pattern whose cells overlap is +/// sampled at a lower resolution. Four per pixel at [`MAX_RASTER_PIXELS`], because a cell no +/// larger than its step meets three others at its corners. +const MAX_TILING_SAMPLES: f64 = 4.0 * MAX_RASTER_PIXELS; + +/// Inks a tiling pattern's canvases hold at their full pixel limits. Each pixel holds a value +/// per ink of the page, so a page with more inks is sampled at a lower resolution, keeping the +/// canvases (and their sampling) at the memory and work of CMYK ones. +const CANVAS_INKS: f64 = 4.0; + +/// How [`read_page`] reads. +#[derive(Clone, Debug)] +pub struct ReadOptions { + /// The printing condition RGB, Lab and ICC colors convert into when the page has no output + /// intent with a usable CMYK profile. + pub fallback: Fallback, + /// Read the page's annotation appearances too. When `false`, the print-flagged ones with an + /// appearance are left out and counted ([`ReadReport::dropped_annotations`]). + pub annotations: bool, + /// The resolution, in pixels per inch, of images read from function-based and mesh + /// shadings and from tiling patterns past [`MAX_TILING_CELLS`] or [`MAX_TILING_ITEMS`]. + pub raster_dpi: f32, +} + +impl Default for ReadOptions { + fn default() -> Self { + Self { fallback: Fallback::default(), annotations: false, raster_dpi: 300.0 } + } +} + +/// What [`read_page`] read, converted or left out. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct ReadReport { + /// How process color that isn't CMYK became CMYK. + pub conversion: Conversion, + /// Raster images read. + pub images: usize, + /// Of [`Self::images`], those whose samples were converted into CMYK (RGB, Lab, ICC and + /// CIE-based spaces). + pub converted_images: usize, + /// Images whose `/SMask` or `/Mask` had another size than the image, resampled to it. + pub resampled_masks: usize, + /// Images and stencil masks that didn't decode, left out. + pub unreadable_images: usize, + /// Stencil masks (`/ImageMask`) read. + pub stencils: usize, + /// Axial and radial shadings read as gradients. + pub gradients: usize, + /// Function-based and mesh shadings read as images. + pub rasterized_shadings: usize, + /// Tiling pattern cells expanded. + pub tiling_cells: usize, + /// Tiling pattern fills past [`MAX_TILING_CELLS`] cells or [`MAX_TILING_ITEMS`] items, read + /// as images. + pub rasterized_tilings: usize, + /// Tiling pattern fills left out: cells stacked too deep to sample, or canvases too large. + pub dropped_tilings: usize, + /// Glyphs in text rendering mode 3, dropped. + pub invisible_glyphs: usize, + /// Spot inks past the ink set's capacity, whose tints were dropped. + pub dropped_inks: usize, + /// Annotations that would print (Print flag set, not Hidden, with a normal appearance), left + /// out because [`ReadOptions::annotations`] is `false`. + pub dropped_annotations: usize, +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReadError { + #[error(transparent)] + Open(#[from] OpenError), + #[error("page {0} does not exist")] + NoPage(usize), + #[error("the fallback profile can't be a printing condition: {0}")] + Profile(#[from] ProfileError), + #[error("the PDF interpreter failed on the page")] + Interpret, +} + +/// Reads page `page` (from 0) of `pdf` into a display list. Never panics. +pub fn read_page(pdf: &[u8], page: usize, opts: &ReadOptions) -> Result<(DisplayList, ReadReport), ReadError> { + let doc = open_slice(pdf, None)?; + let color = read_color(&doc, page, &opts.fallback)?; + read_page_in(&doc, page, opts, color) +} + +/// How a page's process color converts: its output intent's simulation, else `fallback`'s. +pub(crate) type ReadColor = (Option, Conversion); + +/// The [`ReadColor`] of page `page`; parsing the profile is the slow part, so a document +/// reads it once per output intent. +pub(crate) fn read_color(doc: &Pdf, page: usize, fallback: &Fallback) -> Result { + match catch_unwind(AssertUnwindSafe(|| plates::output_intent(doc, page))) { + Ok(Some(found)) => Ok((Some(found.0), found.1)), + Ok(None) | Err(_) => plates::fallback(fallback).map_err(|e| match e { + plates::Error::Profile(p) => ReadError::Profile(p), + _ => ReadError::Interpret, + }), + } +} + +/// [`read_page`] of an open document, with the page's color already read. +pub(crate) fn read_page_in(doc: &Pdf, page: usize, opts: &ReadOptions, color: ReadColor) -> Result<(DisplayList, ReadReport), ReadError> { + let (simulation, conversion) = color; + let pages = doc.pages(); + let p = pages.get(page).ok_or(ReadError::NoPage(page))?; + let (w, h) = p.render_dimensions(); + let bbox = Rect::new(0.0, 0.0, f64::from(w), f64::from(h)); + let report = ReadReport { + conversion, + images: 0, + converted_images: 0, + resampled_masks: 0, + unreadable_images: 0, + stencils: 0, + gradients: 0, + rasterized_shadings: 0, + tiling_cells: 0, + rasterized_tilings: 0, + dropped_tilings: 0, + invisible_glyphs: 0, + dropped_inks: 0, + dropped_annotations: if opts.annotations { 0 } else { catch_unwind(AssertUnwindSafe(|| printing_annotations(p))).unwrap_or(0) }, + }; + let dpi = if opts.raster_dpi.is_finite() && opts.raster_dpi > 0.0 { opts.raster_dpi } else { 300.0 }; + let mut shared = Shared { + simulation, + inks: InkSet::new(), + spots: HashMap::new(), + report, + masks: HashMap::new(), + fonts: FontCache::new(), + scale: f64::from(dpi) / 72.0, + }; + + let items = catch_unwind(AssertUnwindSafe(|| { + let cache = InterpreterCache::new(); + let settings = InterpreterSettings { render_annotations: opts.annotations, ..Default::default() }; + let mut context = Context::new(p.initial_transform(true).to_kurbo(), bbox, &cache, p.xref(), settings); + let mut reader = Reader::new(&mut shared, false, bbox); + interpret_page(p, &mut context, &mut reader); + reader.finish() + })) + .map_err(|_| ReadError::Interpret)?; + + let Shared { inks, report, fonts, .. } = shared; + let fonts = fonts.finish(); + let mut items = items; + set_fonts(&mut items, &fonts); + Ok((DisplayList { page: bbox, inks, items }, report)) +} + +/// The annotations of `page` a printer would paint: Print flag (bit 3) set, Hidden (bit 2) +/// clear, with a normal appearance (`/AP /N`). +fn printing_annotations(page: &Page<'_>) -> usize { + let Some(annots) = page.raw().get::>(ANNOTS) else { return 0 }; + annots + .iter::>() + .filter(|a| a.get::(F).unwrap_or(0) & (4 | 2) == 4 && a.get::>(AP).is_some_and(|ap| ap.contains_key(N))) + .count() +} + +/// State shared by the page's reader and the readers of its soft masks and Type 3 glyphs. +struct Shared { + simulation: Option, + inks: InkSet, + spots: HashMap>, + report: ReadReport, + /// Soft masks read so far, by hayro's cache key. + masks: HashMap>, + fonts: FontCache, + /// Raster pixels per point. + scale: f64, +} + +/// How a paint composites: what wraps it in a group of its own. +#[derive(Clone)] +struct Look<'a> { + opacity: f32, + blend: BlendMode, + mask: Option>, +} + +impl Look<'_> { + fn same(&self, other: &Self) -> bool { + self.opacity == other.opacity + && self.blend == other.blend + && self.mask.as_ref().map(CacheKey::cache_key) == other.mask.as_ref().map(CacheKey::cache_key) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum FrameKind { + Root, + Clip, + Group, +} + +struct Frame { + kind: FrameKind, + group: GroupItem, +} + +/// A text run being collected. +struct Run<'a> { + /// The source font's key; `None` for Type 3 glyphs. + font: Option, + /// The linear part of glyph space (one unit per em) to page space. + linear: Affine, + look: Look<'a>, + item: TextItem, +} + +/// What a paint paints with. +enum Painted<'a> { + /// A paint an item carries. + Flat(Paint, Look<'a>, Overprint), + /// A pattern read over the painted area ([`Reader::area_fill`]). + Area, +} + +/// The hayro-interpret device. `glyph` readers read a Type 3 glyph procedure: they collect +/// paints without their own transparency, which the glyph's run carries. +struct Reader<'s, 'a> { + shared: &'s mut Shared, + glyph: bool, + /// Where the content can show, before clips: the page, or unbounded for a tiling cell, + /// whose copies land elsewhere. + bounds: Rect, + frames: Vec, + soft_mask: Option>, + blend: BlendMode, + /// `op`, `OP` and `OPM`. + overprint: (bool, bool, u8), + run: Option>, +} + +fn plain_group(clip: Option) -> GroupItem { + GroupItem { opacity: 1.0, blend: BlendMode::Normal, isolated: false, knockout: false, clip, mask: None, actual_text: None, children: Vec::new() } +} + +impl<'s, 'a> Reader<'s, 'a> { + fn new(shared: &'s mut Shared, glyph: bool, bounds: Rect) -> Self { + Self { + shared, + glyph, + bounds, + frames: vec![Frame { kind: FrameKind::Root, group: plain_group(None) }], + soft_mask: None, + blend: BlendMode::Normal, + overprint: (false, false, 0), + run: None, + } + } + + /// The items read, with every open clip and group closed. + fn finish(mut self) -> Vec { + self.flush(); + while self.frames.len() > 1 { + self.close_top(); + } + self.frames.pop().map(|f| f.group.children).unwrap_or_default() + } + + fn add(&mut self, item: Item) { + if let Some(frame) = self.frames.last_mut() { + frame.group.children.push(item); + } + } + + fn close_top(&mut self) { + let Some(frame) = self.frames.pop_if(|f| f.kind != FrameKind::Root) else { return }; + if frame.group.children.is_empty() { + return; + } + // A group that changes nothing (hayro wraps every image in one) adds its children. + let g = &frame.group; + let plain = g.opacity == 1.0 + && g.blend == BlendMode::Normal + && !g.isolated + && !g.knockout + && g.clip.is_none() + && g.mask.is_none() + && g.actual_text.is_none(); + if plain { + for item in frame.group.children { + self.add(item); + } + } else { + self.add(Item::Group(frame.group)); + } + } + + /// The page area content can show in, through every open clip. + fn clip_bounds(&self) -> Rect { + let mut r = self.bounds; + for frame in &self.frames { + if let Some(clip) = &frame.group.clip { + r = r.intersect(clip.bounding_box()); + } + } + r + } + + /// Raster pixels per point for an image over `area`, within [`MAX_RASTER_PIXELS`]. + fn raster_scale(&self, area: Rect) -> f64 { + limit_scale(self.shared.scale, area, MAX_RASTER_PIXELS) + } + + /// Closes the innermost frame of `kind`, and the frames opened inside it. + fn close(&mut self, kind: FrameKind) { + self.flush(); + let Some(at) = self.frames.iter().rposition(|f| f.kind == kind) else { return }; + while self.frames.len() > at { + self.close_top(); + } + } + + fn look(&self, opacity: f32) -> Look<'a> { + if self.glyph { + return Look { opacity: 1.0, blend: BlendMode::Normal, mask: None }; + } + Look { opacity, blend: self.blend, mask: self.soft_mask.clone() } + } + + /// Adds `item`, in a group of its own when `look` is transparent. + fn push(&mut self, item: Item, look: &Look<'a>) { + let mask = look.mask.as_ref().and_then(|m| self.mask(m)); + let item = if look.opacity < 1.0 || look.blend != BlendMode::Normal || mask.is_some() { + Item::Group(GroupItem { opacity: look.opacity, blend: look.blend, mask: mask.map(Box::new), children: vec![item], ..plain_group(None) }) + } else { + item + }; + self.add(item); + } + + fn flush(&mut self) { + let Some(mut run) = self.run.take() else { return }; + if run.item.text.as_deref() == Some("") { + run.item.text = None; + } + self.push(Item::Text(run.item), &run.look); + } + + fn mask(&mut self, mask: &SoftMask<'a>) -> Option { + let key = mask.cache_key(); + if let Some(read) = self.shared.masks.get(&key) { + return read.clone(); + } + // A mask that refers to itself reads as no mask. + self.shared.masks.insert(key, None); + let mut reader = Reader::new(&mut *self.shared, false, self.bounds); + mask.interpret(&mut reader); + let children = reader.finish(); + let kind = match mask.mask_type() { + MaskType::Luminosity => MaskKind::Luminosity, + MaskType::Alpha => MaskKind::Alpha, + }; + let backdrop = match kind { + MaskKind::Luminosity => self.ink_color(&mask.background_color()), + MaskKind::Alpha => None, + }; + let transfer = mask.transfer_function().map(|tf| (0..=255_u8).map(|v| (tf.apply(f32::from(v) / 255.0) * 255.0).round() as u8).collect()); + let group = GroupItem { isolated: true, children, ..plain_group(None) }; + let read = Mask { kind, group, backdrop, transfer }; + self.shared.masks.insert(key, Some(read.clone())); + Some(read) + } + + fn ink(&mut self, ink: &PdfInk, alternate: impl FnOnce() -> Option) -> Option { + let process = match ink { + PdfInk::Cyan => Process::Cyan, + PdfInk::Magenta => Process::Magenta, + PdfInk::Yellow => Process::Yellow, + PdfInk::Black => Process::Black, + PdfInk::Spot(name) => { + if let Some(id) = self.shared.spots.get(ink) { + return *id; + } + let alternate = match alternate() { + Some(SpotAlternate::Cmyk(cmyk)) => Alternate::Cmyk(cmyk), + Some(SpotAlternate::Srgb(rgb)) => Alternate::Lab(srgb8_to_lab(rgb).unwrap_or([0.0; 3])), + None => Alternate::Lab([0.0; 3]), + }; + let id = self.shared.inks.add(Ink::Spot(Spot { name: name.to_string(), alternate, solidity: None })); + self.shared.spots.insert(ink.clone(), id); + return id; + } + }; + Some(InkId::process(process)) + } + + /// The color as inks; `None` when it paints nothing (the `None` colorant). + fn ink_color(&mut self, color: &craft_pdf::interpret::color::Color) -> Option { + match color.ink_paint(self.shared.simulation.as_ref())? { + InkPaint::Tints(tints) => { + let mut out = Vec::with_capacity(tints.len()); + for (ink, tint) in tints { + match self.ink(&ink, || color.spot_alternate(&ink)) { + Some(id) => out.push((id, tint)), + None => self.shared.report.dropped_inks += 1, + } + } + Some(InkColor::Tints(out)) + } + InkPaint::All(tint) => Some(InkColor::All(tint)), + InkPaint::None => None, + } + } + + /// How values of `space` (colors of `cs`) become samples or stops: the inks, and which + /// values to keep. `None` when the space paints nothing. + fn space_inks(&mut self, space: &InkSpace, cs: &ColorSpace) -> Option<(Inks, Channels)> { + let process = || Process::ALL.iter().map(|p| InkId::process(*p)).collect(); + Some(match space { + InkSpace::None => return None, + InkSpace::Gray => (Inks::Gray, Channels::All), + InkSpace::Cmyk { .. } => (Inks::Cmyk(process()), Channels::All), + InkSpace::All => (Inks::List(self.shared.inks.iter().map(|(id, _)| id).collect()), Channels::Spread(self.shared.inks.len())), + InkSpace::Inks(list) => { + let mut ids = Vec::with_capacity(list.len()); + let mut keep = Vec::with_capacity(list.len()); + for ink in list { + let id = self.ink(ink, || cs.spot_alternate(ink)); + if id.is_none() { + self.shared.report.dropped_inks += 1; + } + keep.push(id.is_some()); + ids.extend(id); + } + if ids.is_empty() { + return None; + } + let channels = if keep.iter().all(|k| *k) { Channels::All } else { Channels::Keep(keep) }; + (Inks::List(ids), channels) + } + }) + } + + /// The paint and how it paints; `None` when it paints nothing. + fn paint(&mut self, paint: &PdfPaint<'a>, stroke: bool) -> Option> { + let (op, big_op, mode) = self.overprint; + let flag = if stroke { big_op } else { op }; + let mut overprint = Overprint { fill: flag, stroke: flag, nonzero: false }; + match paint { + PdfPaint::Color(color) => { + overprint.nonzero = mode == 1 && color.is_device_cmyk(); + let ink = self.ink_color(color)?; + Some(Painted::Flat(Paint::Solid(ink), self.look(color.opacity()), overprint)) + } + PdfPaint::Pattern(pattern) => match pattern.as_ref() { + // A gradient that needs a clip (a bounding box, or an end that doesn't extend) or a + // background is read over its area. + Pattern::Shading(s) + if s.shading.clip_path.is_none() + && s.shading.background.is_none() + && matches!(s.shading.shading_type.as_ref(), ShadingType::RadialAxial { extend: [true, true], .. }) => + { + overprint.nonzero = mode == 1 && s.shading.color_space.is_device_cmyk(); + let (gradient, _) = self.gradient(s)?; + Some(Painted::Flat(gradient, self.look(s.opacity), overprint)) + } + Pattern::Shading(_) | Pattern::Tiling(_) => Some(Painted::Area), + }, + } + } + + /// An axial or radial shading as a gradient extended at both ends, with the clip to its own + /// extent when the shading doesn't extend (see [`shade::gradient_extent`]); `None` for other + /// types, or when it paints nothing. + fn gradient(&mut self, sp: &ShadingPattern) -> Option<(Paint, Option)> { + let ShadingType::RadialAxial { coords, domain, function, extend, axial } = sp.shading.shading_type.as_ref() else { return None }; + let cs = &sp.shading.color_space; + let simulation = self.shared.simulation.clone(); + let space = cs.ink_space(simulation.as_ref()); + let (inks, channels) = self.space_inks(&space, cs)?; + let stops = shade::sample_stops(space.len(), |s| { + let t = domain[0] + s * (domain[1] - domain[0]); + let components = shade::eval(function, &[t])?; + let mut values = Vec::new(); + cs.ink_values(&components, simulation.as_ref(), &mut values); + Some(values) + }); + let kind = inks.stop_inks(); + let stops = stops.into_iter().map(|(offset, values)| (offset, shade::stop_color(&kind, &channels.apply_f32(&values)))).collect(); + let c = coords.map(f64::from); + let geometry = if *axial { + GradientGeometry::Axial { start: Point::new(c[0], c[1]), end: Point::new(c[2], c[3]), extend: [true, true], transform: sp.matrix } + } else { + GradientGeometry::Radial { + start: Point::new(c[0], c[1]), + start_radius: c[2], + end: Point::new(c[3], c[4]), + end_radius: c[5], + extend: [true, true], + transform: sp.matrix, + } + }; + let extent = shade::gradient_extent(&geometry, *extend, self.clip_bounds()); + self.shared.report.gradients += 1; + Some((Paint::Gradient { stops, geometry }, extent)) + } + + fn fill(&mut self, path: BezPath, rule: FillRule, paint: &PdfPaint<'a>, stroke: bool) { + match self.paint(paint, stroke) { + Some(Painted::Flat(paint, look, overprint)) => { + self.flush(); + self.push(Item::Fill(FillItem { path, rule, paint, overprint }), &look); + } + Some(Painted::Area) => self.area_fill(path, rule, paint, stroke), + None => {} + } + } + + /// A shading or tiling pattern read over `path`: its items or an image, clipped to the + /// path. + fn area_fill(&mut self, path: BezPath, rule: FillRule, paint: &PdfPaint<'a>, stroke: bool) { + let PdfPaint::Pattern(pattern) = paint else { return }; + self.flush(); + let clip = nonzero(&path, rule); + let (op, big_op, _) = self.overprint; + let flag = if stroke { big_op } else { op }; + match pattern.as_ref() { + Pattern::Shading(sp) => self.shading_area(clip, sp, flag), + Pattern::Tiling(t) => self.tiling_area(clip, t, flag, stroke), + } + } + + fn shading_area(&mut self, clip: BezPath, sp: &ShadingPattern, flag: bool) { + let look = self.look(sp.opacity); + let cs = &sp.shading.color_space; + // Nonzero overprint mode is for colors set in DeviceCMYK, not shadings or images + // (ISO 32000-2 §8.6.7). + let overprint = Overprint { fill: flag, stroke: flag, nonzero: false }; + let simulation = self.shared.simulation.clone(); + let mut children = Vec::new(); + if let Some(bg) = &sp.shading.background { + let mut values = Vec::new(); + cs.ink_values(bg, simulation.as_ref(), &mut values); + let space = cs.ink_space(simulation.as_ref()); + if let Some((inks, channels)) = self.space_inks(&space, cs) { + let kind = inks.stop_inks(); + let paint = Paint::Solid(shade::stop_color(&kind, &channels.apply_f32(&values))); + children.push(Item::Fill(FillItem { path: clip.clone(), rule: FillRule::NonZero, paint, overprint })); + } + } + let shaded = if matches!(sp.shading.shading_type.as_ref(), ShadingType::RadialAxial { .. }) { + self.gradient(sp).map(|(paint, extent)| { + let fill = Item::Fill(FillItem { path: clip.clone(), rule: FillRule::NonZero, paint, overprint }); + match extent { + Some(extent) => Item::Group(GroupItem { children: vec![fill], ..plain_group(Some(extent)) }), + None => fill, + } + }) + } else { + self.shading_image(&clip, sp, overprint).map(Item::Image) + }; + if let Some(item) = shaded { + match &sp.shading.clip_path { + Some(bbox) => children.push(Item::Group(GroupItem { children: vec![item], ..plain_group(Some(bbox.clone())) })), + None => children.push(item), + } + } + if !children.is_empty() { + self.push(Item::Group(GroupItem { children, ..plain_group(Some(clip)) }), &look); + } + } + + /// A function-based or mesh shading sampled over the visible part of `clip`. + fn shading_image(&mut self, clip: &BezPath, sp: &ShadingPattern, overprint: Overprint) -> Option { + let mut area = clip.bounding_box().intersect(self.clip_bounds()); + if let Some(bbox) = &sp.shading.clip_path { + area = area.intersect(bbox.bounding_box()); + } + if !(area.width() > 0.0 && area.height() > 0.0) { + return None; + } + let scale = self.raster_scale(area); + let simulation = self.shared.simulation.clone(); + // The background is its own fill, under the image. + let raster = shade::rasterize_shading(sp, area, scale, false, simulation.as_ref())?; + let cs = &sp.shading.color_space; + let space = cs.ink_space(simulation.as_ref()); + let (inks, channels) = self.space_inks(&space, cs)?; + let samples: Vec = raster.values.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect(); + let samples = channels.apply(samples, space.len()); + let (w, h) = (raster.width as usize, raster.height as usize); + let smask = (!raster.alpha.iter().all(|a| *a == 255)).then(|| raster.alpha.into()); + self.shared.report.rasterized_shadings += 1; + Some(ImageItem { + space: inks.image_space(), + size: (raster.width, raster.height), + samples: samples.into(), + transform: shade::area_transform(area, scale, (w, h)), + smask, + overprint, + jpeg: None, + }) + } + + /// A tiling pattern over `clip`: its cells, each read like page content, or an image past + /// [`MAX_TILING_CELLS`] or [`MAX_TILING_ITEMS`]. + fn tiling_area(&mut self, clip: BezPath, t: &TilingPattern<'a>, flag: bool, stroke: bool) { + let area = clip.bounding_box().intersect(self.clip_bounds()); + if !(area.width() > 0.0 && area.height() > 0.0) { + return; + } + let m = t.matrix; + let inv = m.inverse(); + let (xs, ys) = (f64::from(t.x_step), f64::from(t.y_step)); + let b = t.bbox; + let pa = inv.transform_rect_bbox(area); + let (Some(is), Some(js)) = (cells(pa.x0, pa.x1, b.x0, b.x1, xs), cells(pa.y0, pa.y1, b.y0, b.y1, ys)) else { return }; + let count = (is.1 - is.0 + 1) as u128 * (js.1 - js.0 + 1) as u128; + + let mut reader = Reader::new(&mut *self.shared, false, Rect::new(-1e9, -1e9, 1e9, 1e9)); + reader.overprint = self.overprint; + t.interpret(&mut reader, m, stroke); + let cell = reader.finish(); + if cell.is_empty() { + return; + } + let look = self.look(1.0); + let offset = |i: i64, j: i64| m * Point::new(i as f64 * xs, j as f64 * ys) - m * Point::ORIGIN; + let items = count.saturating_mul(item_count(&cell) as u128); + let child = if count <= MAX_TILING_CELLS as u128 && items <= MAX_TILING_ITEMS as u128 { + let mut children = Vec::with_capacity(count as usize * cell.len()); + for j in js.0..=js.1 { + for i in is.0..=is.1 { + let mut copy = cell.clone(); + shade::translate(&mut copy, offset(i, j)); + children.extend(copy); + } + } + self.shared.report.tiling_cells += count as usize; + children + } else { + // Every pixel samples each cell over it: one, or more where cells are larger than + // their step and overlap. Cells stacked deeper than MAX_TILING_CELLS paint nothing, + // like fills past the range of `cells`. + let per_pixel = ((b.width().abs() / xs.abs()).floor() + 1.0) * ((b.height().abs() / ys.abs()).floor() + 1.0); + if !per_pixel.is_finite() || per_pixel > MAX_TILING_CELLS as f64 { + self.shared.report.dropped_tilings += 1; + return; + } + let inks = &self.shared.inks; + let cell_box = m.transform_rect_bbox(b); + let (scale, cell_scale) = tiling_scales(self.shared.scale, area, cell_box, per_pixel, inks.len()); + let (Some(mut drawn), Some(mut canvas)) = (Canvas::new(cell_box, cell_scale, inks), Canvas::new(area, scale, inks)) else { + self.shared.report.dropped_tilings += 1; + return; + }; + drawn.draw(&cell); + let (w, h) = canvas.size(); + for y in 0..h { + for x in 0..w { + let p = canvas.center(x, y); + let q = inv * p; + let (Some(ci), Some(cj)) = (cells(q.x, q.x, b.x0, b.x1, xs), cells(q.y, q.y, b.y0, b.y1, ys)) else { continue }; + for j in cj.0..=cj.1 { + for i in ci.0..=ci.1 { + if let Some((value, a)) = drawn.sample(p - offset(i, j)) + && a > 0.0 + { + let value = value.to_vec(); + canvas.over(x, y, &value, a); + } + } + } + } + } + let image = canvas.into_image(Overprint { fill: flag, stroke: flag, nonzero: false }); + self.shared.report.rasterized_tilings += 1; + vec![Item::Image(image)] + }; + self.push(Item::Group(GroupItem { children: child, ..plain_group(Some(clip)) }), &look); + } + + fn raster_image(&mut self, image: &RasterImage<'_>, transform: Affine) { + let mut decoded = None; + image.with_inks(self.shared.simulation.as_ref(), |im| decoded = Some(im)); + let Some(im) = decoded else { + self.shared.report.unreadable_images += 1; + return; + }; + let Some((inks, channels)) = self.space_inks(&im.space, &im.color_space) else { return }; + self.shared.report.images += 1; + if matches!(im.space, InkSpace::Cmyk { converted: true }) { + self.shared.report.converted_images += 1; + } + let (w, h) = (im.width as usize, im.height as usize); + let samples = channels.apply(im.samples, im.space.len()); + let smask = im.alpha.map(|a| { + if (a.width as usize, a.height as usize) == (w, h) { + a.data + } else { + self.shared.report.resampled_masks += 1; + shade::resample(&a.data, (a.width as usize, a.height as usize), (w, h)) + } + }); + let smask = smask.filter(|a| !a.iter().all(|v| *v == 255)).map(Arc::from); + let (sx, sy) = im.scale_factors; + let transform = transform * Affine::scale_non_uniform(w as f64 * f64::from(sx), h as f64 * f64::from(sy)); + let (op, _, _) = self.overprint; + let overprint = Overprint { fill: op, stroke: op, nonzero: false }; + self.flush(); + let device = matches!(im.space, InkSpace::Gray | InkSpace::Cmyk { converted: false }); + let jpeg = if device && smask.is_none() { dct_passthrough(image.stream(), im.width, im.height) } else { None }; + self.add(Item::Image(ImageItem { + space: inks.image_space(), + size: (im.width, im.height), + samples: samples.into(), + transform, + smask, + overprint, + jpeg, + })); + } + + /// A stencil mask: a fill traced from the mask, or past [`MAX_STENCIL_RECTS`] an image of + /// the paint with the mask as its alpha. + fn stencil(&mut self, image: &StencilImage<'a, '_>, transform: Affine) { + let mut read = None; + image.with_stencil(|luma, paint| read = Some((luma, paint.clone())), None); + let Some((luma, paint)) = read else { + self.shared.report.unreadable_images += 1; + return; + }; + self.shared.report.stencils += 1; + let (w, h) = (luma.width as usize, luma.height as usize); + let (sx, sy) = luma.scale_factors; + let to_page = transform * Affine::scale_non_uniform(f64::from(sx), f64::from(sy)); + if let Some(rects) = shade::trace_stencil(&luma.data, w, h, MAX_STENCIL_RECTS) { + let mut path = BezPath::new(); + for r in rects { + path.extend(shade::rect_path(r, to_page).iter()); + } + if !path.elements().is_empty() { + self.fill(path, FillRule::NonZero, &paint, false); + } + return; + } + let unit = to_page * Affine::scale_non_uniform(w as f64, h as f64); + let size = (luma.width, luma.height); + match self.paint(&paint, false) { + None => {} + Some(Painted::Flat(Paint::Solid(color), look, overprint)) => { + let (space, value): (ImageSpace, Vec) = match &color { + InkColor::Tints(tints) => (ImageSpace::Inks(tints.iter().map(|t| t.0).collect()), tints.iter().map(|t| to_byte(t.1)).collect()), + InkColor::All(t) => { + (ImageSpace::Inks(self.shared.inks.iter().map(|(id, _)| id).collect()), vec![to_byte(*t); self.shared.inks.len()]) + } + InkColor::None => return, + }; + let samples: Vec = value.iter().copied().cycle().take(value.len() * w * h).collect(); + self.flush(); + self.push( + Item::Image(ImageItem { + space, + size, + samples: samples.into(), + transform: unit, + smask: Some(luma.data.into()), + overprint, + jpeg: None, + }), + &look, + ); + } + Some(_) => { + // A pattern paint: the paint over the image's square, masked by the stencil. + let shape = ImageItem { + space: ImageSpace::Gray, + size, + samples: vec![0; w * h].into(), + transform: unit, + smask: Some(luma.data.into()), + overprint: Overprint::default(), + jpeg: None, + }; + let group = GroupItem { isolated: true, children: vec![Item::Image(shape)], ..plain_group(None) }; + let mask = Mask { kind: MaskKind::Alpha, group, backdrop: None, transfer: None }; + self.flush(); + self.frames.push(Frame { kind: FrameKind::Group, group: GroupItem { mask: Some(Box::new(mask)), ..plain_group(None) } }); + self.fill(shade::rect_path(Rect::new(0.0, 0.0, 1.0, 1.0), unit), FillRule::NonZero, &paint, false); + self.close(FrameKind::Group); + } + } + } + + fn outline_glyph(&mut self, glyph: &OutlineGlyph, matrix: Affine, pdf_paint: &PdfPaint<'a>) { + let (paint, look, overprint) = match self.paint(pdf_paint, false) { + Some(Painted::Flat(paint, look, overprint)) => (paint, look, overprint), + Some(Painted::Area) => return self.area_fill(matrix * glyph.outline(), FillRule::NonZero, pdf_paint, false), + None => return, + }; + let key = self.shared.fonts.note(glyph); + let unicode = glyph.as_unicode().map(unicode); + self.add_glyph(Some(key), glyph.glyph_id().to_u32(), matrix, matrix * glyph.outline(), unicode, paint, look, overprint); + } + + #[allow(clippy::too_many_arguments)] + fn add_glyph( + &mut self, + font: Option, + id: u32, + matrix: Affine, + outline: BezPath, + unicode: Option, + paint: Paint, + look: Look<'a>, + overprint: Overprint, + ) { + let linear = matrix * Affine::scale(1000.0); + let continues = self.run.as_ref().is_some_and(|run| { + run.font == font && same_linear(run.linear, linear) && run.item.paint == paint && run.item.overprint == overprint && run.look.same(&look) + }); + if !continues { + self.flush(); + let size = linear.determinant().abs().sqrt(); + if !size.is_finite() || size <= f64::EPSILON { + return; + } + let [a, b, c, d, ..] = linear.as_coeffs(); + let origin = matrix.translation(); + let transform = Affine::new([a / size, b / size, c / size, d / size, origin.x, origin.y]); + // A placeholder font carrying the key; `set_fonts` replaces it. + let placeholder = font.map(|key| Arc::new(TextFont { data: Arc::new(Vec::::new()), index: 0, key })); + let item = TextItem { + font: placeholder, + size: size as f32, + transform, + glyphs: Vec::new(), + text: Some(String::new()), + outlines: BezPath::new(), + paint, + overprint, + }; + self.run = Some(Run { font, linear, look, item }); + } + let Some(run) = self.run.as_mut() else { return }; + let item = &mut run.item; + let text = item.text.get_or_insert_with(String::new); + let start = text.len(); + text.push_str(unicode.as_deref().unwrap_or("")); + let origin = item.transform.inverse() * matrix.translation().to_point(); + item.glyphs.push(PlacedGlyph { id, origin, text: start..text.len() }); + item.outlines.extend(outline.iter()); + } + + fn type3_glyph(&mut self, glyph: &craft_pdf::interpret::font::Type3Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &PdfPaint<'a>) { + let (run_paint, look, overprint) = match self.paint(paint, false) { + Some(Painted::Flat(paint, look, overprint)) => (Some(paint), look, overprint), + Some(Painted::Area) => (None, self.look(1.0), Overprint::default()), + None => return, + }; + let mut reader = Reader::new(&mut *self.shared, true, self.bounds); + reader.overprint = self.overprint; + glyph.interpret(&mut reader, transform, glyph_transform, paint); + let items = reader.finish(); + let unicode = glyph.as_unicode().map(unicode); + // A glyph that only fills in the text's paint (`d1`, or `d0` painting that color) is a + // shape: it joins the run. + let shape = items.iter().all(|item| matches!(item, Item::Fill(f) if Some(&f.paint) == run_paint.as_ref())); + if let (true, Some(run_paint)) = (shape, run_paint) { + let mut outline = BezPath::new(); + for item in &items { + if let Item::Fill(f) = item { + outline.extend(nonzero(&f.path, f.rule).iter()); + } + } + self.add_glyph(None, 0, transform * glyph_transform, outline, unicode, run_paint, look, overprint); + } else if !items.is_empty() { + self.flush(); + let group = GroupItem { actual_text: unicode, children: items, ..plain_group(None) }; + self.push(Item::Group(group), &look); + } + } +} + +impl<'a> Device<'a> for Reader<'_, 'a> { + fn set_soft_mask(&mut self, mask: Option>) { + self.soft_mask = mask; + } + + fn set_blend_mode(&mut self, blend_mode: PdfBlend) { + self.blend = blend(blend_mode); + } + + fn set_overprint(&mut self, fill: bool, stroke: bool, mode: u8) { + self.overprint = (fill, stroke, mode); + } + + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &PdfPaint<'a>, draw_mode: &PathDrawMode) { + match draw_mode { + PathDrawMode::Fill(rule) => self.fill(transform * path.clone(), fill_rule(*rule), paint, false), + PathDrawMode::Stroke(props) => self.fill(outline_stroke(path, transform, props), FillRule::NonZero, paint, true), + } + } + + fn push_clip_path(&mut self, clip_path: &ClipPath) { + self.flush(); + let clip = nonzero(&clip_path.path, fill_rule(clip_path.fill)); + self.frames.push(Frame { kind: FrameKind::Clip, group: plain_group(Some(clip)) }); + } + + fn push_transparency_group(&mut self, opacity: f32, mask: Option>, blend_mode: PdfBlend) { + self.flush(); + let mask = if self.glyph { None } else { mask.and_then(|m| self.mask(&m)) }; + let (opacity, blend_mode) = if self.glyph { (1.0, BlendMode::Normal) } else { (opacity, blend(blend_mode)) }; + let group = GroupItem { opacity, blend: blend_mode, mask: mask.map(Box::new), ..plain_group(None) }; + self.frames.push(Frame { kind: FrameKind::Group, group }); + } + + fn set_knockout(&mut self) { + if let Some(frame) = self.frames.last_mut().filter(|f| f.kind == FrameKind::Group) { + frame.group.knockout = true; + } + } + + fn set_isolated(&mut self) { + if let Some(frame) = self.frames.last_mut().filter(|f| f.kind == FrameKind::Group) { + frame.group.isolated = true; + } + } + + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &PdfPaint<'a>, draw_mode: &GlyphDrawMode) { + match (glyph, draw_mode) { + (_, GlyphDrawMode::Invisible) => self.shared.report.invisible_glyphs += 1, + // Clip-only text: the interpreter installs its clip, and the glyph paints nothing. + (_, GlyphDrawMode::Clip) => {} + (Glyph::Outline(o), GlyphDrawMode::Fill) => self.outline_glyph(o, transform * glyph_transform, paint), + (Glyph::Outline(o), GlyphDrawMode::Stroke(props)) => { + self.fill(outline_stroke(&(glyph_transform * o.outline()), transform, props), FillRule::NonZero, paint, true); + } + (Glyph::Type3(t), _) => self.type3_glyph(t, transform, glyph_transform, paint), + } + } + + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + match image { + Image::Raster(r) => self.raster_image(&r, transform), + Image::Stencil(s) => self.stencil(&s, transform), + } + } + + fn pop_clip_path(&mut self) { + self.close(FrameKind::Clip); + } + + fn pop_transparency_group(&mut self) { + self.close(FrameKind::Group); + } +} + +/// The inks of samples or stops. +enum Inks { + /// A gray level. + Gray, + /// The four process inks. + Cmyk(Vec), + List(Vec), +} + +impl Inks { + fn stop_inks(self) -> StopInks { + match self { + Self::Gray => StopInks::Gray, + Self::Cmyk(ids) | Self::List(ids) => StopInks::Inks(ids), + } + } + + fn image_space(self) -> ImageSpace { + match self { + Self::Gray => ImageSpace::Gray, + Self::Cmyk(_) => ImageSpace::Cmyk, + Self::List(ids) => ImageSpace::Inks(ids), + } + } +} + +/// Which of a space's values become samples. +enum Channels { + All, + /// Values of inks the set has room for. + Keep(Vec), + /// One value (the `All` colorant) for each of this many inks. + Spread(usize), +} + +impl Channels { + fn apply(&self, samples: Vec, width: usize) -> Vec { + match self { + Self::All => samples, + Self::Keep(keep) => samples.chunks_exact(width.max(1)).flat_map(|s| s.iter().zip(keep).filter(|(_, k)| **k).map(|(v, _)| *v)).collect(), + Self::Spread(n) => samples.iter().flat_map(|v| std::iter::repeat_n(*v, *n)).collect(), + } + } + + fn apply_f32(&self, values: &[f32]) -> Vec { + match self { + Self::All => values.to_vec(), + Self::Spread(n) => values.iter().flat_map(|v| std::iter::repeat_n(*v, *n)).collect(), + Self::Keep(keep) => values.iter().zip(keep).filter(|(_, k)| **k).map(|(v, _)| *v).collect(), + } + } +} + +/// The JPEG data of a `width` × `height` image that krilla's JPEG passthrough writes with the +/// same meaning: DCTDecode alone without parameters, 8 bits, DeviceGray with the default +/// `/Decode`, or DeviceCMYK with the inverted `/Decode` of Adobe CMYK JPEGs, which krilla +/// always writes for CMYK. +fn dct_passthrough(stream: &Stream<'_>, width: u32, height: u32) -> Option> { + let dict = stream.dict(); + let only_dct = stream.filters().as_slice() == [Filter::DctDecode] && !dict.contains_key(DECODE_PARMS); + let size = dict.get::(WIDTH) == Some(width) && dict.get::(HEIGHT) == Some(height); + if !(only_dct && size && dict.get::(BITS_PER_COMPONENT) == Some(8)) { + return None; + } + let decode: Option> = dict.get::>(DECODE).map(|a| a.iter::().collect()); + let ok = match dict.get::>(COLORSPACE)?.as_ref() { + b"DeviceGray" => decode.is_none_or(|d| d == [0.0, 1.0]), + b"DeviceCMYK" => decode.is_some_and(|d| d == [1.0, 0.0].repeat(4)), + _ => false, + }; + ok.then(|| Arc::from(stream.raw_data().as_ref())) +} + +/// Pixels per point of the image a tiling pattern over `area` is read as, from `scale`, and of +/// the drawing of its cell over `cell_box`, for `per_pixel` cells over each pixel and `inks` +/// values per pixel. +fn tiling_scales(scale: f64, area: Rect, cell_box: Rect, per_pixel: f64, inks: usize) -> (f64, f64) { + let ink_share = (CANVAS_INKS / inks.max(1) as f64).min(1.0); + let scale = limit_scale(scale, area, MAX_RASTER_PIXELS.min(MAX_TILING_SAMPLES / per_pixel) * ink_share); + (scale, limit_scale(scale * CELL_OVERSAMPLE, cell_box, MAX_CELL_PIXELS * ink_share)) +} + +/// `scale` (pixels per point), lowered so that `area` holds at most `pixels` pixels. +fn limit_scale(scale: f64, area: Rect, pixels: f64) -> f64 { + let have = area.area() * scale * scale; + if have > pixels { scale * (pixels / have).sqrt() } else { scale } +} + +/// `items` and every item inside their groups and masks. +fn item_count(items: &[Item]) -> usize { + items.iter().fold(0, |sum, item| { + let inner = match item { + Item::Group(g) => item_count(&g.children).saturating_add(g.mask.as_ref().map_or(0, |m| item_count(&m.group.children))), + Item::Fill(_) | Item::Text(_) | Item::Image(_) => 0, + }; + sum.saturating_add(1).saturating_add(inner) + }) +} + +/// The cell indices `k` whose span `[c0, c1] + k * step` meets `[lo, hi]`. +fn cells(lo: f64, hi: f64, c0: f64, c1: f64, step: f64) -> Option<(i64, i64)> { + let (a, z) = ((lo - c1) / step, (hi - c0) / step); + let (a, z) = if step > 0.0 { (a, z) } else { (z, a) }; + let (a, z) = (a.ceil(), z.floor()); + (a.is_finite() && z.is_finite() && a <= z && z - a < 1e9).then_some((a as i64, z as i64)) +} + +fn to_byte(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +/// Fills each text item's font from the prepared fonts, replacing the key placeholders. +fn set_fonts(items: &mut [Item], fonts: &HashMap>>) { + for item in items { + match item { + Item::Text(t) => { + t.font = t.font.as_ref().and_then(|f| fonts.get(&f.key).cloned().flatten()); + } + Item::Group(g) => set_group_fonts(g, fonts), + Item::Fill(_) | Item::Image(_) => {} + } + } +} + +fn set_group_fonts(group: &mut GroupItem, fonts: &HashMap>>) { + if let Some(mask) = group.mask.as_mut() { + set_group_fonts(&mut mask.group, fonts); + } + set_fonts(&mut group.children, fonts); +} + +fn unicode(s: BfString) -> String { + match s { + BfString::Char(c) => c.to_string(), + BfString::String(s) => s, + } +} + +fn same_linear(a: Affine, b: Affine) -> bool { + let (a, b) = (a.as_coeffs(), b.as_coeffs()); + let scale = a[..4].iter().fold(0.0_f64, |m, v| m.max(v.abs())); + a[..4].iter().zip(&b[..4]).all(|(x, y)| (x - y).abs() <= scale * 1e-6) +} + +fn fill_rule(rule: PdfFillRule) -> FillRule { + match rule { + PdfFillRule::NonZero => FillRule::NonZero, + PdfFillRule::EvenOdd => FillRule::EvenOdd, + } +} + +fn blend(mode: PdfBlend) -> BlendMode { + match mode { + PdfBlend::Normal => BlendMode::Normal, + PdfBlend::Multiply => BlendMode::Multiply, + PdfBlend::Screen => BlendMode::Screen, + PdfBlend::Overlay => BlendMode::Overlay, + PdfBlend::Darken => BlendMode::Darken, + PdfBlend::Lighten => BlendMode::Lighten, + PdfBlend::ColorDodge => BlendMode::ColorDodge, + PdfBlend::ColorBurn => BlendMode::ColorBurn, + PdfBlend::HardLight => BlendMode::HardLight, + PdfBlend::SoftLight => BlendMode::SoftLight, + PdfBlend::Difference => BlendMode::Difference, + PdfBlend::Exclusion => BlendMode::Exclusion, + PdfBlend::Hue => BlendMode::Hue, + PdfBlend::Saturation => BlendMode::Saturation, + PdfBlend::Color => BlendMode::Color, + PdfBlend::Luminosity => BlendMode::Luminosity, + } +} + +/// `path` (user space) stroked with `props` and taken to page space by `transform`. The stroke +/// is outlined in user space, where its width is given, so a skewed CTM skews it too. +fn outline_stroke(path: &BezPath, transform: Affine, props: &StrokeProps) -> BezPath { + let scale = transform.determinant().abs().sqrt(); + let scale = if scale.is_finite() && scale > f64::EPSILON { scale } else { 1.0 }; + let width = match f64::from(props.line_width) { + w if w > 0.0 && w.is_finite() => w, + _ => HAIRLINE / scale, + }; + let mut style = Stroke::new(width).with_caps(props.line_cap).with_join(props.line_join).with_miter_limit(f64::from(props.miter_limit)); + if let Some(dashes) = dash_pattern(path, &props.dash_array) { + style = style.with_dashes(f64::from(props.dash_offset), dashes); + } + let outline = kurbo::stroke(path.iter(), &style, &StrokeOpts::default(), STROKE_TOLERANCE / scale); + transform * outline +} + +/// The dash pattern to outline `path` with; `None` (solid) when the pattern is invalid or could +/// cut the path into more than [`MAX_DASHES_PER_STROKE`] pieces. The same estimate as hayro's. +fn dash_pattern(path: &BezPath, dashes: &[f32]) -> Option> { + if dashes.iter().any(|d| !d.is_finite() || *d < 0.0) { + return None; + } + let sum: f64 = dashes.iter().map(|d| f64::from(*d)).sum(); + // kurbo repeats an odd-length pattern once, so a period covers it twice. + let (period, per_period) = if dashes.len() % 2 == 1 { (2.0 * sum, dashes.len().saturating_mul(2)) } else { (sum, dashes.len()) }; + if !(period.is_finite() && period > 0.0) { + return None; + } + // A curve is no longer than its control polygon. kurbo restarts the pattern at every + // subpath and close, so each restart can begin a whole period however short its segment. + let (mut length, mut restarts) = (0.0, 0usize); + let (mut start, mut last) = (Point::ORIGIN, Point::ORIGIN); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => { + (start, last) = (p, p); + restarts = restarts.saturating_add(1); + } + PathEl::LineTo(p) => { + length += last.distance(p); + last = p; + } + PathEl::QuadTo(c, p) => { + length += last.distance(c) + c.distance(p); + last = p; + } + PathEl::CurveTo(c1, c2, p) => { + length += last.distance(c1) + c1.distance(c2) + c2.distance(p); + last = p; + } + PathEl::ClosePath => { + length += last.distance(start); + last = start; + restarts = restarts.saturating_add(1); + } + } + } + let pieces = (length / period + restarts as f64) * per_period as f64; + (pieces.is_finite() && pieces <= MAX_DASHES_PER_STROKE).then(|| dashes.iter().map(|d| f64::from(*d)).collect()) +} + +/// `path` filled by `rule`, as a path filled nonzero. An even-odd path is rebuilt from its +/// boundary; if that fails it is kept as it is. +fn nonzero(path: &BezPath, rule: FillRule) -> BezPath { + if rule == FillRule::NonZero { + return path.clone(); + } + let closed = closed(path); + match linesweeper::binary_op(&closed, &BezPath::new(), linesweeper::FillRule::EvenOdd, linesweeper::BinaryOp::Union) { + Ok(contours) => { + let mut out = BezPath::new(); + for contour in contours.contours() { + out.extend(contour.path.iter()); + } + out + } + Err(_) => closed, + } +} + +/// `path` with every subpath closed, as a fill closes them. +fn closed(path: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let mut open = false; + for el in path.elements() { + match el { + PathEl::MoveTo(_) => { + if open { + out.close_path(); + } + open = true; + } + PathEl::ClosePath => open = false, + _ => {} + } + out.push(*el); + } + if open { + out.close_path(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiling_canvases_hold_cmyk_memory_with_many_inks() { + let (page, cell) = (Rect::new(0.0, 0.0, 600.0, 800.0), Rect::new(0.0, 0.0, 300.0, 300.0)); + let dpi = 300.0 / 72.0; + let values = |area: Rect, scale: f64, inks: usize| { + let (w, h) = shade::pixel_size(area, scale).unwrap(); + (w * h * inks) as f64 + }; + // With 1,000 inks the page canvas would hold 8 billion values. + let (scale, cell_scale) = tiling_scales(dpi, page, cell, 1.0, 1000); + assert!(values(page, scale, 1000) <= MAX_RASTER_PIXELS * CANVAS_INKS * 1.01); + assert!(values(cell, cell_scale, 1000) <= MAX_CELL_PIXELS * CANVAS_INKS * 1.01); + // A CMYK page keeps its resolution. + assert_eq!(tiling_scales(dpi, page, cell, 1.0, 4).0, dpi); + } +} diff --git a/crates/prepress/src/flatten/raster.rs b/crates/prepress/src/flatten/raster.rs new file mode 100644 index 00000000..92c55162 --- /dev/null +++ b/crates/prepress/src/flatten/raster.rs @@ -0,0 +1,393 @@ +//! Complex regions rasterized as ink images (plan tasks C3 and C4), and [`flatten_page`], which +//! flattens one page of a PDF. +//! +//! A region's image is its items written alone as a small PDF ([`page_pdf`]) and separated with +//! [`plates`], overprint on: one plane per ink, so spot inks stay spot inks and overprint applies +//! per plate as on press. The planes map to the list's inks by name and come back as an +//! [`ImageSpace::Inks`] image, which the writer clips to the region and writes as DeviceN. +//! +//! Backdrop: the sub-list is [`ComplexRegion::items`], in paint order, and nothing else. Those +//! are the owners of every shape covering the region, and a flattened group holds every item +//! whose box touches one of its members ([`geom::overlap_groups`](super::geom::overlap_groups)), +//! up to its last transparent item. Kept items are either in other groups, so their boxes are +//! clear of the region, or painted after it. So no kept item is under a complex region, and the +//! items listed are the region's whole backdrop. +//! +//! Seams: +//! - The image is snapped outward to the device pixel grid at the raster resolution (counted +//! from the page box's corner) and reaches one more device pixel on every side. The clip stays +//! the exact region path, so the image's edge pixels are never seen: no paper shows where the +//! clip edge falls between pixel centers, and anti-aliased edges of the image don't exist. +//! - Items render unclipped in the sub-list, so pixels along the region edge hold the full color +//! of the art on both sides of the edge, not a mix with paper. +//! - Neighboring complex regions are merged before rasterizing (`compose`), so their shared +//! edges fall inside one image. +//! - Regions larger than a plate render in tiles that abut on the device grid and are joined +//! into one image before it is written. +//! +//! Whole groups the region sweep failed on +//! ([`ComplexReason::Degenerate`](super::ComplexReason::Degenerate)) are clipped to their +//! members' boxes, which hold pixels nothing paints. Their image is opaque like any other: the +//! boxes are clear of every other group's items, so the paper is the only backdrop there, and +//! the plates of the items over paper print the same as the items would. A soft mask would leave +//! transparency on the page, which PDF/X-1a forbids. +//! +//! Ported from VectorCraft (MIT OR Apache-2.0) at commit c2b15982: `render_raster` in +//! `crates/engine/src/cmd/flatten.rs` (the image reaching a pixel past its area). VectorCraft +//! renders RGB over white; here the plates give each ink's tint directly. + +use craft_pdf::OpenError; +use craft_pdf::interpret::color::CmykConversion; +use craft_pdf::write::backend::SerializeSettings; +use kurbo::{Affine, Rect, Shape as _}; + +use super::document::{FlattenDocumentError, FlattenDocumentOptions, flatten_document}; +use super::pdf::{ReadError, ReadOptions, ReadReport}; +use super::write::{WriteError, page_pdf}; +use super::{ + BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenError, FlattenOptions, FlattenReport, GroupItem, ImageItem, ImageSpace, Item, + Mask, MaskKind, Paint, +}; +use crate::ink::{Ink, InkColor, InkId, Overprint, Process}; +use crate::plates::{Fallback, MAX_SPOT_INKS, Options, plates}; + +/// Pixels per complex region's image. A larger region rasterizes at a lower resolution; +/// [`RasterReport::lowest_dpi`] says how low. +pub const MAX_RASTER_PIXELS: u64 = 64 << 20; + +/// Tile side in device pixels: tiles stay below [`crate::MAX_PLATE_PIXELS`], so `plates` renders +/// them at the resolution asked for. +const TILE: i64 = 1536; + +/// What [`Rasterizer`] made. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct RasterReport { + /// Images returned. + pub images: usize, + /// Device pixels of all images. + pub pixels: u64, + /// The lowest resolution used, when a region past [`MAX_RASTER_PIXELS`] needed less than + /// the one asked for. + pub lowest_dpi: Option, +} + +/// Rasterizes the complex regions of a flattened [`DisplayList`]; [`Self::region`] is the +/// callback [`write_flattened`](super::write::write_flattened) takes. +pub struct Rasterizer<'a> { + list: &'a DisplayList, + settings: SerializeSettings, + printing: Fallback, + cms: &'a dyn CmykConversion, + dpi: f32, + report: RasterReport, +} + +impl<'a> Rasterizer<'a> { + /// `settings` and `cms` are the output's, and `printing` is its printing condition: the + /// sub-lists are written without the output intent, which `plates` would decode and parse + /// for every region, and separated with `printing` as the fallback. + pub fn new(list: &'a DisplayList, settings: SerializeSettings, printing: Fallback, cms: &'a dyn CmykConversion, dpi: f32) -> Self { + let settings = SerializeSettings { output_intent: None, ..settings }; + Self { list, settings, printing, cms, dpi, report: RasterReport::default() } + } + + pub fn report(&self) -> &RasterReport { + &self.report + } + + /// The image of `region`, or `None` when it prints no ink (nothing is under a complex + /// region to knock out). + pub fn region(&mut self, region: &ComplexRegion) -> Result, WriteError> { + let page = self.list.page; + let bounds = region.path.bounding_box(); + if !(bounds.is_finite() && page.is_finite() && self.dpi.is_finite() && self.dpi > 0.0) { + return Ok(None); + } + let mut scale = f64::from(self.dpi) / 72.0; + let mut grid = Grid::new(bounds, page, scale); + let pixels = grid.pixels(); + if pixels > MAX_RASTER_PIXELS { + scale *= (MAX_RASTER_PIXELS as f64 / pixels as f64).sqrt() * 0.999; + grid = Grid::new(bounds, page, scale); + let dpi = (scale * 72.0) as f32; + self.report.lowest_dpi = Some(self.report.lowest_dpi.map_or(dpi, |d| d.min(dpi))); + } + if grid.width() <= 0 || grid.height() <= 0 { + return Ok(None); + } + + let items: Vec = + region.items.iter().filter_map(|&i| self.list.items.get(i)).map(|i| self.alpha_masks(i, &grid)).collect::>()?; + let planes = self.separate(&items, &grid, true)?; + + let mut ids = Vec::new(); + let mut used = Vec::new(); + for ((id, _), plane) in self.list.inks.iter().zip(planes) { + if let Some(plane) = plane.filter(|p| p.iter().any(|v| *v > 0)) { + ids.push(id); + used.push(plane); + } + } + if ids.is_empty() { + return Ok(None); + } + let n = grid.len(); + let mut samples = Vec::with_capacity(n * used.len()); + for px in 0..n { + samples.extend(used.iter().map(|p| p[px])); + } + self.report.images += 1; + self.report.pixels += n as u64; + let r = grid.rect(); + Ok(Some(ImageItem { + space: ImageSpace::Inks(ids), + size: (grid.width() as u32, grid.height() as u32), + samples: samples.into(), + transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]), + smask: None, + overprint: Overprint::default(), + jpeg: None, + })) + } + + /// `item` with its luminosity masks, and those of the groups in it, made alpha masks over + /// `grid` (see [`Self::luminosity`]). + fn alpha_masks(&self, item: &Item, grid: &Grid) -> Result { + let Item::Group(g) = item else { return Ok(item.clone()) }; + let mask = match g.mask.as_deref() { + Some(m) if m.kind == MaskKind::Luminosity => Some(Box::new(self.luminosity(m, grid)?)), + other => other.cloned().map(Box::new), + }; + let children = g.children.iter().map(|c| self.alpha_masks(c, grid)).collect::>()?; + Ok(Item::Group(GroupItem { + opacity: g.opacity, + blend: g.blend, + isolated: g.isolated, + knockout: g.knockout, + clip: g.clip.clone(), + mask, + actual_text: g.actual_text.clone(), + children, + })) + } + + /// A luminosity mask as an alpha mask: an image whose alpha is the mask's luminosity over + /// `grid`, its transfer function applied. + /// + /// The mask's content arrives in inks, and a CMYK blending space has no exact black for the + /// renderer: K 100% (DeviceGray black, read as ink) has a luminosity of about 12% through + /// the printing condition. So the content is separated and its luminosity computed from the + /// inks with the ISO 32000-2 §10.4.2.2 formula, `1 - min(1, 0.3c + 0.59m + 0.11y + k)`, + /// spot inks through their CMYK equivalents. + fn luminosity(&self, m: &Mask, grid: &Grid) -> Result { + let r = grid.rect(); + // Where nothing paints the mask is the backdrop's luminosity, black by default. + let backdrop = m.backdrop.clone().unwrap_or_else(|| InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)])); + let content = [ + Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(backdrop), overprint: Overprint::default() }), + self.alpha_masks(&Item::Group(m.group.clone()), grid)?, + ]; + let planes = self.separate(&content, grid, false)?; + let weights: Vec<[f32; 4]> = self + .list + .inks + .iter() + .map(|(id, _)| match Process::ALL.get(id.index()) { + Some(p) => { + let mut w = [0.0; 4]; + w[p.index()] = 1.0; + w + } + None => self.list.inks.spot_cmyk(id, self.cms).unwrap_or([0.0, 0.0, 0.0, 1.0]), + }) + .collect(); + let used: Vec<(&[u8], [f32; 4])> = planes.iter().zip(&weights).filter_map(|(p, w)| Some((p.as_deref()?, *w))).collect(); + let alpha: Vec = (0..grid.len()) + .map(|px| { + let mut cmyk = [0.0_f32; 4]; + for (plane, w) in &used { + let t = f32::from(plane[px]) / 255.0; + for (v, w) in cmyk.iter_mut().zip(w) { + *v = 1.0 - (1.0 - *v) * (1.0 - t * w); + } + } + let [c, m_, y, k] = cmyk; + let gray = 1.0 - (0.3 * c + 0.59 * m_ + 0.11 * y + k).min(1.0); + let v = (gray * 255.0).round().clamp(0.0, 255.0) as u8; + m.transfer.as_ref().and_then(|t| t.get(usize::from(v)).copied()).unwrap_or(v) + }) + .collect(); + let n = alpha.len(); + let image = ImageItem { + space: ImageSpace::Gray, + size: (grid.width() as u32, grid.height() as u32), + samples: vec![0; n].into(), + transform: Affine::new([r.width(), 0.0, 0.0, r.height(), r.x0, r.y0]), + smask: Some(alpha.into()), + overprint: Overprint::default(), + jpeg: None, + }; + let group = GroupItem { + opacity: 1.0, + blend: BlendMode::Normal, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: None, + children: vec![Item::Image(image)], + }; + Ok(Mask { kind: MaskKind::Alpha, group, backdrop: None, transfer: None }) + } + + /// `items` separated over `grid`, tile by tile: per ink of the list, its plane, `None` for + /// inks the items don't print. + fn separate(&self, items: &[Item], grid: &Grid, overprint: bool) -> Result>>, WriteError> { + let (w, h) = (grid.width() as usize, grid.height() as usize); + let mut planes: Vec>> = vec![None; self.list.inks.len()]; + let dpi = (grid.scale * 72.0) as f32; + // The renderer makes a page at least 1 pt on a side: thinner tiles are rendered at that + // size and cropped. + let min = grid.scale.ceil() as i64; + let mut ty = 0; + while ty < grid.height() { + let th = TILE.min(grid.height() - ty); + let mut tx = 0; + while tx < grid.width() { + let tw = TILE.min(grid.width() - tx); + let (rw, rh) = (tw.max(min), th.max(min)); + // A quarter pixel more than the tile: the renderer floors the page size. + let (x0, y0) = grid.point(grid.x0 + tx, grid.y0 + ty); + let extra = 0.25 / grid.scale; + let tile = Rect::new(x0, y0, x0 + rw as f64 / grid.scale + extra, y0 + rh as f64 / grid.scale + extra); + let sub = DisplayList { page: tile, inks: self.list.inks.clone(), items: items.to_vec() }; + let pdf = page_pdf(&sub, self.settings.clone(), self.cms)?; + let p = plates(&pdf, 0, dpi, &Options { overprint, fallback: self.printing.clone() }) + .map_err(|e| WriteError::Region(format!("separating a complex region: {e}")))?; + if !p.unseparated.is_empty() { + let spots = p.inks.iter().filter(|(_, ink)| matches!(ink, Ink::Spot(_))).count(); + return Err(WriteError::SpotInks { inks: spots + p.unseparated.len(), max: MAX_SPOT_INKS, unseparated: p.unseparated }); + } + if (p.width as i64, p.height as i64) != (rw, rh) { + return Err(WriteError::Region(format!("a tile of {rw} × {rh} px separated at {} × {} px", p.width, p.height))); + } + for (pid, ink) in p.inks.iter() { + let Some(film) = p.plane(pid).filter(|f| f.iter().any(|v| *v > 0)) else { continue }; + let id = self.list.inks.lookup(ink.name()).ok_or_else(|| WriteError::Region(format!("ink {} is not in the list", ink.name())))?; + let Some(plane) = planes.get_mut(id.index()) else { continue }; + let plane = plane.get_or_insert_with(|| vec![0; w * h]); + for (row, src) in film.chunks_exact(rw as usize).take(th as usize).enumerate() { + let at = (ty as usize + row) * w + tx as usize; + if let (Some(dst), Some(src)) = (plane.get_mut(at..at + tw as usize), src.get(..tw as usize)) { + dst.copy_from_slice(src); + } + } + } + tx += tw; + } + ty += th; + } + Ok(planes) + } +} + +/// A region's pixels on the device grid of the page: `x0..x1` and `y0..y1` count device pixels +/// from the page box's top left corner. +struct Grid { + page: Rect, + scale: f64, + x0: i64, + y0: i64, + x1: i64, + y1: i64, +} + +impl Grid { + /// `bounds` snapped outward and grown by one pixel, within the page's pixels. + fn new(bounds: Rect, page: Rect, scale: f64) -> Self { + let px = |v: f64| v.clamp(-1e12, 1e12) as i64; + let (pw, ph) = (px((page.width() * scale).ceil()), px((page.height() * scale).ceil())); + Self { + page, + scale, + x0: px(((bounds.x0 - page.x0) * scale).floor() - 1.0).max(0), + y0: px(((bounds.y0 - page.y0) * scale).floor() - 1.0).max(0), + x1: px(((bounds.x1 - page.x0) * scale).ceil() + 1.0).min(pw), + y1: px(((bounds.y1 - page.y0) * scale).ceil() + 1.0).min(ph), + } + } + + fn width(&self) -> i64 { + self.x1 - self.x0 + } + + fn height(&self) -> i64 { + self.y1 - self.y0 + } + + fn pixels(&self) -> u64 { + (self.width().max(0) as u64).saturating_mul(self.height().max(0) as u64) + } + + fn len(&self) -> usize { + usize::try_from(self.pixels()).unwrap_or(usize::MAX) + } + + /// Page space of device pixel corner (`x`, `y`). + fn point(&self, x: i64, y: i64) -> (f64, f64) { + (self.page.x0 + x as f64 / self.scale, self.page.y0 + y as f64 / self.scale) + } + + fn rect(&self) -> Rect { + let (x0, y0) = self.point(self.x0, self.y0); + let (x1, y1) = self.point(self.x1, self.y1); + Rect::new(x0, y0, x1, y1) + } +} + +/// How [`flatten_page`] reads and flattens. +#[derive(Clone, Debug, Default)] +pub struct FlattenPageOptions { + pub read: ReadOptions, + pub flatten: FlattenOptions, +} + +/// What [`flatten_page`] did. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct FlattenPageReport { + pub read: ReadReport, + pub flatten: FlattenReport, + pub raster: RasterReport, +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum FlattenPageError { + #[error(transparent)] + Open(#[from] OpenError), + #[error(transparent)] + Read(#[from] ReadError), + #[error(transparent)] + Flatten(#[from] FlattenError), + #[error(transparent)] + Write(#[from] WriteError), +} + +/// Page `page` (from 0) of `pdf` with its transparency flattened, as a one-page PDF: the +/// one-page case of [`flatten_document`]. +pub fn flatten_page(pdf: &[u8], page: usize, opts: &FlattenPageOptions) -> Result<(Vec, FlattenPageReport), FlattenPageError> { + let doc = + FlattenDocumentOptions { read: opts.read.clone(), flatten: opts.flatten, pages: Some(page..page.saturating_add(1)), ..Default::default() }; + let (bytes, mut report) = flatten_document(pdf, &doc).map_err(|e| match e { + FlattenDocumentError::Open(e) => FlattenPageError::Open(e), + FlattenDocumentError::PageRange { .. } => FlattenPageError::Read(ReadError::NoPage(page)), + FlattenDocumentError::Read { source, .. } => FlattenPageError::Read(source), + FlattenDocumentError::Flatten { source, .. } => FlattenPageError::Flatten(source), + FlattenDocumentError::Write { source, .. } => FlattenPageError::Write(source), + FlattenDocumentError::Serialize(e) => FlattenPageError::Write(WriteError::Pdf(e)), + })?; + let (_, report) = report.pages.pop().ok_or(FlattenPageError::Read(ReadError::Interpret))?; + Ok((bytes, report)) +} diff --git a/crates/prepress/src/flatten/shade.rs b/crates/prepress/src/flatten/shade.rs new file mode 100644 index 00000000..08243afa --- /dev/null +++ b/crates/prepress/src/flatten/shade.rs @@ -0,0 +1,828 @@ +//! Raster work for the PDF-in adapter, all in ink values: gradient stops sampled from a shading +//! function, shadings rasterized, tiling patterns past the cell cap drawn, stencil masks traced. + +use std::collections::HashMap; + +use craft_pdf::interpret::color::{CmykSimulation, ColorSpace}; +use craft_pdf::interpret::pattern::ShadingPattern; +use craft_pdf::interpret::shading::{ShadingFunction, ShadingType, Triangle}; +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Vec2}; + +use super::{FillRule, GradientGeometry, ImageItem, ImageSpace, Item, Paint}; +use crate::ink::{InkColor, InkId, InkSet, Overprint, Process}; + +/// Most a gradient's piecewise-linear stops may differ from the function, per ink. +const STOP_TOLERANCE: f32 = 0.5 / 255.0; + +/// The narrowest interval between gradient stops; a discontinuous function gets a pair of stops +/// this close. +const MIN_STOP_STEP: f32 = 1.0 / 4096.0; + +/// Sub-rows per pixel row when the canvas computes path coverage. +const SUBROWS: usize = 4; + +/// Stops `(offset, values)` approximating `eval` on `0..=1` within [`STOP_TOLERANCE`]. `eval` +/// returns the same number of values everywhere; a failed evaluation reads as zeros. +pub(super) fn sample_stops(len: usize, eval: impl Fn(f32) -> Option>) -> Vec<(f32, Vec)> { + let f = |s: f32| { + let mut v = eval(s).unwrap_or_default(); + v.resize(len, 0.0); + v + }; + const START: usize = 8; + let mut stops = vec![(0.0, f(0.0))]; + for k in 0..START { + let (a, b) = (k as f32 / START as f32, (k + 1) as f32 / START as f32); + let fa = stops.last().map(|s| s.1.clone()).unwrap_or_default(); + refine(a, &fa, b, f(b), &f, &mut stops); + } + stops +} + +fn refine(a: f32, fa: &[f32], b: f32, fb: Vec, f: &impl Fn(f32) -> Vec, out: &mut Vec<(f32, Vec)>) { + let m = (a + b) / 2.0; + let fm = f(m); + let err = |t: f32, ft: &[f32]| { + let u = (t - a) / (b - a); + fa.iter().zip(&fb).zip(ft).map(|((x, y), v)| (x + (y - x) * u - v).abs()).fold(0.0_f32, f32::max) + }; + let within = err(m, &fm) <= STOP_TOLERANCE + && [0.25, 0.75].iter().all(|q| { + let t = a + (b - a) * q; + err(t, &f(t)) <= STOP_TOLERANCE + }); + if within || b - a <= MIN_STOP_STEP { + out.push((b, fb)); + } else { + refine(a, fa, m, fm.clone(), f, out); + refine(m, &fm, b, fb, f, out); + } +} + +/// `function` at `input`. +pub(super) fn eval(function: &ShadingFunction, input: &[f32]) -> Option> { + function.eval(&input.iter().copied().collect()).map(|v| v.to_vec()) +} + +/// A shading's colors sampled on a pixel grid. +pub(super) struct ShadeRaster { + pub width: u32, + pub height: u32, + /// `ink_space.len()` values per pixel. + pub values: Vec, + /// 255 where the shading (or its background) paints. + pub alpha: Vec, +} + +/// The function-based or mesh shading `sp` over `area` (page space) at `scale` pixels per point, +/// with `/Background` under it when `background`. `None` for other shading types. +pub(super) fn rasterize_shading( + sp: &ShadingPattern, + area: Rect, + scale: f64, + background: bool, + simulation: Option<&CmykSimulation>, +) -> Option { + let (w, h) = pixel_size(area, scale)?; + let cs: &ColorSpace = &sp.shading.color_space; + let n = cs.components().max(1); + let mut comps = vec![0.0_f32; w * h * n]; + let mut covered = vec![false; w * h]; + let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2()); + match sp.shading.shading_type.as_ref() { + ShadingType::FunctionBased { domain, matrix, function } => { + let inv = (to_px * sp.matrix * *matrix).inverse(); + for y in 0..h { + for x in 0..w { + let r = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let (rx, ry) = (r.x as f32, r.y as f32); + if rx < domain[0] || rx > domain[1] || ry < domain[2] || ry > domain[3] { + continue; + } + if let Some(c) = eval(function, &[rx, ry]) { + set(&mut comps, &mut covered, n, y * w + x, &c); + } + } + } + } + ShadingType::TriangleMesh { triangles, function } => { + let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() }; + for t in triangles { + raster.draw(t, to_px * sp.matrix); + } + } + ShadingType::CoonsPatchMesh { patches, function } => { + let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() }; + let mut buffer = Vec::new(); + for patch in patches { + buffer.clear(); + patch.to_triangles(&mut buffer); + for t in &buffer { + raster.draw(t, to_px * sp.matrix); + } + } + } + ShadingType::TensorProductPatchMesh { patches, function } => { + let mut raster = Triangles { w, h, n, comps: &mut comps, covered: &mut covered, function: function.as_ref() }; + let mut buffer = Vec::new(); + for patch in patches { + buffer.clear(); + patch.to_triangles(&mut buffer); + for t in &buffer { + raster.draw(t, to_px * sp.matrix); + } + } + } + ShadingType::RadialAxial { .. } | ShadingType::Dummy => return None, + } + if let Some(bg) = sp.shading.background.as_ref().filter(|_| background) { + for i in 0..w * h { + if !covered[i] { + set(&mut comps, &mut covered, n, i, bg); + } + } + } + // Only covered pixels are converted. + let mut colors = Vec::new(); + for (i, c) in covered.iter().enumerate() { + if *c { + colors.extend_from_slice(&comps[i * n..(i + 1) * n]); + } + } + let mut converted = Vec::new(); + cs.ink_values(&colors, simulation, &mut converted); + let len = cs.ink_space(simulation).len(); + let mut values = vec![0.0_f32; w * h * len]; + let mut src = converted.chunks_exact(len.max(1)); + for (i, c) in covered.iter().enumerate() { + if *c + && len > 0 + && let Some(v) = src.next() + { + values[i * len..(i + 1) * len].copy_from_slice(v); + } + } + let alpha = covered.iter().map(|c| if *c { 255 } else { 0 }).collect(); + Some(ShadeRaster { width: w as u32, height: h as u32, values, alpha }) +} + +fn set(comps: &mut [f32], covered: &mut [bool], n: usize, i: usize, c: &[f32]) { + for (k, v) in comps[i * n..(i + 1) * n].iter_mut().enumerate() { + *v = c.get(k).copied().unwrap_or(0.0); + } + covered[i] = true; +} + +/// The pixel grid covering `area` at `scale`: its size, or `None` when empty. +pub(super) fn pixel_size(area: Rect, scale: f64) -> Option<(usize, usize)> { + let (w, h) = ((area.width() * scale).ceil(), (area.height() * scale).ceil()); + (w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0).then_some((w as usize, h as usize)) +} + +/// Mesh triangles drawn into component buffers, later triangles over earlier ones. +struct Triangles<'a> { + w: usize, + h: usize, + n: usize, + comps: &'a mut [f32], + covered: &'a mut [bool], + function: Option<&'a ShadingFunction>, +} + +impl Triangles<'_> { + fn draw(&mut self, t: &Triangle, to_px: Affine) { + let (a, b, c) = (to_px * t.p0.point, to_px * t.p1.point, to_px * t.p2.point); + let area2 = (b.x - a.x) * (c.y - a.y) - (c.x - a.x) * (b.y - a.y); + if !area2.is_finite() || area2.abs() < 1e-12 { + return; + } + let bbox = Rect::from_points(a, b).union_pt(c); + let x0 = (bbox.x0 - 0.5).ceil().max(0.0) as usize; + let y0 = (bbox.y0 - 0.5).ceil().max(0.0) as usize; + let x1 = ((bbox.x1 - 0.5).floor() + 1.0).clamp(0.0, self.w as f64) as usize; + let y1 = ((bbox.y1 - 0.5).floor() + 1.0).clamp(0.0, self.h as f64) as usize; + let mut color = Vec::with_capacity(self.n); + for y in y0..y1 { + for x in x0..x1 { + let p = Point::new(x as f64 + 0.5, y as f64 + 0.5); + let la = ((b.x - p.x) * (c.y - p.y) - (c.x - p.x) * (b.y - p.y)) / area2; + let lb = ((c.x - p.x) * (a.y - p.y) - (a.x - p.x) * (c.y - p.y)) / area2; + let lc = 1.0 - la - lb; + if la < -1e-9 || lb < -1e-9 || lc < -1e-9 { + continue; + } + color.clear(); + let (ca, cb, cc) = (&t.p0.colors, &t.p1.colors, &t.p2.colors); + for k in 0..ca.len() { + let v = + la * f64::from(ca[k]) + lb * f64::from(cb.get(k).copied().unwrap_or(0.0)) + lc * f64::from(cc.get(k).copied().unwrap_or(0.0)); + color.push(v as f32); + } + let value = match self.function { + Some(f) => match eval(f, &color[..color.len().min(1)]) { + Some(v) => v, + None => continue, + }, + None => color.clone(), + }; + set(self.comps, self.covered, self.n, y * self.w + x, &value); + } + } + } +} + +/// `items` with every coordinate moved by `by`. +pub(super) fn translate(items: &mut [Item], by: Vec2) { + let t = Affine::translate(by); + for item in items { + match item { + Item::Fill(f) => { + f.path.apply_affine(t); + translate_paint(&mut f.paint, t); + } + Item::Text(text) => { + text.transform = t * text.transform; + text.outlines.apply_affine(t); + translate_paint(&mut text.paint, t); + } + Item::Image(im) => im.transform = t * im.transform, + Item::Group(g) => { + if let Some(clip) = g.clip.as_mut() { + clip.apply_affine(t); + } + if let Some(mask) = g.mask.as_mut() { + translate(&mut mask.group.children, by); + if let Some(clip) = mask.group.clip.as_mut() { + clip.apply_affine(t); + } + } + translate(&mut g.children, by); + } + } + } +} + +fn translate_paint(paint: &mut Paint, t: Affine) { + if let Paint::Gradient { geometry, .. } = paint { + match geometry { + GradientGeometry::Axial { transform, .. } | GradientGeometry::Radial { transform, .. } => *transform = t * *transform, + } + } +} + +/// The opaque samples (≥ 128) of a `w` × `h` mask as disjoint rectangles in sample space; +/// `None` past `cap` rectangles. +pub(super) fn trace_stencil(mask: &[u8], w: usize, h: usize, cap: usize) -> Option> { + let mut done = Vec::new(); + let mut open: HashMap<(usize, usize), usize> = HashMap::new(); + for y in 0..=h { + let mut next = HashMap::new(); + if y < h { + let row = mask.get(y * w..(y + 1) * w)?; + let mut x = 0; + while x < w { + if row[x] >= 128 { + let start = x; + while x < w && row[x] >= 128 { + x += 1; + } + let top = open.remove(&(start, x)).unwrap_or(y); + next.insert((start, x), top); + } else { + x += 1; + } + } + } + for ((x0, x1), top) in open.drain() { + done.push(Rect::new(x0 as f64, top as f64, x1 as f64, y as f64)); + if done.len() > cap { + return None; + } + } + open = next; + } + Some(done) +} + +/// `src` (`sw` × `sh`) resampled bilinearly to `dw` × `dh`. +pub(super) fn resample(src: &[u8], (sw, sh): (usize, usize), (dw, dh): (usize, usize)) -> Vec { + let at = |x: usize, y: usize| f64::from(src.get(y.min(sh - 1) * sw + x.min(sw - 1)).copied().unwrap_or(0)); + let mut out = Vec::with_capacity(dw * dh); + for y in 0..dh { + let fy = ((y as f64 + 0.5) * sh as f64 / dh as f64 - 0.5).max(0.0); + let (y0, ty) = (fy.floor() as usize, fy.fract()); + for x in 0..dw { + let fx = ((x as f64 + 0.5) * sw as f64 / dw as f64 - 0.5).max(0.0); + let (x0, tx) = (fx.floor() as usize, fx.fract()); + let top = at(x0, y0) * (1.0 - tx) + at(x0 + 1, y0) * tx; + let bottom = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx; + out.push((top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8); + } + } + out +} + +/// A raster of every ink in an [`InkSet`] with alpha (values premultiplied), for content drawn +/// as an image. +/// Transparency groups draw as plain groups: their clip and opacity apply; blend modes and soft +/// masks are left out. +pub(super) struct Canvas<'a> { + w: usize, + h: usize, + inks: &'a InkSet, + n: usize, + /// Page space to pixels. + to_px: Affine, + values: Vec, + alpha: Vec, +} + +impl<'a> Canvas<'a> { + pub fn new(area: Rect, scale: f64, inks: &'a InkSet) -> Option { + let (w, h) = pixel_size(area, scale)?; + let n = inks.len(); + let to_px = Affine::scale(scale) * Affine::translate(-area.origin().to_vec2()); + let pixels = w.checked_mul(h)?; + Some(Self { w, h, inks, n, to_px, values: vec![0.0; pixels.checked_mul(n)?], alpha: vec![0.0; pixels] }) + } + + pub fn size(&self) -> (usize, usize) { + (self.w, self.h) + } + + /// The unit square to page space, as [`ImageItem::transform`]. + pub fn image_transform(&self) -> Affine { + self.to_px.inverse() * Affine::scale_non_uniform(self.w as f64, self.h as f64) + } + + pub fn draw(&mut self, items: &[Item]) { + self.draw_clipped(items, None, 1.0); + } + + fn draw_clipped(&mut self, items: &[Item], clip: Option<&[f32]>, opacity: f32) { + for item in items { + match item { + Item::Fill(f) => self.fill(&f.path, f.rule, &f.paint, f.overprint, clip, opacity), + Item::Text(t) => self.fill(&t.outlines, FillRule::NonZero, &t.paint, t.overprint, clip, opacity), + Item::Image(im) => self.image(im, clip, opacity), + Item::Group(g) => { + let own = g.clip.as_ref().map(|c| { + let mut cov = self.coverage(c, FillRule::NonZero); + if let Some(outer) = clip { + cov.iter_mut().zip(outer).for_each(|(a, b)| *a *= b); + } + cov + }); + self.draw_clipped(&g.children, own.as_deref().or(clip), opacity * g.opacity.clamp(0.0, 1.0)); + } + } + } + } + + /// Dense values and the inks a paint sets, as the compositor reads a flat paint. + fn dense(&self, color: &InkColor, overprint: Overprint) -> Option<(Vec, Vec)> { + let n = self.n; + let (mut value, mut named) = (vec![0.0; n], vec![false; n]); + match color { + InkColor::None => return None, + InkColor::All(t) => { + value.fill(t.clamp(0.0, 1.0)); + named.fill(true); + } + InkColor::Tints(tints) => { + for &(id, t) in tints { + if let (Some(v), Some(k)) = (value.get_mut(id.index()), named.get_mut(id.index())) { + *v = t.clamp(0.0, 1.0); + *k = true; + } + } + if overprint.nonzero && color.as_cmyk().is_some() { + for (k, v) in named.iter_mut().zip(&value).take(4) { + *k = *v > 0.0; + } + } + } + } + if !overprint.fill { + named.fill(true); + } + Some((value, named)) + } + + fn put(&mut self, i: usize, cov: f32, value: &[f32], named: &[bool]) { + if cov <= 0.0 { + return; + } + let cov = cov.min(1.0); + for k in 0..self.n { + if named[k] { + let v = &mut self.values[i * self.n + k]; + *v = *v * (1.0 - cov) + value[k] * cov; + } + } + self.alpha[i] += cov * (1.0 - self.alpha[i]); + } + + fn fill(&mut self, path: &BezPath, rule: FillRule, paint: &Paint, overprint: Overprint, clip: Option<&[f32]>, opacity: f32) { + let cov = self.coverage(path, rule); + let factor = |i: usize| cov[i] * clip.map_or(1.0, |c| c[i]) * opacity; + match paint { + Paint::Solid(color) => { + let Some((value, named)) = self.dense(color, overprint) else { return }; + for i in 0..self.w * self.h { + self.put(i, factor(i), &value, &named); + } + } + Paint::Gradient { stops, geometry } => { + let dense: Vec<(f32, Vec, Vec)> = + stops.iter().filter_map(|(s, c)| self.dense(c, overprint).map(|(v, k)| (*s, v, k))).collect(); + let Some(first) = dense.first() else { return }; + let mut named = first.2.clone(); + for d in &dense { + named.iter_mut().zip(&d.2).for_each(|(a, b)| *a |= b); + } + let from_px = self.to_px.inverse(); + let mut value = vec![0.0; self.n]; + for y in 0..self.h { + for x in 0..self.w { + let i = y * self.w + x; + let f = factor(i); + if f <= 0.0 { + continue; + } + let p = from_px * Point::new(x as f64 + 0.5, y as f64 + 0.5); + let Some(s) = gradient_offset(geometry, p) else { continue }; + interpolate_stops(&dense, s, &mut value); + self.put(i, f, &value, &named); + } + } + } + } + } + + fn image(&mut self, im: &ImageItem, clip: Option<&[f32]>, opacity: f32) { + let (iw, ih) = (im.size.0 as usize, im.size.1 as usize); + let inv = (self.to_px * im.transform).inverse(); + let ids: Vec = match &im.space { + ImageSpace::Gray => vec![InkId::process(Process::Black)], + ImageSpace::Cmyk => Process::ALL.iter().map(|p| InkId::process(*p)).collect(), + ImageSpace::Inks(ids) => ids.clone(), + ImageSpace::Rgb => return, + }; + let comps = if im.space == ImageSpace::Gray { 1 } else { ids.len() }; + let mut named = vec![!im.overprint.fill; self.n]; + for id in &ids { + if let Some(k) = named.get_mut(id.index()) { + *k = true; + } + } + if im.space == ImageSpace::Gray && !im.overprint.fill { + named.fill(true); + } else if im.space == ImageSpace::Gray { + named.iter_mut().take(4).for_each(|k| *k = true); + } + let mut value = vec![0.0; self.n]; + for y in 0..self.h { + for x in 0..self.w { + let u = inv * Point::new(x as f64 + 0.5, y as f64 + 0.5); + if !(0.0..1.0).contains(&u.x) || !(0.0..1.0).contains(&u.y) { + continue; + } + let (sx, sy) = ((u.x * iw as f64) as usize, (u.y * ih as f64) as usize); + let s = sy * iw + sx; + let Some(sample) = im.samples.get(s * comps..(s + 1) * comps) else { continue }; + value.fill(0.0); + if im.space == ImageSpace::Gray { + value[Process::Black.index()] = 1.0 - f32::from(sample[0]) / 255.0; + } else { + for (id, v) in ids.iter().zip(sample) { + if let Some(slot) = value.get_mut(id.index()) { + *slot = f32::from(*v) / 255.0; + } + } + } + let a = im.smask.as_ref().and_then(|m| m.get(s)).map_or(1.0, |a| f32::from(*a) / 255.0); + let i = y * self.w + x; + self.put(i, a * clip.map_or(1.0, |c| c[i]) * opacity, &value, &named); + } + } + } + + /// The fraction of each pixel `path` covers. + fn coverage(&self, path: &BezPath, rule: FillRule) -> Vec { + let mut edges: Vec<(Point, Point)> = Vec::new(); + let (mut start, mut last) = (Point::ZERO, Point::ZERO); + kurbo::flatten(self.to_px * path.clone(), 0.1, |el| match el { + PathEl::MoveTo(p) => { + if last != start { + edges.push((last, start)); + } + (start, last) = (p, p); + } + PathEl::LineTo(p) => { + edges.push((last, p)); + last = p; + } + PathEl::ClosePath => { + if last != start { + edges.push((last, start)); + } + last = start; + } + _ => {} + }); + if last != start { + edges.push((last, start)); + } + let mut cov = vec![0.0_f32; self.w * self.h]; + let mut crossings: Vec<(f64, i32)> = Vec::new(); + for sy in 0..self.h * SUBROWS { + let y = (sy as f64 + 0.5) / SUBROWS as f64; + crossings.clear(); + for (a, b) in &edges { + let (lo, hi, dir) = if a.y <= b.y { (a, b, 1) } else { (b, a, -1) }; + if y >= lo.y && y < hi.y { + crossings.push((lo.x + (y - lo.y) / (hi.y - lo.y) * (hi.x - lo.x), dir)); + } + } + crossings.sort_by(|a, b| a.0.total_cmp(&b.0)); + let row = sy / SUBROWS; + let mut winding = 0; + for k in 0..crossings.len() { + winding += crossings[k].1; + let inside = match rule { + FillRule::NonZero => winding != 0, + FillRule::EvenOdd => winding % 2 != 0, + }; + if inside && let Some(next) = crossings.get(k + 1) { + self.span(&mut cov, row, crossings[k].0, next.0); + } + } + } + cov + } + + fn span(&self, cov: &mut [f32], row: usize, xa: f64, xb: f64) { + let (xa, xb) = (xa.clamp(0.0, self.w as f64), xb.clamp(0.0, self.w as f64)); + if xb <= xa { + return; + } + let weight = 1.0 / SUBROWS as f32; + let last = (xb.ceil() as usize).min(self.w); + for px in xa.floor() as usize..last { + let overlap = xb.min(px as f64 + 1.0) - xa.max(px as f64); + if overlap > 0.0 { + cov[row * self.w + px] += overlap as f32 * weight; + } + } + } + + /// The canvas as an image in every ink of the set, and its alpha when not opaque throughout. + pub fn into_image(self, overprint: Overprint) -> ImageItem { + let transform = self.image_transform(); + let ids: Vec = self.inks.iter().map(|(id, _)| id).collect(); + // Values are premultiplied by alpha; samples are not. + let n = self.n.max(1); + let samples: Vec = self + .values + .iter() + .enumerate() + .map(|(k, v)| { + let a = self.alpha[k / n]; + let v = if a > 0.0 { v / a } else { 0.0 }; + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect(); + let opaque = self.alpha.iter().all(|a| *a >= 1.0 - 1e-4); + let smask = (!opaque).then(|| self.alpha.iter().map(|a| (a.clamp(0.0, 1.0) * 255.0).round() as u8).collect()); + ImageItem { + space: ImageSpace::Inks(ids), + size: (self.w as u32, self.h as u32), + samples: samples.into(), + transform, + smask, + overprint, + jpeg: None, + } + } + + /// The canvas sample nearest page point `p`: premultiplied values and alpha. + pub fn sample(&self, p: Point) -> Option<(&[f32], f32)> { + let q = self.to_px * p; + if q.x < 0.0 || q.y < 0.0 { + return None; + } + let (x, y) = (q.x as usize, q.y as usize); + if x >= self.w || y >= self.h { + return None; + } + let i = y * self.w + x; + Some((&self.values[i * self.n..(i + 1) * self.n], self.alpha[i])) + } + + /// Composites the premultiplied values `value` with alpha `a` over pixel (`x`, `y`), + /// knocking out every ink. + pub fn over(&mut self, x: usize, y: usize, value: &[f32], a: f32) { + let i = y * self.w + x; + for (v, s) in self.values[i * self.n..(i + 1) * self.n].iter_mut().zip(value) { + *v = *v * (1.0 - a) + s; + } + self.alpha[i] += a * (1.0 - self.alpha[i]); + } + + /// The page point at the center of pixel (`x`, `y`). + pub fn center(&self, x: usize, y: usize) -> Point { + self.to_px.inverse() * Point::new(x as f64 + 0.5, y as f64 + 0.5) + } +} + +/// Where page point `p` falls on the gradient, `0..=1`; `None` where it paints nothing. +fn gradient_offset(geometry: &GradientGeometry, p: Point) -> Option { + let clamp = |s: f64, extend: [bool; 2]| { + if s < 0.0 { + extend[0].then_some(0.0) + } else if s > 1.0 { + extend[1].then_some(1.0) + } else { + Some(s) + } + }; + match geometry { + GradientGeometry::Axial { start, end, extend, transform } => { + let q = transform.inverse() * p; + let d = *end - *start; + let len2 = d.hypot2(); + if len2 <= f64::EPSILON { + return None; + } + clamp((q - *start).dot(d) / len2, *extend) + } + GradientGeometry::Radial { start, start_radius, end, end_radius, extend, transform } => { + let q = transform.inverse() * p; + let (cd, pd, dr, r0) = (*end - *start, q - *start, end_radius - start_radius, *start_radius); + let a = cd.hypot2() - dr * dr; + let b = pd.dot(cd) + r0 * dr; + let c = pd.hypot2() - r0 * r0; + let candidates = if a.abs() < 1e-9 { + if b.abs() < 1e-12 { + return None; + } + [c / (2.0 * b), f64::NAN] + } else { + let disc = b * b - a * c; + if disc < 0.0 { + return None; + } + let root = disc.sqrt(); + let (s1, s2) = ((b + root) / a, (b - root) / a); + [s1.max(s2), s1.min(s2)] + }; + candidates.into_iter().filter(|s| s.is_finite() && r0 + s * dr >= 0.0).find_map(|s| clamp(s, *extend)) + } + } +} + +fn interpolate_stops(stops: &[(f32, Vec, Vec)], s: f64, out: &mut [f32]) { + let s = s as f32; + let k = stops.iter().position(|st| st.0 >= s).unwrap_or(stops.len() - 1); + let (hi, lo) = (&stops[k], &stops[k.saturating_sub(1)]); + let u = if hi.0 > lo.0 { ((s - lo.0) / (hi.0 - lo.0)).clamp(0.0, 1.0) } else { 1.0 }; + for (o, (a, b)) in out.iter_mut().zip(lo.1.iter().zip(&hi.1)) { + *o = a + (b - a) * u; + } +} + +/// The unit square of a raster image placed over `area` at `scale`, as [`ImageItem::transform`]. +pub(super) fn area_transform(area: Rect, scale: f64, (w, h): (usize, usize)) -> Affine { + Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(w as f64 / scale, h as f64 / scale) +} + +/// A gradient stop's color from ink values. +pub(super) fn stop_color(ids: &StopInks, values: &[f32]) -> InkColor { + match ids { + StopInks::Gray => { + let k = 1.0 - values.first().copied().unwrap_or(1.0); + InkColor::Tints(Process::ALL.iter().map(|p| (InkId::process(*p), if *p == Process::Black { k } else { 0.0 })).collect()) + } + StopInks::Inks(ids) => InkColor::Tints(ids.iter().zip(values).map(|(id, v)| (*id, *v)).collect()), + } +} + +/// What ink values mean in a gradient stop. +pub(super) enum StopInks { + /// A gray level, painted as black. + Gray, + Inks(Vec), +} + +/// Where a gradient with `extend` paints, in page space, as a clip for the same gradient +/// extended at both ends; `None` when it extends at both. `bounds` is the page area that can +/// show. Exact for axial gradients; for radial ones it is the convex hull of the circles the +/// gradient sweeps, which is where it paints. +pub(super) fn gradient_extent(geometry: &GradientGeometry, extend: [bool; 2], bounds: Rect) -> Option { + if extend == [true, true] { + return None; + } + let (transform, path) = match geometry { + GradientGeometry::Axial { start, end, transform, .. } => { + let inv = transform.inverse(); + let r = inv.transform_rect_bbox(bounds); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let d = *end - *start; + let len = d.hypot(); + if len.is_nan() || len <= 0.0 { + return None; + } + let normal = Vec2::new(-d.y, d.x) / len; + let s: Vec = corners.iter().map(|c| (*c - *start).dot(d) / (len * len)).collect(); + let t: Vec = corners.iter().map(|c| (*c - *start).dot(normal)).collect(); + let (smin, smax) = s.iter().fold((0.0_f64, 1.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + let (tmin, tmax) = t.iter().fold((0.0_f64, 0.0_f64), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + let a = if extend[0] { smin - 1.0 } else { 0.0 }; + let b = if extend[1] { smax + 1.0 } else { 1.0 }; + let at = |s: f64, t: f64| *start + d * s + normal * t; + let mut path = BezPath::new(); + path.move_to(at(a, tmin - 1.0)); + path.line_to(at(b, tmin - 1.0)); + path.line_to(at(b, tmax + 1.0)); + path.line_to(at(a, tmax + 1.0)); + path.close_path(); + (*transform, path) + } + GradientGeometry::Radial { start, start_radius, end, end_radius, transform, .. } => { + let inv = transform.inverse(); + let r = inv.transform_rect_bbox(bounds); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let circle = |s: f64| (*start + (*end - *start) * s, start_radius + s * (end_radius - start_radius)); + let dr = end_radius - start_radius; + // How far an extended end reaches: to where the radius is 0, or until the circle + // holds the visible area. + let reach = |from: f64, dir: f64| { + if dr * dir < 0.0 { + return -start_radius / dr; + } + let mut s = from + dir; + for _ in 0..64 { + let (c, radius) = circle(s); + if corners.iter().all(|p| (*p - c).hypot() <= radius) { + break; + } + s = from + (s - from) * 2.0; + } + s + }; + let a = if extend[0] { reach(0.0, -1.0) } else { 0.0 }; + let b = if extend[1] { reach(1.0, 1.0) } else { 1.0 }; + (*transform, hull_of_circles(circle(a), circle(b))) + } + }; + Some(transform * path) +} + +/// The convex hull of two circles, circumscribed by a polygon. +fn hull_of_circles((c0, r0): (Point, f64), (c1, r1): (Point, f64)) -> BezPath { + const SIDES: usize = 256; + let grow = 1.0 / (std::f64::consts::PI / SIDES as f64).cos(); + let mut points = Vec::with_capacity(2 * SIDES); + for (c, r) in [(c0, r0.max(0.0)), (c1, r1.max(0.0))] { + for k in 0..SIDES { + let a = k as f64 * std::f64::consts::TAU / SIDES as f64; + points.push(c + Vec2::new(a.cos(), a.sin()) * r * grow); + } + } + points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + let cross = |o: Point, a: Point, b: Point| (a - o).cross(b - o); + let mut hull: Vec = Vec::new(); + for pass in 0..2 { + let floor = hull.len(); + let iter: Box> = if pass == 0 { Box::new(points.iter()) } else { Box::new(points.iter().rev()) }; + for p in iter { + while hull.len() >= floor + 2 && cross(hull[hull.len() - 2], hull[hull.len() - 1], *p) <= 0.0 { + hull.pop(); + } + hull.push(*p); + } + hull.pop(); + } + let mut path = BezPath::new(); + for (k, p) in hull.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else { + path.line_to(*p); + } + } + path.close_path(); + path +} + +/// The rectangle's corners in page space through `t`, as a closed path. +pub(super) fn rect_path(r: Rect, t: Affine) -> BezPath { + let mut path = BezPath::new(); + path.move_to(t * Point::new(r.x0, r.y0)); + path.line_to(t * Point::new(r.x1, r.y0)); + path.line_to(t * Point::new(r.x1, r.y1)); + path.line_to(t * Point::new(r.x0, r.y1)); + path.close_path(); + path +} diff --git a/crates/prepress/src/flatten/write.rs b/crates/prepress/src/flatten/write.rs new file mode 100644 index 00000000..06fcc485 --- /dev/null +++ b/crates/prepress/src/flatten/write.rs @@ -0,0 +1,777 @@ +//! Writes a display list, or a [`Flattened`] page, as PDF content through krilla. +//! +//! Colors print as [`InkSet::resolve`] makes them (aliases, spots printed as process color) +//! and are written by [`to_pdf_color`], with one exception: an overprinting process-only +//! color that names fewer than four process inks is written as a DeviceN color of just the +//! inks it names. DeviceCMYK would set the unnamed plates to 0 under `OPM 0`, and `OPM 1` +//! would also leave alone the plates of named inks at 0; a DeviceN color paints exactly its +//! colorants when it overprints (ISO 32000-2 §8.6.7), whatever `OPM` says. +//! +//! Overprint is written as `op`/`OP`/`OPM` on each item, never as blend modes or knockout +//! groups. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use craft_pdf::interpret::color::CmykConversion; +use craft_pdf::write::backend::blend::{self as kblend, GroupOptions}; +use craft_pdf::write::backend::color::device_n::{self, Colorant, DeviceNSpace}; +use craft_pdf::write::backend::color::{Color as PdfColor, RegularColor, cmyk}; +use craft_pdf::write::backend::configure::PdfVersion; +use craft_pdf::write::backend::geom::{Path, PathBuilder, Point as KPoint, Size, Transform}; +use craft_pdf::write::backend::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use craft_pdf::write::backend::mask::{Mask as KMask, MaskType, TransferFunction}; +use craft_pdf::write::backend::num::NormalizedF32; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule, LinearGradient, Paint as KPaint, RadialGradient, SpreadMethod, Stop}; +use craft_pdf::write::backend::surface::Surface; +use craft_pdf::write::backend::tagging::{ContentTag, SpanTag}; +use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph}; +use craft_pdf::write::backend::{Data, SerializeSettings}; +use craft_pdf::write::{self as pdf_write, Metadata, Writer}; +use kurbo::{Affine, BezPath, PathEl}; + +use super::{ + BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, Flattened, FontBytes, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Mask, + MaskKind, Paint, TextFont, TextItem, +}; +use crate::ink::{Ink, InkColor, InkId, InkSet, Overprint, Process, to_pdf_color}; + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WriteError { + #[error(transparent)] + Pdf(#[from] pdf_write::WriteError), + /// An ink isn't in the set or a spot's Lab alternate can't be converted. + #[error("color {0:?} can't be written")] + Color(InkColor), + /// A DeviceN space would have more colorants than the output's PDF version takes + /// ([`max_colorants`]). + #[error("a DeviceN space of {colorants} colorants; this output takes at most {max}")] + Colorants { colorants: usize, max: usize }, + /// A complex region paints more spot inks than [`plates`](crate::plates::plates) separates; + /// `unseparated` are the ones past the limit. + #[error("a complex region paints {inks} spot inks; at most {max} are separated, not {}", some_names(.unseparated))] + SpotInks { inks: usize, max: usize, unseparated: Vec }, + #[error("the page box is empty or not finite")] + Page, + #[error("image: {0}")] + Image(&'static str), + /// Display-list features the writer doesn't write yet. + #[error("not written yet: {0}")] + Unsupported(&'static str), + /// Raised by a [`write_flattened`] region callback. + #[error("complex region: {0}")] + Region(String), +} + +/// The most colorants a DeviceN space takes at `version`: 8 up to PDF 1.4 (implementation +/// limits, PDF 1.4 reference Appendix C), 32 from PDF 1.5. +pub fn max_colorants(version: PdfVersion) -> usize { + match version { + PdfVersion::Pdf14 => 8, + _ => DeviceNSpace::MAX_COLORANTS, + } +} + +fn some_names(names: &[String]) -> String { + let mut listed = names.iter().take(3).map(String::as_str).collect::>().join(", "); + if names.len() > 3 { + listed.push_str(", …"); + } + listed +} + +/// Draws `items` on `surface` in page space, with colors of `inks`, for an output of PDF +/// `version`. `cms` converts spot inks' Lab alternates into the printing condition. On error, +/// the items before the failing one are drawn and the surface is left balanced. +pub fn draw(surface: &mut Surface<'_>, inks: &InkSet, items: &[Item], cms: &dyn CmykConversion, version: PdfVersion) -> Result<(), WriteError> { + let mut painter = Painter::new(inks, cms, version); + items.iter().try_for_each(|item| painter.item(surface, item)) +} + +/// Draws `flat.items` in order and, before item `at` of each complex region, the image +/// `raster` returns for it, clipped to the region's path and without overprint. `raster` +/// returns an [`ImageItem`] whose space is [`ImageSpace::Inks`] of `list.inks` and which +/// covers the region, or `None` to leave the region empty. `version` is the output's PDF +/// version. +pub fn write_flattened( + surface: &mut Surface<'_>, + list: &DisplayList, + flat: &Flattened, + cms: &dyn CmykConversion, + version: PdfVersion, + raster: &mut dyn FnMut(&ComplexRegion) -> Result, WriteError>, +) -> Result<(), WriteError> { + Painter::new(&list.inks, cms, version).flattened(surface, flat, raster) +} + +/// [`write_flattened`] for one page of a document whose pages share `fonts`. +pub fn write_flattened_with_fonts( + surface: &mut Surface<'_>, + list: &DisplayList, + flat: &Flattened, + cms: &dyn CmykConversion, + version: PdfVersion, + fonts: &mut FontMap, + raster: &mut dyn FnMut(&ComplexRegion) -> Result, WriteError>, +) -> Result<(), WriteError> { + let mut painter = Painter::new(&list.inks, cms, version); + painter.shared = Some(fonts); + painter.flattened(surface, flat, raster) +} + +/// krilla fonts shared by the pages of one document, so that a font used on several pages is +/// embedded once. Runs share a font only when their [`TextFont`] bytes are equal: equal keys on +/// two pages can name different bytes (per-page widths of a wrapped bare CFF). +#[derive(Default)] +pub struct FontMap { + /// By [`TextFont::key`] and face index: each font program seen, `None` where krilla can't + /// read it. + fonts: HashMap<(u128, u32), Vec>, +} + +type Shared = (FontBytes, Option); + +impl FontMap { + pub fn new() -> Self { + Self::default() + } + + /// Distinct font programs seen, including those krilla can't read. + pub fn len(&self) -> usize { + self.fonts.values().map(Vec::len).sum() + } + + pub fn is_empty(&self) -> bool { + self.fonts.is_empty() + } + + fn get(&mut self, tf: &TextFont) -> Option { + let same = |d: &FontBytes| std::ptr::addr_eq(Arc::as_ptr(d), Arc::as_ptr(&tf.data)) || (**d).as_ref() == (*tf.data).as_ref(); + let entries = self.fonts.entry((tf.key, tf.index)).or_default(); + if let Some((_, font)) = entries.iter().find(|(d, _)| same(d)) { + return font.clone(); + } + let font = Font::new(Data::from(tf.data.clone()), tf.index); + entries.push((tf.data.clone(), font.clone())); + font + } +} + +impl Painter<'_, '_> { + fn flattened( + &mut self, + surface: &mut Surface<'_>, + flat: &Flattened, + raster: &mut dyn FnMut(&ComplexRegion) -> Result, WriteError>, + ) -> Result<(), WriteError> { + let mut regions: Vec<&ComplexRegion> = flat.complex.iter().collect(); + regions.sort_by_key(|r| r.at); + let mut regions = regions.into_iter().peekable(); + for at in 0..=flat.items.len() { + while let Some(region) = regions.next_if(|r| r.at <= at || at == flat.items.len()) { + let Some(image) = raster(region)? else { continue }; + let Some(clip) = to_path(®ion.path) else { continue }; + surface.push_clip_path(&clip, &KFillRule::NonZero); + let drawn = self.image(surface, &ImageItem { overprint: Overprint::default(), ..image }); + surface.pop(); + drawn?; + } + if let Some(item) = flat.items.get(at) { + self.item(surface, item)?; + } + } + Ok(()) + } +} + +/// `list` as a one-page PDF with `settings`. +pub fn page_pdf(list: &DisplayList, settings: SerializeSettings, cms: &dyn CmykConversion) -> Result, WriteError> { + let page = list.page; + let size = PageSettings::from_wh(page.width() as f32, page.height() as f32).filter(|_| page.is_finite()).ok_or(WriteError::Page)?; + let version = settings.configuration.version(); + pdf_write::guard(|| { + let mut writer = Writer::new(settings, Metadata::default()); + let mut page_builder = writer.document_mut().start_page_with(size); + let mut surface = page_builder.surface(); + let shifted = page.x0 != 0.0 || page.y0 != 0.0; + if shifted { + surface.push_transform(&Transform::from_translate(-page.x0 as f32, -page.y0 as f32)); + } + let drawn = draw(&mut surface, &list.inks, &list.items, cms, version); + if shifted { + surface.pop(); + } + surface.finish(); + page_builder.finish(); + drawn?; + Ok(writer.finish()?) + }) +} + +struct Painter<'a, 'f> { + inks: &'a InkSet, + cms: &'a dyn CmykConversion, + /// [`max_colorants`] of the output. + max_colorants: usize, + /// The document's fonts, consulted on a miss in `fonts`. + shared: Option<&'f mut FontMap>, + /// `None` for fonts krilla can't read; their runs are written as outlines. Keyed by + /// [`TextFont::key`] for one page only: a font built for another page may have the same + /// key and other bytes (per-page widths of a wrapped bare CFF). + fonts: HashMap<(u128, u32), Option>, + /// Inside an `/ActualText` span, which can't nest. + tagged: bool, +} + +impl<'a> Painter<'a, '_> { + fn new(inks: &'a InkSet, cms: &'a dyn CmykConversion, version: PdfVersion) -> Self { + Self { inks, cms, max_colorants: max_colorants(version), shared: None, fonts: HashMap::new(), tagged: false } + } + + /// An error when a DeviceN space of `colorants` is more than the output takes. + fn fits(&self, colorants: usize) -> Result<(), WriteError> { + if colorants > self.max_colorants { + return Err(WriteError::Colorants { colorants, max: self.max_colorants }); + } + Ok(()) + } + + fn item(&mut self, s: &mut Surface<'_>, item: &Item) -> Result<(), WriteError> { + self.item_with_opacity(s, item, 1.0) + } + + /// `opacity` is a group's, applied to a fill or text drawn alone in it. + fn item_with_opacity(&mut self, s: &mut Surface<'_>, item: &Item, opacity: f32) -> Result<(), WriteError> { + match item { + Item::Fill(f) => self.fill(s, f, opacity), + Item::Text(t) => self.text(s, t, opacity), + Item::Image(im) => self.image(s, im), + Item::Group(g) => self.group(s, g), + } + } + + fn fill(&mut self, s: &mut Surface<'_>, f: &FillItem, opacity: f32) -> Result<(), WriteError> { + let Some(path) = to_path(&f.path) else { return Ok(()) }; + let rule = match f.rule { + FillRule::NonZero => KFillRule::NonZero, + FillRule::EvenOdd => KFillRule::EvenOdd, + }; + let (paint, overprint) = self.paint(&f.paint, f.overprint, Affine::IDENTITY)?; + with_overprint(s, overprint, |s| { + s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule })); + s.draw_path(&path); + }); + Ok(()) + } + + fn text(&mut self, s: &mut Surface<'_>, t: &TextItem, opacity: f32) -> Result<(), WriteError> { + let text = t.text.as_deref().unwrap_or(""); + let font = t.font.as_deref().and_then(|tf| self.font(tf)); + let (Some(font), Some(first)) = (font, t.glyphs.first()) else { + let Some(path) = to_path(&t.outlines) else { return Ok(()) }; + let (paint, overprint) = self.paint(&t.paint, t.overprint, Affine::IDENTITY)?; + let tag = !self.tagged && t.text.is_some(); + with_overprint(s, overprint, |s| { + if tag { + s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() })); + } + s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero })); + s.draw_path(&path); + if tag { + s.end_tagged(); + } + }); + return Ok(()); + }; + let size = f64::from(t.size); + if !(size.is_finite() && size > 0.0) { + return Ok(()); + } + let glyphs: Vec = t + .glyphs + .iter() + .enumerate() + .map(|(i, g)| { + let next_x = t.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x); + let range = if text.get(g.text.clone()).is_some() { g.text.clone() } else { 0..0 }; + KrillaGlyph::new(GlyphId::new(g.id), ((next_x - g.origin.x) / size) as f32, 0.0, (g.origin.y / size) as f32, 0.0, range, None) + }) + .collect(); + // Text space is y up; krilla places glyphs y down. + let ctm = t.transform * Affine::scale_non_uniform(1.0, -1.0); + let (paint, overprint) = self.paint(&t.paint, t.overprint, ctm)?; + with_overprint(s, overprint, |s| { + s.set_fill(Some(Fill { paint, opacity: normalized(opacity), rule: KFillRule::NonZero })); + s.push_transform(&to_transform(ctm)); + s.draw_glyphs(KPoint::from_xy(first.origin.x as f32, 0.0), &glyphs, font, text, t.size, false); + s.pop(); + }); + Ok(()) + } + + fn font(&mut self, tf: &TextFont) -> Option { + if let Some(font) = self.fonts.get(&(tf.key, tf.index)) { + return font.clone(); + } + let font = match self.shared.as_deref_mut() { + Some(shared) => shared.get(tf), + None => Font::new(Data::from(tf.data.clone()), tf.index), + }; + self.fonts.insert((tf.key, tf.index), font.clone()); + font + } + + fn image(&mut self, s: &mut Surface<'_>, im: &ImageItem) -> Result<(), WriteError> { + let (w, h) = im.size; + let pixels = usize::try_from(u64::from(w) * u64::from(h)).map_err(|_| WriteError::Image("too large"))?; + if pixels == 0 { + return Ok(()); + } + let draw = |s: &mut Surface<'_>, image: Image| { + let Some(unit) = Size::from_wh(1.0, 1.0) else { return }; + with_overprint(s, im.overprint, |s| { + s.push_transform(&to_transform(im.transform)); + s.draw_image(image, unit); + s.pop(); + }); + }; + if let Some(image) = passthrough(im) { + draw(s, image); + return Ok(()); + } + let (space, components, device_n, samples) = match &im.space { + ImageSpace::Gray => (ImageColorspace::Luma, 1, None, im.samples.clone()), + ImageSpace::Rgb => (ImageColorspace::Rgb, 3, None, im.samples.clone()), + ImageSpace::Cmyk => (ImageColorspace::Cmyk, 4, None, im.samples.clone()), + ImageSpace::Inks(ids) => { + if im.samples.len() != pixels.saturating_mul(ids.len()) { + return Err(WriteError::Image("sample count doesn't match the size and inks")); + } + let (space, samples) = self.ink_samples(ids, &im.samples)?; + (ImageColorspace::DeviceN, space.colorants().len(), Some(space), samples) + } + }; + if samples.len() != pixels.saturating_mul(components) { + return Err(WriteError::Image("sample count doesn't match the size and space")); + } + if im.smask.as_ref().is_some_and(|a| a.len() != pixels) { + return Err(WriteError::Image("smask size doesn't match the image")); + } + let image = Image::from_custom(Samples { samples, alpha: im.smask.clone(), size: im.size, space, device_n }, false) + .map_err(|_| WriteError::Image("krilla rejected it"))?; + draw(s, image); + Ok(()) + } + + /// Samples of `ids` as samples of the inks they print on: each ink's tint moves to the + /// inks [`InkSet::resolve`] gives a full tint of it, and tints on one ink combine as + /// overlapping halftones, as `resolve` combines them. + fn ink_samples(&self, ids: &[InkId], samples: &Arc<[u8]>) -> Result<(DeviceNSpace, Arc<[u8]>), WriteError> { + let mut weights: Vec> = Vec::with_capacity(ids.len()); + for &id in ids { + let full = InkColor::Tints(vec![(id, 1.0)]); + match self.inks.resolve(&full, self.cms) { + InkColor::Tints(t) => weights.push(t), + _ => return Err(WriteError::Color(full)), + } + } + let out: Vec = weights.iter().flatten().map(|(id, _)| *id).collect::>().into_iter().collect(); + self.fits(out.len())?; + let colorants = out + .iter() + .map(|&id| self.colorant(id).ok_or_else(|| WriteError::Color(InkColor::Tints(vec![(id, 1.0)])))) + .collect::, _>>()?; + let space = DeviceNSpace::new(colorants).ok_or(WriteError::Image("krilla rejected its DeviceN space"))?; + let renames = weights.iter().all(|w| matches!(w[..], [(_, t)] if t == 1.0)) && out.len() == ids.len(); + if renames && weights.iter().zip(&out).all(|(w, o)| w.first().is_some_and(|(id, _)| id == o)) { + return Ok((space, samples.clone())); + } + // Column of each ink's weights in `out`. + let routes: Vec> = + weights.iter().map(|w| w.iter().filter_map(|(id, t)| out.iter().position(|o| o == id).map(|i| (i, *t))).collect()).collect(); + let n = ids.len(); + let mut result = Vec::with_capacity(samples.len() / n.max(1) * out.len()); + let mut keep = vec![1.0_f32; out.len()]; + for px in samples.chunks_exact(n.max(1)) { + keep.fill(1.0); + for (v, route) in px.iter().zip(&routes) { + let v = f32::from(*v) / 255.0; + for &(i, t) in route { + if let Some(k) = keep.get_mut(i) { + *k *= 1.0 - v * t; + } + } + } + result.extend(keep.iter().map(|k| byte(1.0 - k))); + } + Ok((space, result.into())) + } + + fn group(&mut self, s: &mut Surface<'_>, g: &GroupItem) -> Result<(), WriteError> { + if g.children.is_empty() { + return Ok(()); + } + let mut pushed = 0; + let clip = match &g.clip { + Some(clip) => match to_path(clip) { + Some(path) => Some(path), + // An empty clip shows nothing. + None => return Ok(()), + }, + None => None, + }; + if let Some(clip) = clip { + s.push_clip_path(&clip, &KFillRule::NonZero); + pushed += 1; + } + let tag = match &g.actual_text { + Some(text) if !self.tagged => { + s.start_tagged(ContentTag::Span(SpanTag { actual_text: Some(text), ..SpanTag::empty() })); + self.tagged = true; + true + } + _ => false, + }; + let drawn = self.group_content(s, g, &mut pushed); + for _ in 0..pushed { + s.pop(); + } + if tag { + s.end_tagged(); + self.tagged = false; + } + drawn + } + + /// Pushes the group's blend mode, mask, opacity and attributes, counted in `pushed`, and + /// draws the children. + fn group_content(&mut self, s: &mut Surface<'_>, g: &GroupItem, pushed: &mut usize) -> Result<(), WriteError> { + let opacity = unit(g.opacity); + let blended = g.blend != BlendMode::Normal; + if blended { + s.push_blend_mode(blend_mode(g.blend)); + *pushed += 1; + } + // One fill or text run alone in a plain group: opacity and blend mode on the paint + // itself composite the same. + if let ([child @ (Item::Fill(_) | Item::Text(_))], false, false, None) = (&g.children[..], g.isolated, g.knockout, &g.mask) { + return self.item_with_opacity(s, child, opacity); + } + let mut isolated = false; + if let Some(mask) = &g.mask { + let mask = self.mask(s, mask, opacity)?; + s.push_mask(mask); + *pushed += 1; + } else if opacity < 1.0 && g.isolated { + s.push_opacity(normalized(opacity)); + *pushed += 1; + isolated = true; + } else if opacity < 1.0 || (blended && !g.isolated && !g.knockout) { + // krilla writes opacity as an isolated group and a group without attributes as a + // plain form; a constant soft mask makes a non-isolated transparency group. + let mask = constant_mask(s, opacity); + s.push_mask(mask); + *pushed += 1; + } + if g.knockout || (g.isolated && !isolated) { + s.push_group(GroupOptions { isolated: g.isolated, knockout: g.knockout }); + *pushed += 1; + } + g.children.iter().try_for_each(|child| self.item(s, child)) + } + + /// `m` as a krilla mask, with `opacity` folded into its transfer function. + fn mask(&mut self, s: &mut Surface<'_>, m: &Mask, opacity: f32) -> Result { + let mut builder = s.stream_builder(); + let mut sub = builder.surface(); + let tagged = std::mem::replace(&mut self.tagged, true); + let drawn = self.group(&mut sub, &m.group); + self.tagged = tagged; + sub.finish(); + let stream = builder.finish(); + drawn?; + let kind = match m.kind { + MaskKind::Alpha => MaskType::Alpha, + MaskKind::Luminosity => MaskType::Luminosity, + }; + let mut mask = KMask::new(stream, kind); + if let (MaskKind::Luminosity, Some(backdrop)) = (m.kind, &m.backdrop) { + // The backdrop is a color of the CMYK blending space, so spots count as process. + let mut process = self.inks.clone(); + process.set_all_to_process(true); + let cmyk = process.resolve(backdrop, self.cms).as_cmyk().ok_or_else(|| WriteError::Color(backdrop.clone()))?; + let [c, m, y, k] = cmyk.map(byte); + mask = mask.with_backdrop(cmyk::Color::new(c, m, y, k)); + } + let transfer = match &m.transfer { + Some(samples) => Some(samples.iter().map(|v| f32::from(*v) / 255.0 * opacity).collect()), + None if opacity < 1.0 => Some(vec![0.0, opacity]), + None => None, + }; + if let Some(transfer) = transfer { + mask = mask.with_transfer(TransferFunction::Sampled(transfer.into_iter().map(normalized).collect())); + } + Ok(mask) + } + + /// `paint` as krilla draws it under `ctm` (page space from the drawing space), and the + /// overprint to draw it with. + fn paint(&self, paint: &Paint, overprint: Overprint, ctm: Affine) -> Result<(KPaint, Overprint), WriteError> { + match paint { + Paint::Solid(color) => { + let (color, overprint) = self.solid(color, overprint)?; + Ok((color.into(), overprint)) + } + Paint::Gradient { stops, geometry } => Ok((self.gradient(stops, geometry, overprint, ctm)?, overprint)), + } + } + + fn solid(&self, color: &InkColor, overprint: Overprint) -> Result<(PdfColor, Overprint), WriteError> { + let resolved = self.inks.resolve(color, self.cms); + if let InkColor::Tints(tints) = &resolved + && overprint.fill + && let Some(cmyk) = resolved.as_cmyk() + { + let named: Vec = + (0..4).filter(|&i| tints.iter().any(|(id, _)| id.index() == i) && (!overprint.nonzero || cmyk[i] > 0.0)).collect(); + if named.len() < 4 { + if named.is_empty() { + // Zero DeviceCMYK under OPM 1 leaves every plate alone. + return Ok((RegularColor::Cmyk(cmyk::Color::new(0, 0, 0, 0)).into(), Overprint { nonzero: true, ..overprint })); + } + return Ok((self.process_device_n(&named, cmyk).ok_or_else(|| WriteError::Color(color.clone()))?, overprint)); + } + } + if let InkColor::Tints(tints) = &resolved { + // More inks than the output's limit include a spot, so `to_pdf_color` makes a + // DeviceN space of them. + self.fits(tints.iter().map(|(id, _)| *id).collect::>().len())?; + } + let pdf = to_pdf_color(&resolved, self.inks, self.cms).ok_or_else(|| WriteError::Color(color.clone()))?; + Ok((pdf, overprint)) + } + + /// A DeviceN color of process inks `named` (indices in plate order) at their `cmyk` tints. + fn process_device_n(&self, named: &[usize], cmyk: [f32; 4]) -> Option { + let colorants = named.iter().map(|&i| Process::ALL.get(i).map(|p| process_colorant(*p))).collect::>>()?; + let tints = named.iter().map(|&i| cmyk.get(i).copied().map(byte)).collect::>>()?; + Some(device_n::Color::new(tints, DeviceNSpace::new(colorants)?)?.into()) + } + + /// Stops in one color space: each lists every ink some stop lists, so krilla writes them + /// all in the space a color of those inks gets. + fn gradient(&self, stops: &[(f32, InkColor)], geometry: &GradientGeometry, overprint: Overprint, ctm: Affine) -> Result { + let mut resolved: Vec<(f32, BTreeMap)> = Vec::with_capacity(stops.len()); + for (offset, color) in stops { + let InkColor::Tints(tints) = self.inks.resolve(color, self.cms) else { + return Err(WriteError::Unsupported("a gradient stop of the All or None colorant")); + }; + let mut merged = BTreeMap::new(); + for (id, t) in tints { + let v = merged.entry(id).or_insert(0.0); + *v = 1.0 - (1.0 - *v) * (1.0 - unit(t)); + } + resolved.push((*offset, merged)); + } + let all: BTreeSet = resolved.iter().flat_map(|(_, m)| m.keys().copied()).collect(); + self.fits(all.len())?; + let partial_process = overprint.fill && all.len() < 4 && all.iter().all(|id| id.index() < 4); + let mut krilla_stops = Vec::with_capacity(resolved.len()); + for ((offset, merged), (_, original)) in resolved.iter().zip(stops) { + let tint = |id: &InkId| merged.get(id).copied().unwrap_or(0.0); + let color = if partial_process { + let named: Vec = all.iter().map(|id| id.index()).collect(); + let mut cmyk = [0.0; 4]; + for id in &all { + if let Some(c) = cmyk.get_mut(id.index()) { + *c = tint(id); + } + } + self.process_device_n(&named, cmyk) + } else { + to_pdf_color(&InkColor::Tints(all.iter().map(|id| (*id, tint(id))).collect()), self.inks, self.cms) + }; + let color = color.ok_or_else(|| WriteError::Color(original.clone()))?; + krilla_stops.push(Stop { offset: normalized(*offset), color, opacity: NormalizedF32::ONE }); + } + let to_drawing = |t: &Affine| to_transform(ctm.inverse() * *t); + Ok(match geometry { + GradientGeometry::Axial { start, end, extend: [true, true], transform } => LinearGradient { + x1: start.x as f32, + y1: start.y as f32, + x2: end.x as f32, + y2: end.y as f32, + transform: to_drawing(transform), + spread_method: SpreadMethod::Pad, + stops: krilla_stops, + anti_alias: false, + } + .into(), + GradientGeometry::Radial { start, start_radius, end, end_radius, extend: [true, true], transform } => RadialGradient { + fx: start.x as f32, + fy: start.y as f32, + fr: *start_radius as f32, + cx: end.x as f32, + cy: end.y as f32, + cr: *end_radius as f32, + transform: to_drawing(transform), + spread_method: SpreadMethod::Pad, + stops: krilla_stops, + anti_alias: false, + } + .into(), + _ => return Err(WriteError::Unsupported("a gradient that doesn't extend at both ends")), + }) + } + + /// Ink `id` as a DeviceN colorant; a spot carries its CMYK alternate. + fn colorant(&self, id: InkId) -> Option { + match self.inks.get(id)? { + Ink::Process(p) => Some(process_colorant(*p)), + Ink::Spot(spot) => { + let [c, m, y, k] = self.inks.spot_cmyk(id, self.cms)?.map(byte); + (!spot.name.is_empty()).then(|| Colorant::Spot { name: spot.name.clone(), alternate: cmyk::Color::new(c, m, y, k) }) + } + } + } +} + +/// A soft mask of `opacity` everywhere: an empty luminosity group whose transfer function +/// maps every value to `opacity`. +fn constant_mask(s: &mut Surface<'_>, opacity: f32) -> KMask { + let builder = s.stream_builder(); + let stream = builder.finish(); + let o = normalized(opacity); + KMask::new(stream, MaskType::Luminosity).with_transfer(TransferFunction::Sampled(vec![o, o])) +} + +fn with_overprint(s: &mut Surface<'_>, overprint: Overprint, draw: impl FnOnce(&mut Surface<'_>)) { + if overprint == Overprint::default() { + draw(s); + return; + } + s.push_overprint(kblend::Overprint { fill: overprint.fill, stroke: overprint.stroke, nonzero: overprint.nonzero }); + draw(s); + s.pop(); +} + +fn process_colorant(p: Process) -> Colorant { + Colorant::Process(match p { + Process::Cyan => device_n::Process::Cyan, + Process::Magenta => device_n::Process::Magenta, + Process::Yellow => device_n::Process::Yellow, + Process::Black => device_n::Process::Black, + }) +} + +fn blend_mode(b: BlendMode) -> kblend::BlendMode { + use kblend::BlendMode as K; + match b { + BlendMode::Normal => K::Normal, + BlendMode::Multiply => K::Multiply, + BlendMode::Screen => K::Screen, + BlendMode::Overlay => K::Overlay, + BlendMode::Darken => K::Darken, + BlendMode::Lighten => K::Lighten, + BlendMode::ColorDodge => K::ColorDodge, + BlendMode::ColorBurn => K::ColorBurn, + BlendMode::HardLight => K::HardLight, + BlendMode::SoftLight => K::SoftLight, + BlendMode::Difference => K::Difference, + BlendMode::Exclusion => K::Exclusion, + BlendMode::Hue => K::Hue, + BlendMode::Saturation => K::Saturation, + BlendMode::Color => K::Color, + BlendMode::Luminosity => K::Luminosity, + } +} + +fn to_transform(a: Affine) -> Transform { + let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32); + Transform::from_row(a, b, c, d, e, f) +} + +/// `None` for an empty path, which paints nothing. +fn to_path(path: &BezPath) -> Option { + let mut b = PathBuilder::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32), + PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32), + PathEl::ClosePath => b.close(), + } + } + b.finish() +} + +fn unit(v: f32) -> f32 { + if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } +} + +fn normalized(v: f32) -> NormalizedF32 { + NormalizedF32::new(unit(v)).unwrap_or(NormalizedF32::ONE) +} + +fn byte(v: f32) -> u8 { + (unit(v) * 255.0).round() as u8 +} + +/// Image samples for krilla. +#[derive(Clone)] +struct Samples { + samples: Arc<[u8]>, + alpha: Option>, + size: (u32, u32), + space: ImageColorspace, + device_n: Option, +} + +impl Hash for Samples { + fn hash(&self, state: &mut H) { + self.samples.hash(state); + self.alpha.hash(state); + self.size.hash(state); + self.space.hash(state); + self.device_n.hash(state); + } +} + +impl CustomImage for Samples { + fn color_channel(&self) -> &[u8] { + &self.samples + } + fn alpha_channel(&self) -> Option<&[u8]> { + self.alpha.as_deref() + } + fn bits_per_component(&self) -> BitsPerComponent { + BitsPerComponent::Eight + } + fn size(&self) -> (u32, u32) { + self.size + } + fn icc_profile(&self) -> Option<&[u8]> { + None + } + fn color_space(&self) -> ImageColorspace { + self.space + } + fn device_n_space(&self) -> Option { + self.device_n.clone() + } +} + +/// `im`'s JPEG data as a krilla image, written as it is (DCTDecode), when it has one that +/// decodes to its size. The embedded ICC profile is left out, as the samples path leaves it. +fn passthrough(im: &ImageItem) -> Option { + if im.smask.is_some() || !matches!(im.space, ImageSpace::Gray | ImageSpace::Cmyk) { + return None; + } + let data: Arc + Send + Sync> = Arc::new(im.jpeg.clone()?); + let image = Image::from_jpeg_with_icc(Data::from(data), None, false).ok()?; + (image.size() == im.size).then_some(image) +} diff --git a/crates/prepress/src/ink.rs b/crates/prepress/src/ink.rs index 22dfa12f..2dc3b472 100644 --- a/crates/prepress/src/ink.rs +++ b/crates/prepress/src/ink.rs @@ -99,7 +99,7 @@ impl InkId { } /// The id of process ink `p`, the same in every [`InkSet`]. - pub fn process(p: Process) -> Self { + pub const fn process(p: Process) -> Self { Self(p as u16) } } diff --git a/crates/prepress/src/lib.rs b/crates/prepress/src/lib.rs index 24a7d941..4100696b 100644 --- a/crates/prepress/src/lib.rs +++ b/crates/prepress/src/lib.rs @@ -7,6 +7,8 @@ pub mod cms; pub mod composite; pub mod coverage; +#[cfg(feature = "flatten")] +pub mod flatten; pub mod ink; pub mod plates; diff --git a/crates/prepress/src/plates.rs b/crates/prepress/src/plates.rs index 0f976a1b..332c8b6b 100644 --- a/crates/prepress/src/plates.rs +++ b/crates/prepress/src/plates.rs @@ -222,7 +222,7 @@ fn plate_scale(w: f32, h: f32, scale: f32) -> f32 { if s.is_finite() && s > 0.0 { s } else { f32::MIN_POSITIVE } } -fn fallback(fallback: &Fallback) -> Result<(Option, Conversion), Error> { +pub(crate) fn fallback(fallback: &Fallback) -> Result<(Option, Conversion), Error> { let (conversion, icc): (CmsConversion, &[u8]) = match fallback { Fallback::DeviceFormulas => return Ok((None, Conversion::DeviceFormulas)), Fallback::Fogra39 => (CmsConversion::fogra39(), craft_cms::builtin::COATED_FOGRA39_ICC), @@ -264,7 +264,7 @@ fn text_entry(dict: &Dict<'_>, key: &[u8]) -> Option { /// A PDF text string (ISO 32000-2 §7.9.2.2): UTF-16BE or UTF-8 after its byte order mark, else /// PDFDocEncoding, read here as Latin-1 (they differ only in rarely used punctuation). -fn pdf_text(bytes: &[u8]) -> String { +pub(crate) fn pdf_text(bytes: &[u8]) -> String { if let Some(utf16) = bytes.strip_prefix(&[0xFE, 0xFF]) { let units: Vec = utf16.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); return String::from_utf16_lossy(&units); @@ -278,18 +278,8 @@ fn pdf_text(bytes: &[u8]) -> String { /// The printing condition of page `page` (ISO 32000-2 §14.11.5): of the page's own /// `/OutputIntents` (PDF 2.0), else the catalog's, the first whose `/DestOutputProfile` is a /// CMYK profile craft-cms converts into, a `GTS_PDFX` intent before the others. -fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> { - fn list<'a>(intents: Option>) -> Vec> { - intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() - } - let xref = pdf.xref(); - let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); - if listed.is_empty() { - listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); - } - // A stable sort: the PDF/X intent first, the others in their order. - listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX")); - listed.iter().find_map(|intent| { +pub(crate) fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> { + output_intents(pdf, page).iter().find_map(|intent| { let icc = intent.get::>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?; let simulation = CmsConversion::new(&icc).ok()?.simulation(&icc); let condition = text_entry(intent, OUTPUT_CONDITION_IDENTIFIER); @@ -303,6 +293,50 @@ fn output_intent(pdf: &Pdf, page: usize) -> Option<(CmykSimulation, Conversion)> }) } +/// The output intent [`output_intent`] picks, as written in the PDF. +#[cfg(feature = "flatten")] +pub(crate) struct IntentProfile { + pub icc: Vec, + pub identifier: Option, + pub condition: Option, + pub registry_name: Option, + pub info: Option, +} + +/// The output intent [`output_intent`] picks: its profile bytes and entries, to write the same +/// intent again. +#[cfg(feature = "flatten")] +pub(crate) fn output_intent_profile(pdf: &Pdf, page: usize) -> Option { + output_intents(pdf, page).iter().find_map(|intent| { + let icc = intent.get::>(DEST_OUTPUT_PROFILE)?.decoded_within(MAX_PROFILE_BYTES).ok()?; + if *icc != *craft_cms::builtin::COATED_FOGRA39_ICC && CmsConversion::new(&icc).is_err() { + return None; + } + Some(IntentProfile { + icc: icc.into_owned(), + identifier: text_entry(intent, OUTPUT_CONDITION_IDENTIFIER), + condition: text_entry(intent, OUTPUT_CONDITION), + registry_name: text_entry(intent, b"RegistryName"), + info: text_entry(intent, b"Info"), + }) + }) +} + +/// The page's own `/OutputIntents` (PDF 2.0), else the catalog's, a `GTS_PDFX` intent first. +fn output_intents(pdf: &Pdf, page: usize) -> Vec> { + fn list<'a>(intents: Option>) -> Vec> { + intents.iter().flat_map(|a| a.iter::>()).take(MAX_OUTPUT_INTENTS).collect() + } + let xref = pdf.xref(); + let mut listed = list(pdf.pages().get(page).and_then(|p| p.raw().get::>(OUTPUT_INTENTS))); + if listed.is_empty() { + listed = list(xref.get::>(xref.root_id()).and_then(|catalog| catalog.get::>(OUTPUT_INTENTS))); + } + // A stable sort: the PDF/X intent first, the others in their order. + listed.sort_by_key(|d| d.get::>(S).is_none_or(|s| s.as_ref() != b"GTS_PDFX")); + listed +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/prepress/tests/fixtures/cyan-cmyk.jpg b/crates/prepress/tests/fixtures/cyan-cmyk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4cd81d1309447108e4e038943734fa801ff197c3 GIT binary patch literal 297 zcmb7;$qm9V6h!BHAx^wt2Ro!2gt$@#f-@CS00nU8fT)3`2U;*rE=alYG^eNeOMc`J z+5~-vVLN--3Q>HOSH(FcQdh3GI+IK!IVoJ%(mwgxeHl{c% zAzo&Ltfj1@)i(Z3z9K4kctHUy5Dz2*@(mq`Xn6LAs*8_)VllQ$dE%?F9VWK4UvPrT REgl50!pYyymn}f~`2~juBFg{( literal 0 HcmV?d00001 diff --git a/crates/prepress/tests/fixtures/gray-25.jpg b/crates/prepress/tests/fixtures/gray-25.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21fcda8f3d288cb80d1faacc96f05c4961428cc1 GIT binary patch literal 162 zcmY+8u@S;B6hq};lYjn>`3`o-HWb|D4whmH_Mu=1NE$ZzI8^YYN`)kS>Kk&I&J&CQ zOciv&37e)_U9r`UoU_*Xw%tw`&>#1yTdr-vCk#z9ZV32F$wxW X`S#-%s&SS&_>az@^~(`AT=n$ Option> { + let dir = std::env::var_os(reference::FONT_DIR_ENV) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?; + let font = std::fs::read(dir.join(reference::FONT_FILE)).ok(); + if font.is_none() { + eprintln!("skipped: no {} in {}", reference::FONT_FILE, dir.display()); + } + font +} + +fn fill(c: u8, m: u8, y: u8, k: u8) -> Fill { + Fill { paint: cmyk::Color::new(c, m, y, k).into(), ..Default::default() } +} + +fn rect(s: &mut Surface<'_>, x0: f32, y0: f32, x1: f32, y1: f32, f: Fill) { + let mut b = PathBuilder::new(); + b.push_rect(tiny_rect(x0, y0, x1, y1)); + s.set_fill(Some(f)); + s.draw_path(&b.finish().unwrap()); +} + +fn tiny_rect(x0: f32, y0: f32, x1: f32, y1: f32) -> craft_pdf::write::backend::geom::Rect { + KRect::from_ltrb(x0, y0, x1, y1).unwrap() +} + +fn text(s: &mut Surface<'_>, font: &Font, face: &ttf_parser::Face<'_>, at: (f32, f32), size: f32, string: &str) { + let upem = f32::from(face.units_per_em()); + let glyphs: Vec = string + .char_indices() + .map(|(i, c)| { + let id = face.glyph_index(c).unwrap(); + let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem; + KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None) + }) + .collect(); + s.set_fill(Some(fill(0, 0, 0, 255))); + s.draw_glyphs(Point::from_xy(at.0, at.1), &glyphs, font.clone(), string, size, false); +} + +/// Three pages of different sizes: transparency beside text, no transparency, and transparency +/// over text in page 1's font. Page 1 has a TrimBox and a BleedBox. +fn three_pages(font_bytes: &[u8]) -> Vec { + let face = ttf_parser::Face::parse(font_bytes, 0).unwrap(); + let font = craft_pdf::write::font(font_bytes, 0, None).unwrap(); + let metadata = Metadata { + title: Some("Flatten test".into()), + authors: vec!["A. Writer".into()], + created: Some(DateTime::new(2026).month(10).day(20).hour(9).minute(30).second(0).utc_offset_hour(2)), + modified: Some(DateTime::new(2026).month(10).day(21).hour(16).minute(5).second(0).utc_offset_hour(2)), + ..Metadata::default() + }; + // DeviceCMYK blending and no output intent: parsing a profile takes half a second in a debug build. + let settings = SerializeSettings { no_device_cs: false, group_color_space: GroupColorSpace::Cmyk, ..SerializeSettings::default() }; + let mut writer = Writer::new(settings, metadata); + let mut page = |settings: PageSettings, draw: &dyn Fn(&mut Surface<'_>)| { + let mut page = writer.document_mut().start_page_with(settings); + let mut s = page.surface(); + draw(&mut s); + s.finish(); + page.finish(); + }; + let size = |i: usize| PageSettings::from_wh(SIZES[i].0, SIZES[i].1).unwrap(); + page(size(0).with_trim_box(Some(tiny_rect(10.0, 10.0, 190.0, 150.0))).with_bleed_box(Some(tiny_rect(5.0, 5.0, 195.0, 155.0))), &|s| { + rect(s, 20.0, 20.0, 120.0, 100.0, fill(255, 0, 0, 0)); + s.push_opacity(NormalizedF32::new(0.5).unwrap()); + rect(s, 70.0, 60.0, 170.0, 130.0, fill(0, 255, 0, 0)); + s.pop(); + text(s, &font, &face, (10.0, 150.0), 10.0, "Hello"); + }); + page(size(1), &|s| { + rect(s, 10.0, 10.0, 140.0, 90.0, fill(0, 0, 255, 0)); + text(s, &font, &face, (10.0, 150.0), 14.0, "Opaque"); + }); + page(size(2), &|s| { + text(s, &font, &face, (20.0, 60.0), 30.0, "Hello"); + s.push_opacity(NormalizedF32::new(0.4).unwrap()); + rect(s, 40.0, 25.0, 120.0, 80.0, fill(255, 0, 0, 0)); + s.pop(); + text(s, &font, &face, (20.0, 110.0), 12.0, "World"); + }); + drop(page); + writer.finish().unwrap() +} + +fn opts() -> FlattenDocumentOptions { + FlattenDocumentOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenDocumentOptions::default() } +} + +fn separate(pdf: &[u8], page: usize) -> Plates { + plates(pdf, page, DPI, &Options::default()).unwrap() +} + +fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 { + let s = p.dpi / 72.0; + p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0) +} + +/// No transparency groups, soft masks in graphics states, blend modes or constant alpha. +fn assert_opaque(pdf: &[u8]) { + let s = structure::summarize(pdf).unwrap(); + assert!(s.groups.iter().all(|g| g.s != "Transparency"), "transparency groups: {:?}", s.groups); + for gs in &s.ext_g_states { + assert!(gs.smask.as_deref().is_none_or(|m| m == "None" || m.is_empty()), "soft mask: {gs:?}"); + assert!(gs.bm.as_deref().is_none_or(|b| b == "Normal" || b == "Compatible"), "blend mode: {gs:?}"); + assert!(gs.alpha_fill.is_none_or(|a| a >= 1.0) && gs.alpha_stroke.is_none_or(|a| a >= 1.0), "alpha: {gs:?}"); + } +} + +fn has_text(pdf: &craft_pdf::syntax::Pdf, page: usize) -> bool { + let pages = pdf.pages(); + let mut ops = pages.get(page).unwrap().operations(); + let mut found = false; + while let Some(op) = ops.next() { + found |= matches!(&**op.operator, b"Tj" | b"TJ"); + } + found +} + +fn count(haystack: &[u8], needle: &[u8]) -> usize { + haystack.windows(needle.len()).filter(|w| *w == needle).count() +} + +fn page_box(pdf: &craft_pdf::syntax::Pdf, page: usize, key: &[u8]) -> Option<[f64; 4]> { + pdf.pages().get(page)?.raw().get::(key).map(|r| [r.x0, r.y0, r.x1, r.y1]) +} + +#[test] +fn a_document_flattens_page_by_page_and_keeps_boxes_metadata_and_shared_fonts() { + let Some(font) = inter() else { return }; + let source = three_pages(&font); + let start = Instant::now(); + let (out, report) = flatten_document(&source, &opts()).unwrap(); + eprintln!("flatten_document: {:?}, totals {:?}", start.elapsed(), report.totals); + + let doc = craft_pdf::open_slice(&out, None).unwrap(); + let src = craft_pdf::open_slice(&source, None).unwrap(); + assert_eq!(doc.pages().len(), 3); + for (i, (w, h)) in SIZES.iter().enumerate() { + assert_eq!(doc.pages()[i].render_dimensions(), (*w, *h), "page {i}"); + } + assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::>(), [0, 1, 2]); + assert!(report.pages[0].1.flatten.flattened > 0 && report.pages[2].1.flatten.flattened > 0); + assert_eq!(report.pages[1].1.flatten.flattened, 0); + assert!(report.unflattened.is_empty() && report.other_intents.is_empty()); + + assert_opaque(&out); + for page in 0..3 { + assert!(has_text(&doc, page), "page {page} has live text"); + } + + // One embedded program for the font all three pages use. + assert_eq!(count(&source, b"/FontFile2"), 1); + assert_eq!(count(&out, b"/FontFile2"), 1, "the shared font is embedded once"); + + for key in [TRIM_BOX, BLEED_BOX] { + assert_eq!(page_box(&doc, 0, key), page_box(&src, 0, key)); + assert!(page_box(&doc, 0, key).is_some()); + assert_eq!(page_box(&doc, 1, key), None); + } + + let info = doc.metadata(); + assert_eq!(info.title.as_deref(), Some(b"Flatten test".as_slice())); + assert_eq!(info.author.as_deref(), Some(b"A. Writer".as_slice())); + let created = info.creation_date.unwrap(); + assert_eq!((created.year, created.month, created.day, created.hour, created.minute), (2026, 10, 20, 9, 30)); + let modified = info.modification_date.unwrap(); + assert_eq!((modified.year, modified.month, modified.day, modified.hour, modified.minute), (2026, 10, 21, 16, 5)); + + let points: [&[(f32, f32)]; 3] = [ + &[(40.0, 40.0), (100.0, 80.0), (150.0, 120.0), (190.0, 15.0)], + &[(50.0, 50.0), (100.0, 180.0)], + &[(110.0, 30.0), (115.0, 75.0), (180.0, 50.0)], + ]; + for (page, points) in points.iter().enumerate() { + let (a, b) = (separate(&source, page), separate(&out, page)); + for &(x, y) in *points { + for ink in ["Cyan", "Magenta", "Yellow", "Black"] { + let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y)); + assert!(want.abs_diff(got) <= 3, "page {page} {ink} at ({x}, {y}): {got}, the source has {want}"); + } + } + } + + let single: usize = (0..3) + .map(|p| { + let opts = FlattenPageOptions { flatten: opts().flatten, ..FlattenPageOptions::default() }; + flatten_page(&source, p, &opts).unwrap().0.len() + }) + .sum(); + eprintln!("source {} bytes; flattened document {} bytes; three flatten_page outputs {single} bytes", source.len(), out.len()); +} + +#[test] +fn a_page_range_flattens_only_those_pages() { + let Some(font) = inter() else { return }; + let t = Instant::now(); + let source = three_pages(&font); + eprintln!("three_pages {:?}", t.elapsed()); + let mut calls = Vec::new(); + let opts = FlattenDocumentOptions { pages: Some(1..3), ..opts() }; + let t = Instant::now(); + let (out, report) = flatten_document_with_progress(&source, &opts, &mut |done, total| calls.push((done, total))).unwrap(); + eprintln!("flatten {:?}", t.elapsed()); + let doc = craft_pdf::open_slice(&out, None).unwrap(); + assert_eq!(doc.pages().len(), 2); + assert_eq!(doc.pages()[0].render_dimensions(), SIZES[1]); + assert_eq!(doc.pages()[1].render_dimensions(), SIZES[2]); + assert_eq!(report.pages.iter().map(|(p, _)| *p).collect::>(), [1, 2]); + assert_eq!(calls, [(1, 2), (2, 2)]); + + let opts = FlattenDocumentOptions { pages: Some(2..4), ..FlattenDocumentOptions::default() }; + assert!(flatten_document(&source, &opts).is_err()); +} + +#[test] +fn the_source_output_intent_is_written_once() { + let color = PrintColor { + profile: craft_cms::builtin::COATED_CMYK_ICC, + condition_identifier: "Test coated", + condition: Some("A coated test condition"), + registry_name: None, + info: Some("A coated test condition"), + }; + let mut writer = Writer::new(print::settings(color).unwrap(), Metadata::default()); + for _ in 0..2 { + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap()); + let mut s = page.surface(); + rect(&mut s, 10.0, 10.0, 60.0, 60.0, fill(255, 0, 0, 0)); + s.push_opacity(NormalizedF32::new(0.5).unwrap()); + rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255)); + s.pop(); + s.finish(); + page.finish(); + } + let source = writer.finish().unwrap(); + let (out, report) = flatten_document(&source, &opts()).unwrap(); + assert!(report.other_intents.is_empty()); + assert_opaque(&out); + + let doc = craft_pdf::open_slice(&out, None).unwrap(); + let xref = doc.xref(); + let catalog = xref.get::>(xref.root_id()).unwrap(); + let intents: Vec> = catalog.get::>(OUTPUT_INTENTS).unwrap().iter::>().collect(); + assert_eq!(intents.len(), 1); + let id = intents[0].get::>(OUTPUT_CONDITION_IDENTIFIER).unwrap(); + assert_eq!(id.as_bytes(), b"Test coated"); + let icc = intents[0].get::>(DEST_OUTPUT_PROFILE).unwrap().decoded().unwrap(); + assert_eq!(&*icc, craft_cms::builtin::COATED_CMYK_ICC); + assert_eq!(count(&out, b"/DestOutputProfile"), 1); +} + +/// PDF/X-1a output: the source's DeviceN fill (cyan and a spot, NChannel at 1.7) is written at +/// 1.4 without the 1.6 NChannel attributes, and the PDF/X version goes to Info and XMP. +#[test] +fn pdf_x1a_output_is_pdf_14_without_nchannel() { + let gold = cmyk::Color::new(0, 51, 255, 26); + let space = DeviceNSpace::new(vec![Colorant::Process(Process::Cyan), Colorant::Spot { name: "Gold".to_owned(), alternate: gold }]).unwrap(); + let color = device_n::Color::new(vec![255, 128], space).unwrap(); + let mut writer = Writer::new(print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap()); + let mut s = page.surface(); + rect(&mut s, 10.0, 10.0, 60.0, 60.0, Fill { paint: backend::color::Color::from(color).into(), ..Default::default() }); + s.push_opacity(NormalizedF32::new(0.5).unwrap()); + rect(&mut s, 30.0, 30.0, 90.0, 90.0, fill(0, 0, 0, 255)); + s.pop(); + s.finish(); + page.finish(); + let source = writer.finish().unwrap(); + assert!(count(&source, b"/NChannel") > 0); + + let opts = FlattenDocumentOptions { + version: PdfVersion::Pdf14, + pdfx_version: Some("PDF/X-1:2003".to_owned()), + pdfx_conformance: Some("PDF/X-1a:2003".to_owned()), + ..opts() + }; + let (out, _) = flatten_document(&source, &opts).unwrap(); + assert_opaque(&out); + assert!(out.starts_with(b"%PDF-1.4")); + assert_eq!(count(&out, b"NChannel"), 0); + assert_eq!(count(&out, b"/GTS_PDFXVersion(PDF/X-1:2003)"), 1); + assert_eq!(count(&out, b"/GTS_PDFXConformance(PDF/X-1a:2003)"), 1); + assert_eq!(count(&out, b"PDF/X-1:2003"), 1); + assert_eq!(count(&out, b"1.4"), 1); + let gold_plate = separate(&out, 0); + assert!(tint(&gold_plate, "Gold", 20.0, 20.0) > 100); +} + +/// A 16 × 16 Adobe CMYK JPEG of C 100%, and a 16 × 16 gray JPEG of 25% gray (ImageMagick). +const CYAN_JPEG: &[u8] = include_bytes!("fixtures/cyan-cmyk.jpg"); +const GRAY_JPEG: &[u8] = include_bytes!("fixtures/gray-25.jpg"); + +#[test] +fn untouched_jpeg_images_are_written_as_they_are() { + let image = |cs: &str, decode: &str, data: &[u8]| { + let mut obj = format!( + "<< /Type /XObject /Subtype /Image /Width 16 /Height 16 /ColorSpace /{cs} {decode} /BitsPerComponent 8 \ + /Filter /DCTDecode /Length {} >> stream\n", + data.len() + ) + .into_bytes(); + obj.extend_from_slice(data); + obj.extend_from_slice(b"\nendstream"); + obj + }; + // The images, and a half-transparent black square away from them. + let content = "q 50 0 0 50 10 10 cm /Im1 Do Q q 50 0 0 50 70 10 cm /Im2 Do Q \ + q /GS1 gs 0 0 0 1 k 140 10 50 50 re f Q"; + let objects = + [image("DeviceCMYK", "/Decode [1 0 1 0 1 0 1 0]", CYAN_JPEG), image("DeviceGray", "", GRAY_JPEG), b"<< /Type /ExtGState /ca 0.5 >>".to_vec()]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + pdf.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + pdf.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + pdf.extend_from_slice( + b"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 70] /Contents 4 0 R \ + /Resources << /XObject << /Im1 5 0 R /Im2 6 0 R >> /ExtGState << /GS1 7 0 R >> >> >> endobj\n", + ); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + pdf.extend_from_slice(format!("{} 0 obj ", i + 5).as_bytes()); + pdf.extend_from_slice(body); + pdf.extend_from_slice(b" endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + + let (out, report) = flatten_document(&pdf, &opts()).unwrap(); + assert_eq!(report.totals.images, 2); + assert!(report.totals.flattened > 0); + assert_opaque(&out); + assert_eq!(count(&out, CYAN_JPEG), 1, "the CMYK JPEG is written as it is"); + assert_eq!(count(&out, GRAY_JPEG), 1, "the gray JPEG is written as it is"); + assert_eq!(count(&out, b"/DCTDecode"), 2); + let (a, b) = (separate(&pdf, 0), separate(&out, 0)); + for (x, y) in [(35.0, 35.0), (95.0, 35.0), (165.0, 35.0)] { + for ink in ["Cyan", "Magenta", "Yellow", "Black"] { + let (want, got) = (tint(&a, ink, x, y), tint(&b, ink, x, y)); + assert!(want.abs_diff(got) <= 3, "{ink} at ({x}, {y}): {got}, the source has {want}"); + } + } + assert!(tint(&b, "Cyan", 35.0, 35.0) > 240 && tint(&b, "Black", 95.0, 35.0) > 150); +} diff --git a/crates/prepress/tests/flatten_pdf.rs b/crates/prepress/tests/flatten_pdf.rs new file mode 100644 index 00000000..0f8dfc45 --- /dev/null +++ b/crates/prepress/tests/flatten_pdf.rs @@ -0,0 +1,613 @@ +//! The PDF-in adapter on plate-test scenes and hand-written pages: display list structure, ink +//! colors, overprint and group attributes, and plates of opaque items written back. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_pdf::write::backend::blend::Overprint as KOverprint; +use craft_pdf::write::backend::geom::PathBuilder; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::backend::paint::{Fill, FillRule as KFillRule}; +use craft_pdf::write::{Metadata, Writer}; +use craft_plate_test::reference::{self, Context}; +use craft_plate_test::scene; +use craft_prepress::flatten::kurbo::{BezPath, PathEl, Point, Shape}; +use craft_prepress::flatten::pdf::{ReadOptions, ReadReport, read_page}; +use craft_prepress::flatten::{ + BlendMode, DisplayList, FillItem, FillRule, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, MaskKind, Paint, +}; +use craft_prepress::{CmsConversion, InkColor, InkId, Options, Overprint, Process, plates, to_pdf_color}; + +fn scene_pdf(id: &str) -> Vec { + let scenes = scene::load_dir(&scene::scenes_dir()).unwrap(); + let s = scenes.iter().find(|s| s.id == id).unwrap(); + reference::write(s, &Context { scenes: &scenes, font: None }).unwrap().pdf +} + +fn read(pdf: &[u8]) -> (DisplayList, ReadReport) { + read_page(pdf, 0, &ReadOptions::default()).unwrap() +} + +/// Every non-group item with the groups it is in, outermost first. +fn leaves(items: &[Item]) -> Vec<(Vec<&GroupItem>, &Item)> { + fn walk<'a>(items: &'a [Item], path: &mut Vec<&'a GroupItem>, out: &mut Vec<(Vec<&'a GroupItem>, &'a Item)>) { + for item in items { + match item { + Item::Group(g) => { + path.push(g); + walk(&g.children, path, out); + path.pop(); + } + other => out.push((path.clone(), other)), + } + } + } + let mut out = Vec::new(); + walk(items, &mut Vec::new(), &mut out); + out +} + +fn fill_items(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &FillItem)> { + leaves(&list.items) + .into_iter() + .filter_map(|(groups, item)| match item { + Item::Fill(f) => Some((groups, f)), + _ => None, + }) + .collect() +} + +fn ink(p: Process) -> InkId { + InkId::process(p) +} + +fn cmyk_color(c: f32, m: f32, y: f32, k: f32) -> InkColor { + InkColor::Tints(vec![(ink(Process::Cyan), c), (ink(Process::Magenta), m), (ink(Process::Yellow), y), (ink(Process::Black), k)]) +} + +fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint { + Paint::Solid(cmyk_color(c, m, y, k)) +} + +fn op(fill: bool, nonzero: bool) -> Overprint { + Overprint { fill, stroke: fill, nonzero } +} + +#[test] +fn black_overprints_cyan() { + let (list, report) = read(&scene_pdf("S01")); + let fills = fill_items(&list); + assert_eq!(fills.len(), 2, "two rects (text is skipped without a font): {fills:?}"); + assert_eq!(fills[0].1.paint, cmyk(1.0, 0.0, 0.0, 0.0)); + assert_eq!(fills[0].1.overprint, op(false, false)); + assert_eq!(fills[1].1.paint, cmyk(0.0, 0.0, 0.0, 1.0)); + assert_eq!(fills[1].1.overprint, op(true, true)); + assert_eq!(fills[1].1.path.bounding_box().size().width.round(), 100.0); + assert_eq!((report.images, report.gradients), (0, 0)); +} + +#[test] +fn spot_inks_join_the_set() { + let (list, _) = read(&scene_pdf("S04")); + let gold = list.inks.lookup("Gold").unwrap(); + let fills = fill_items(&list); + let Paint::Solid(InkColor::Tints(tints)) = &fills[1].1.paint else { panic!() }; + assert!(matches!(tints[..], [(id, t)] if id == gold && (t - 0.5).abs() < 0.01), "{tints:?}"); + assert!(fills[1].1.overprint.fill); + let alternate = list.inks.spots().find(|(id, _)| *id == gold).unwrap().1.alternate; + assert_eq!(alternate, craft_prepress::Alternate::Cmyk([0.0, 0.2, 0.6, 0.2])); + + // An overprinting stroke carries `OP` in `fill`. + let (list, _) = read(&scene_pdf("D06")); + let gold = list.inks.lookup("Gold").unwrap(); + let stroke = fill_items(&list).into_iter().find(|(_, f)| f.paint == Paint::Solid(InkColor::Tints(vec![(gold, 1.0)]))).unwrap().1; + assert_eq!(stroke.overprint, op(true, false)); + // The 10 pt stroke outlined around a 110 × 60 rect. + assert_eq!(stroke.path.bounding_box().size().width.round(), 120.0); +} + +#[test] +fn registration_is_all_inks() { + let (list, _) = read(&scene_pdf("S13")); + let all: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == Paint::Solid(InkColor::All(1.0))).collect(); + assert_eq!(all.len(), 2, "the registration fill and stroke"); +} + +#[test] +fn knockout_and_isolated_groups() { + let (list, _) = read(&scene_pdf("S09")); + let fills = fill_items(&list); + let ellipses: Vec<_> = fills.iter().filter(|(groups, _)| groups.iter().any(|g| g.knockout)).collect(); + assert_eq!(ellipses.len(), 2); + for (groups, _) in ellipses { + // The writer puts each ellipse in an opacity group of its own, inside the knockout group. + let at = groups.iter().position(|g| g.knockout).unwrap(); + assert!(groups[at + 1..].iter().any(|g| g.opacity == 0.5)); + } + + let (list, _) = read(&scene_pdf("S10")); + let fills = fill_items(&list); + let magenta = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap(); + assert!(magenta.0.iter().any(|g| g.isolated)); + assert_eq!(magenta.0.last().unwrap().blend, BlendMode::Multiply); + let yellow = fills.iter().find(|(_, f)| f.paint == cmyk(0.0, 0.0, 1.0, 0.0)).unwrap(); + assert!(yellow.0.iter().any(|g| g.isolated)); + assert!(yellow.0.iter().all(|g| g.blend == BlendMode::Normal)); +} + +#[test] +fn soft_masks_keep_backdrop_and_transfer() { + let (list, _) = read(&scene_pdf("M1")); + let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap(); + let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap(); + assert_eq!(mask.kind, MaskKind::Luminosity); + assert!(mask.group.isolated); + assert_eq!(leaves(&mask.group.children).len(), 1, "the mask's rect"); + // The scene's backdrop is white (no black ink). + assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 0.0))); + + let (list, _) = read(&scene_pdf("M2")); + let (groups, _) = fill_items(&list).into_iter().find(|(_, f)| f.paint == cmyk(0.0, 1.0, 0.0, 0.0)).unwrap(); + let mask = groups.iter().find_map(|g| g.mask.as_deref()).unwrap(); + let transfer = mask.transfer.as_deref().unwrap(); + assert_eq!((transfer.len(), transfer[0], transfer[255]), (256, 255, 0)); +} + +#[test] +fn shadow_over_spot_counts_images() { + let (list, report) = read(&scene_pdf("S11")); + assert_eq!(report.images, 3, "the split image and two feather images in masks"); + let shadows: Vec<_> = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(0.0, 0.0, 0.0, 1.0)).collect(); + assert_eq!(shadows.len(), 2); + for (groups, _) in shadows { + let masked = groups.iter().find(|g| g.mask.is_some()).unwrap(); + assert_eq!(masked.blend, BlendMode::Multiply); + let mask = masked.mask.as_deref().unwrap(); + assert_eq!(mask.kind, MaskKind::Luminosity); + assert_eq!(mask.backdrop, Some(cmyk_color(0.0, 0.0, 0.0, 1.0))); + // The mask's content is the feather image, alpha and all. + let feathers: Vec<_> = + leaves(&mask.group.children).into_iter().filter_map(|(_, i)| if let Item::Image(im) = i { Some(im) } else { None }).collect(); + assert_eq!(feathers.len(), 1, "{:?}", mask.group.children); + assert!(groups.iter().any(|g| g.opacity == 0.75)); + } +} + +/// A one-page PDF from raw content and resources, objects numbered from 5. +fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + out.extend_from_slice( + format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(), + ); + out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes()); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len()) +} + +#[test] +fn text_clips_strokes_and_process_color() { + let to_unicode = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def /CMapType 2 def \ + 1 begincodespacerange <00> endcodespacerange 2 beginbfchar <41> <0041> <42> <0042> endbfchar \ + endcmap CMapName currentdict /CMap defineresource pop end end"; + let objects = [ + // 5: a Type 3 font with a shape glyph (A) and a colored one (B). + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /sq 6 0 R /cg 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /sq /cg] >> \ + /FirstChar 65 /LastChar 66 /Widths [1000 1000] /ToUnicode 8 0 R >>" + .to_string(), + stream("", "1000 0 0 0 1000 1000 d1 0 0 1000 1000 re f"), + stream("", "1000 0 d0 0 1 0 0 k 0 0 1000 1000 re f"), + stream("", to_unicode), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + let content = "BT /F1 20 Tf 10 90 Td (AAB) Tj ET \ + BT /F2 24 Tf 100 90 Td (Hi) Tj ET \ + q 0 0 100 50 re 25 10 50 30 re W* n 0 0 0 1 k 0 0 100 50 re f Q \ + 0.5 G 4 w [6 3] 0 d 110 10 m 290 10 l S \ + 1 0 0 rg 200 30 10 10 re f"; + let pdf = raw_pdf(content, "/Font << /F1 5 0 R /F2 9 0 R >>", &objects); + let (list, report) = read(&pdf); + let items = leaves(&list.items); + + // The two shape glyphs form one run; the colored glyph is a group with its text. + let Item::Text(t3) = items[0].1 else { panic!("{:?}", items[0]) }; + assert_eq!((t3.glyphs.len(), t3.text.as_deref(), t3.font.is_none()), (2, Some("AA"), true)); + assert_eq!(t3.size.round(), 20.0); + assert_eq!(t3.glyphs[1].origin.x.round(), 20.0, "origins in text space"); + assert_eq!(t3.paint, cmyk(0.0, 0.0, 0.0, 1.0), "DeviceGray 0 is solid black only"); + let colored = items[1].0.last().unwrap(); + assert_eq!(colored.actual_text.as_deref(), Some("B")); + assert!(matches!(items[1].1, Item::Fill(f) if f.paint == cmyk(0.0, 1.0, 0.0, 0.0))); + + let Item::Text(hi) = items[2].1 else { panic!("{:?}", items[2]) }; + assert_eq!(hi.text.as_deref(), Some("Hi")); + assert_eq!(hi.glyphs[1].text, 1..2); + assert!(!hi.outlines.is_empty()); + // Text space is y up: the run's transform flips y into page space. + assert!(hi.transform.as_coeffs()[3] < 0.0); + + // The even-odd clip, as a nonzero path with the hole kept. + let (groups, _) = &items[3]; + let clip = groups.last().unwrap().clip.as_ref().unwrap(); + assert_eq!(clip.winding(Point::new(50.0, 95.0)), 0, "the hole"); + assert_ne!(clip.winding(Point::new(10.0, 115.0)), 0); + + // The dashed stroke, outlined: 20 dashes of 6 pt in 180 pt, 4 pt wide. + let Item::Fill(dashes) = items[4].1 else { panic!() }; + assert_eq!(dashes.paint, cmyk(0.0, 0.0, 0.0, 0.5)); + assert_eq!(dashes.path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(), 20); + assert_eq!(dashes.path.bounding_box().height().round(), 4.0); + + // RGB converts through FOGRA39: red is mostly magenta and yellow. + let Item::Fill(red) = items[5].1 else { panic!() }; + let Paint::Solid(InkColor::Tints(tints)) = &red.paint else { panic!() }; + assert_eq!(tints.len(), 4); + assert!(tints[1].1 > 0.8 && tints[2].1 > 0.8 && tints[0].1 < 0.2, "{tints:?}"); + assert!(matches!(report.conversion, plates::Conversion::Fallback { .. })); + assert_eq!(items.len(), 6); +} + +fn images(list: &DisplayList) -> Vec<(Vec<&GroupItem>, &ImageItem)> { + leaves(&list.items) + .into_iter() + .filter_map(|(groups, item)| match item { + Item::Image(im) => Some((groups, im)), + _ => None, + }) + .collect() +} + +/// The ink tints of `im` at page point `p` (nearest sample). +fn tints_at(im: &ImageItem, p: Point) -> Vec<(InkId, f32)> { + let u = im.transform.inverse() * p; + let (w, h) = (im.size.0 as usize, im.size.1 as usize); + let (x, y) = (((u.x * w as f64) as usize).min(w - 1), ((u.y * h as f64) as usize).min(h - 1)); + let ids: Vec = match &im.space { + ImageSpace::Gray => vec![ink(Process::Black)], + ImageSpace::Cmyk => Process::ALL.iter().map(|p| ink(*p)).collect(), + ImageSpace::Inks(ids) => ids.clone(), + ImageSpace::Rgb => panic!("RGB image"), + }; + let n = if im.space == ImageSpace::Gray { 1 } else { ids.len() }; + let s = &im.samples[(y * w + x) * n..(y * w + x + 1) * n]; + if im.space == ImageSpace::Gray { + return vec![(ids[0], 1.0 - f32::from(s[0]) / 255.0)]; + } + ids.into_iter().zip(s).map(|(id, v)| (id, f32::from(*v) / 255.0)).collect() +} + +fn tint(tints: &[(InkId, f32)], id: InkId) -> f32 { + tints.iter().find(|t| t.0 == id).map_or(0.0, |t| t.1) +} + +fn hex_image(dict: &str, hex: &str) -> String { + stream(&format!("/Type /XObject /Subtype /Image /Filter /ASCIIHexDecode {dict}"), &format!("{hex}>")) +} + +const GOLD: &str = "[/Separation /Gold /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.2 0.6 0.2] /N 1 >>]"; + +#[test] +fn cmyk_image_keeps_its_samples() { + let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")]; + let pdf = raw_pdf("q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + let ims = images(&list); + assert_eq!(ims.len(), 1); + let im = ims[0].1; + assert_eq!((&im.space, im.size, &im.samples[..]), (&ImageSpace::Cmyk, (2, 1), &[255, 0, 0, 0, 0, 128, 64, 32][..])); + assert_eq!(im.transform * Point::ORIGIN, Point::new(10.0, 60.0), "(0, 0) is the top left sample"); + assert_eq!(im.transform * Point::new(1.0, 1.0), Point::new(110.0, 110.0)); + assert!(im.smask.is_none()); + assert_eq!((report.images, report.converted_images), (1, 0)); +} + +#[test] +fn cmyk_image_overprints_without_nonzero_mode() { + let objects = [hex_image("/Width 2 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", "FF000000 00804020")]; + let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects); + let (list, _) = read(&pdf); + assert_eq!(images(&list)[0].1.overprint, op(true, false), "nonzero overprint mode is not for images"); +} + +#[test] +fn separation_image_is_its_ink_with_decode_applied() { + let objects = [hex_image(&format!("/Width 2 /Height 1 /ColorSpace {GOLD} /BitsPerComponent 8 /Decode [1 0]"), "FF40")]; + let pdf = raw_pdf("/GS1 gs q 100 0 0 50 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >> /ExtGState << /GS1 << /op true /OPM 1 >> >>", &objects); + let (list, _) = read(&pdf); + let gold = list.inks.lookup("Gold").unwrap(); + let im = images(&list)[0].1; + assert_eq!(im.space, ImageSpace::Inks(vec![gold])); + assert_eq!(&im.samples[..], &[0, 0xBF]); + assert_eq!(im.overprint, op(true, false), "nonzero overprint mode is for DeviceCMYK only"); +} + +#[test] +fn rgb_image_converts_to_cmyk() { + let objects = [hex_image("/Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", "FF0000")]; + let pdf = raw_pdf("q 10 0 0 10 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + let im = images(&list)[0].1; + assert_eq!(im.space, ImageSpace::Cmyk); + assert!(im.samples[1] > 200 && im.samples[2] > 200 && im.samples[0] < 50, "{:?}", im.samples); + assert_eq!(report.converted_images, 1); +} + +#[test] +fn image_smask_is_resampled_to_the_image() { + let objects = [ + hex_image("/Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R", "00FF8040"), + hex_image("/Width 1 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", "FF00"), + ]; + let pdf = raw_pdf("q 20 0 0 20 10 10 cm /Im1 Do Q", "/XObject << /Im1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + let im = images(&list)[0].1; + assert_eq!((&im.space, &im.samples[..]), (&ImageSpace::Gray, &[0, 255, 128, 64][..])); + assert_eq!(im.smask.as_deref(), Some(&[255, 255, 0, 0][..])); + assert_eq!(report.resampled_masks, 1); +} + +#[test] +fn spot_stencil_is_a_traced_fill() { + // Sample 0 paints: the left half of each row. + let objects = [hex_image("/Width 8 /Height 2 /ImageMask true /BitsPerComponent 1", "0F0F")]; + let pdf = raw_pdf("/CS1 cs 1 scn q 80 0 0 20 10 10 cm /Im1 Do Q", &format!("/XObject << /Im1 5 0 R >> /ColorSpace << /CS1 {GOLD} >>"), &objects); + let (list, report) = read(&pdf); + let gold = list.inks.lookup("Gold").unwrap(); + let fills = fill_items(&list); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].1.paint, Paint::Solid(InkColor::Tints(vec![(gold, 1.0)]))); + let bbox = fills[0].1.path.bounding_box(); + assert_eq!((bbox.x0, bbox.x1, bbox.y0, bbox.y1), (10.0, 50.0, 90.0, 110.0)); + assert_eq!((report.stencils, images(&list).len()), (1, 0)); +} + +/// The gradient's ink values at offset `s`, interpolated between stops. +fn stop_tints(stops: &[(f32, InkColor)], s: f32) -> Vec<(InkId, f32)> { + let k = stops.iter().position(|st| st.0 >= s).unwrap(); + let (lo, hi) = (&stops[k.saturating_sub(1)], &stops[k]); + let u = if hi.0 > lo.0 { (s - lo.0) / (hi.0 - lo.0) } else { 1.0 }; + let (InkColor::Tints(a), InkColor::Tints(b)) = (&lo.1, &hi.1) else { panic!() }; + a.iter().zip(b).map(|(x, y)| (x.0, x.1 + (y.1 - x.1) * u)).collect() +} + +#[test] +fn axial_spot_to_paper_shading_is_a_gradient() { + // Gold from 100% to paper, quadratic: 1 - s². + let objects = [format!( + "<< /ShadingType 2 /ColorSpace {GOLD} /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 2 >> >>" + )]; + let pdf = raw_pdf("q 0 0 100 100 re W n /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + let gold = list.inks.lookup("Gold").unwrap(); + let fills = fill_items(&list); + assert_eq!(fills.len(), 1); + let Paint::Gradient { stops, geometry } = &fills[0].1.paint else { panic!("{:?}", fills[0].1.paint) }; + for s in [0.25_f32, 0.5, 0.75] { + let t = stop_tints(stops, s); + assert!((tint(&t, gold) - (1.0 - s * s)).abs() < 1.0 / 255.0, "at {s}: {t:?}"); + } + assert!(stops.len() < 64, "{} stops", stops.len()); + let GradientGeometry::Axial { start, end, extend, .. } = geometry else { panic!() }; + assert_eq!((*start, *end, *extend), (Point::new(0.0, 0.0), Point::new(100.0, 0.0), [true, true])); + assert_eq!(report.gradients, 1); +} + +#[test] +fn radial_process_shading_is_clipped_to_its_extent() { + let objects = ["<< /ShadingType 3 /ColorSpace /DeviceCMYK /Coords [50 50 0 50 50 40] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0 0] /C1 [0 1 0 0] /N 1 >> >>".to_string()]; + let pdf = raw_pdf("/Sh1 sh", "/Shading << /Sh1 5 0 R >>", &objects); + let (list, _) = read(&pdf); + let fills = fill_items(&list); + assert_eq!(fills.len(), 1); + let (groups, fill) = &fills[0]; + let Paint::Gradient { stops, geometry } = &fill.paint else { panic!() }; + assert!(matches!(geometry, GradientGeometry::Radial { extend: [true, true], end_radius, .. } if *end_radius == 40.0)); + let mid = stop_tints(stops, 0.5); + assert!((tint(&mid, ink(Process::Cyan)) - 0.5).abs() < 0.01 && (tint(&mid, ink(Process::Magenta)) - 0.5).abs() < 0.01); + assert_eq!(mid.len(), 4); + // Without Extend it paints within the outer circle only (page space: y flipped). + let extent = groups.iter().rev().find_map(|g| g.clip.as_ref()).unwrap(); + assert_ne!(extent.winding(Point::new(50.0, 70.0)), 0); + assert_ne!(extent.winding(Point::new(89.5, 70.0)), 0); + assert_eq!(extent.winding(Point::new(91.0, 70.0)), 0); +} + +#[test] +fn coons_mesh_shading_rasterizes() { + // One square patch over 0..100 with C, M, Y and K corners. + let points = "00 00 00 55 00 AA 00 FF 55 FF AA FF FF FF FF AA FF 55 FF 00 AA 00 55 00"; + let colors = "FF000000 00FF0000 0000FF00 000000FF"; + let objects = [hex_image_free( + "/ShadingType 6 /ColorSpace /DeviceCMYK /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 100 0 100 0 1 0 1 0 1 0 1]", + &format!("00 {points} {colors}"), + )]; + let pdf = raw_pdf("q 1 0 0 1 10 10 cm /Sh1 sh Q", "/Shading << /Sh1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + let ims = images(&list); + assert_eq!((ims.len(), report.rasterized_shadings), (1, 1)); + let im = ims[0].1; + assert_eq!(im.space, ImageSpace::Cmyk); + let corner = tints_at(im, Point::new(11.0, 109.0)); + assert!(tint(&corner, ink(Process::Cyan)) > 0.95, "{corner:?}"); + let center = tints_at(im, Point::new(60.0, 60.0)); + for p in Process::ALL { + assert!((tint(¢er, ink(p)) - 0.25).abs() < 0.03, "{center:?}"); + } + assert_eq!(im.smask.as_ref().unwrap()[0], 0, "outside the patch"); +} + +fn hex_image_free(dict: &str, hex: &str) -> String { + stream(&format!("{dict} /Filter /ASCIIHexDecode"), &format!("{hex}>")) +} + +#[test] +fn tiling_pattern_expands_into_cells() { + let objects = [ + stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 20 /YStep 20 /Resources << >>", "1 0 0 0 k 0 0 10 10 re f"), + stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.25 0.25] /XStep 0.5 /YStep 0.5 /Resources << >>", + "0 1 0 0 k 0 0 0.25 0.25 re f", + ), + ]; + let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f /P2 scn 100 0 90 30 re f", "/Pattern << /P1 5 0 R /P2 6 0 R >>", &objects); + let (list, report) = read(&pdf); + // Cells start every 20 pt from 0: five across 90 pt, two up 30 pt. + assert_eq!(report.tiling_cells, 10); + let cyan = fill_items(&list).into_iter().filter(|(_, f)| f.paint == cmyk(1.0, 0.0, 0.0, 0.0)).count(); + assert_eq!(cyan, 10); + // 180 × 60 cells are past the cap: an image, a quarter magenta on average. + assert_eq!(report.rasterized_tilings, 1); + let im = images(&list)[0].1; + let a = im.smask.as_ref().unwrap(); + let mean = a.iter().map(|v| f64::from(*v)).sum::() / a.len() as f64 / 255.0; + assert!((mean - 0.25).abs() < 0.05, "{mean}"); +} + +#[test] +fn tiling_cells_past_the_item_budget_rasterize() { + // Each of the 99 × 99 outer cells holds an expanded 15 × 15 inner pattern: 2.2 million + // items in all. + let objects = [ + stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 1 /YStep 1 /Resources << /Pattern << /P2 6 0 R >> >>", + "/Pattern cs /P2 scn 0 0 1 1 re f", + ), + stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0.02 0.02] /XStep 0.07 /YStep 0.07 /Resources << >>", + "1 0 0 0 k 0 0 0.02 0.02 re f", + ), + ]; + let pdf = raw_pdf("/Pattern cs /P1 scn 0.5 0.5 98 98 re f", "/Pattern << /P1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + assert_eq!(report.tiling_cells, 225, "only the inner pattern expands"); + assert_eq!(report.rasterized_tilings, 1); + assert_eq!(images(&list).len(), 1); +} + +#[test] +fn deeply_overlapping_tiling_cells_paint_nothing() { + // Every point lies in 6 × 10^14 cells. + let objects = [stream( + "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 100000 100000] /XStep 0.004 /YStep 0.004 /Resources << >>", + "1 0 0 0 k 0 0 1 1 re f", + )]; + let pdf = raw_pdf("/Pattern cs /P1 scn 0 0 90 30 re f", "/Pattern << /P1 5 0 R >>", &objects); + let (list, report) = read(&pdf); + assert_eq!((report.tiling_cells, report.rasterized_tilings, report.dropped_tilings), (0, 0, 1)); + assert!(images(&list).is_empty()); +} + +#[test] +fn dashes_past_the_budget_stroke_solid() { + // A billion dashes. + let pdf = raw_pdf("[0.000001] 0 d 0 0 m 1000 0 l S", "", &[]); + let (list, _) = read(&pdf); + let fills = fill_items(&list); + assert_eq!(fills.len(), 1); + assert!(fills[0].1.path.elements().len() < 20, "{:?}", fills[0].1.path); +} + +#[test] +fn plate_test_gradients_and_meshes_read() { + let (list, report) = read(&scene_pdf("S14")); + let gold = list.inks.lookup("Gold").unwrap(); + assert_eq!((report.gradients, report.unreadable_images), (2, 0)); + let duotone = images(&list)[0].1; + let at = tints_at(duotone, Point::new(100.0, 60.0)); + assert!((tint(&at, ink(Process::Black)) - 0.25).abs() < 0.1 && (tint(&at, gold) - 0.15).abs() < 0.1, "{at:?}"); + for (_, f) in fill_items(&list) { + assert!(matches!(f.paint, Paint::Gradient { .. }), "{:?}", f.paint); + } + + let (list, report) = read(&scene_pdf("S17")); + let gold = list.inks.lookup("Gold").unwrap(); + assert_eq!(report.rasterized_shadings, 1); + let mesh = images(&list)[0].1; + let expect = [ + ((24.0, 24.0), vec![(gold, 0.95)]), + ((216.0, 24.0), vec![(ink(Process::Cyan), 0.95)]), + ((120.0, 80.0), vec![(gold, 0.375), (ink(Process::Cyan), 0.25), (ink(Process::Magenta), 0.25)]), + ]; + for ((x, y), inks) in expect { + let at = tints_at(mesh, Point::new(x, y)); + for (id, v) in inks { + assert!((tint(&at, id) - v).abs() < 0.1, "({x}, {y}): {at:?}"); + } + } +} + +fn write_fills(list: &DisplayList) -> Vec { + let cms = CmsConversion::fogra39(); + let mut writer = Writer::new(Default::default(), Metadata::default()); + let size = list.page.size(); + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(size.width as f32, size.height as f32).unwrap()); + let mut surface = page.surface(); + for (groups, item) in leaves(&list.items) { + assert!(groups.iter().all(|g| g.opacity == 1.0 && g.mask.is_none() && g.clip.is_none()), "opaque scenes only"); + let Item::Fill(f) = item else { panic!("{item:?}") }; + let Paint::Solid(color) = &f.paint else { panic!() }; + let o = f.overprint; + surface.push_overprint(KOverprint { fill: o.fill, stroke: o.stroke, nonzero: o.nonzero }); + let rule = if f.rule == FillRule::EvenOdd { KFillRule::EvenOdd } else { KFillRule::NonZero }; + surface.set_fill(Some(Fill { paint: to_pdf_color(color, &list.inks, &cms).unwrap().into(), rule, ..Default::default() })); + surface.draw_path(&krilla_path(&f.path)); + surface.pop(); + } + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +fn krilla_path(path: &BezPath) -> craft_pdf::write::backend::geom::Path { + let mut b = PathBuilder::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32), + PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32), + PathEl::ClosePath => b.close(), + } + } + b.finish().unwrap() +} + +#[test] +fn opaque_items_separate_as_the_source() { + for id in ["S01", "S04", "D06", "S13"] { + let source = scene_pdf(id); + let (list, _) = read(&source); + let copy = write_fills(&list); + let (a, b) = (plates(&source, 0, 36.0, &Options::default()).unwrap(), plates(©, 0, 36.0, &Options::default()).unwrap()); + assert_eq!(a.planes.len(), b.planes.len(), "{id}: plates"); + for (k, (pa, pb)) in a.planes.iter().zip(&b.planes).enumerate() { + let differ = pa.iter().zip(pb).filter(|(x, y)| x.abs_diff(**y) > 8).count(); + assert!(differ * 100 < pa.len(), "{id}: plate {k} differs in {differ} of {} pixels", pa.len()); + } + } +} + +#[test] +fn print_flagged_annotations_left_out_are_counted() { + let ap = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", "0 0 0 1 k 0 0 10 10 re f"); + let annot = |flags: u32| format!("<< /Type /Annot /Subtype /Stamp /Rect [10 10 20 20] /F {flags} /AP << /N 5 0 R >> >>"); + // Print; Print and Hidden; no flags (screen only); Print without an appearance. + let objects = [ap, annot(4), annot(4 | 2), annot(0), "<< /Type /Annot /Subtype /Text /Rect [0 0 5 5] /F 4 >>".to_string()]; + // `raw_pdf` puts its argument inside the page's /Resources; this closes that dictionary to + // add /Annots to the page. + let pdf = raw_pdf("", ">> /Annots [6 0 R 7 0 R 8 0 R 9 0 R] /PieceInfo <<", &objects); + let (list, report) = read_page(&pdf, 0, &ReadOptions::default()).unwrap(); + assert!(list.items.is_empty()); + assert_eq!(report.dropped_annotations, 1); + let (list, report) = read_page(&pdf, 0, &ReadOptions { annotations: true, ..ReadOptions::default() }).unwrap(); + assert!(!list.items.is_empty()); + assert_eq!(report.dropped_annotations, 0); +} diff --git a/crates/prepress/tests/flatten_plates.rs b/crates/prepress/tests/flatten_plates.rs new file mode 100644 index 00000000..91c11262 --- /dev/null +++ b/crates/prepress/tests/flatten_plates.rs @@ -0,0 +1,38 @@ +//! Local plate check of the flattener: every plate-test scene written, flattened with +//! [`flatten_page`] at 300 dpi, separated with Ghostscript and judged against the scene, with no +//! transparency left in the flattened page. Run it with `scripts/plate-check.sh --flatten`; it +//! never runs in CI. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use craft_plate_test::reference::{self, Context}; +use craft_plate_test::report::{self, Status}; +use craft_plate_test::{gs, scene}; +use craft_prepress::flatten::{FlattenPageOptions, flatten_page}; + +#[test] +#[ignore = "needs Ghostscript; run scripts/plate-check.sh --flatten"] +fn every_scene_separates_as_expected_flattened() { + let Some(version) = gs::version() else { + println!("note: Ghostscript ({:?}) not found; set {} or install gs", gs::program(), gs::GS_ENV); + return; + }; + let scenes = scene::load_dir(&scene::scenes_dir()).unwrap(); + let ctx = Context::from_env(&scenes); + if ctx.font.is_none() { + println!("note: {} not set; text points are skipped", reference::FONT_DIR_ENV); + } + let opts = FlattenPageOptions::default(); + let flatten = |pdf: &[u8]| flatten_page(pdf, 0, &opts).map(|(flat, _)| flat).map_err(|e| e.to_string()); + let report = report::run_flattened(&ctx, version, &flatten); + let path = report::write(&report, &report::output_dir().join("flattened")).unwrap(); + for r in &report.scenes { + let pending = r.pending.as_deref().map(|t| format!(" (pending {t})")).unwrap_or_default(); + let flat = r.flattened.as_ref().map(|f| format!(", flattened {:?} in {:.0} ms", f.status, f.millis)).unwrap_or_default(); + println!("{} {:?}{flat}{pending}", r.id, r.status); + } + println!("report: {}", path.display()); + let wrong = report.unexpected(); + assert!(wrong.is_empty(), "see {}:\n{}", path.display(), wrong.join("\n")); + assert!(report.scenes.iter().any(|r| r.flattened.as_ref().is_some_and(|f| f.status == Status::Pass))); +} diff --git a/crates/prepress/tests/flatten_raster.rs b/crates/prepress/tests/flatten_raster.rs new file mode 100644 index 00000000..7f9af1c2 --- /dev/null +++ b/crates/prepress/tests/flatten_raster.rs @@ -0,0 +1,416 @@ +//! Complex regions rasterized as ink images, and plate-test scenes flattened end to end: the +//! plates of a scene and of its flattened page match at the scene's sample points, and the +//! flattened page has no transparency left. +//! +//! D10 needs Inter from `CRAFT_PDF_TEST_FONT_DIR` or `~/.cache/craft-pdf-test-fonts`, and is +//! skipped when it is missing. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; +use std::time::Instant; + +use craft_pdf::write::backend::SerializeSettings; +use craft_pdf::write::backend::configure::PdfVersion; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::print::{self, PrintColor}; +use craft_pdf::write::{Metadata, Writer, configuration}; +use craft_plate_test::reference::{self, Context}; +use craft_plate_test::{scene, structure}; +use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape}; +use craft_prepress::flatten::raster::Rasterizer; +use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened}; +use craft_prepress::flatten::{ + BlendMode, ComplexReason, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, Flattened, GradientGeometry, + GroupItem, Item, Paint, flatten, flatten_page, +}; +use craft_prepress::{Alternate, CmsConversion, Fallback, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates}; + +const C: InkId = InkId::process(Process::Cyan); +const K: InkId = InkId::process(Process::Black); + +fn settings() -> SerializeSettings { + // Reading the profile takes a quarter second in a debug build. + static SETTINGS: OnceLock = OnceLock::new(); + SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone() +} + +fn separate(pdf: &[u8], dpi: f32) -> Plates { + plates(pdf, 0, dpi, &Options::default()).unwrap() +} + +/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink. +fn tint(p: &Plates, name: &str, x: f32, y: f32) -> u8 { + let s = p.dpi / 72.0; + p.inks.lookup(name).and_then(|id| p.tint(id, (x * s) as u32, (y * s) as u32)).unwrap_or(0) +} + +/// The flattened page has no transparency left ([`structure::transparency`]). +fn assert_opaque(pdf: &[u8]) { + let left = structure::transparency(&structure::summarize(pdf).unwrap()); + assert!(left.is_empty(), "transparency left: {left:?}"); +} + +fn font() -> Option>> { + let dir = std::env::var_os(reference::FONT_DIR_ENV) + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts")))?; + std::fs::read(dir.join(reference::FONT_FILE)).ok().map(Arc::new) +} + +/// Scene `id` written by the reference writer and flattened with complex regions at 72 dpi: the +/// plates match at every judged sample point within `tol` (0–255), or the flattened page meets the +/// scene's expectation there. The renderer's source page can differ from the scene where it +/// converts a mask's CMYK luminosity through the printing condition (S11's K100 `/BC` reads as +/// about 12% luminosity); the flattener uses the DeviceCMYK formula, as Acrobat does. Returns the worst +/// difference from the source. +fn scene_round_trip(id: &str, tol: u8) -> u8 { + scene_planes(id, tol).0 +} + +/// [`scene_round_trip`], with the source's and the flattened page's plates. +fn scene_planes(id: &str, tol: u8) -> (u8, Plates, Plates) { + let scenes = scene::load_dir(&scene::scenes_dir()).unwrap(); + let s = scenes.iter().find(|s| s.id == id).unwrap(); + let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf; + let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: 72.0, ..FlattenOptions::default() }, ..FlattenPageOptions::default() }; + let start = Instant::now(); + let (flat, report) = flatten_page(&pdf, 0, &opts).unwrap(); + eprintln!("{id}: flatten_page {:?}, {:?}", start.elapsed(), report.raster); + assert_opaque(&flat); + let (a, b) = (separate(&pdf, 72.0), separate(&flat, 72.0)); + let mut worst = 0; + for point in s.expect.points.iter().filter(|p| !p.info) { + let [x, y] = point.at; + for name in s.expect.plates.iter().map(String::as_str) { + let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y)); + if want.abs_diff(got) <= tol { + worst = worst.max(want.abs_diff(got)); + continue; + } + let meets = |v: u8| { + let pct = f32::from(v) / 2.55; + match point.inks.get(name).copied().unwrap_or(scene::Level::Value(0.0)) { + scene::Level::Value(e) => (pct - e).abs() <= point.tol, + scene::Level::Range([lo, hi]) => pct >= lo - point.tol && pct <= hi + point.tol, + } + }; + assert!(meets(got), "{id} {name} at ({x}, {y}): {got}, the source has {want}"); + eprintln!("{id} {name} at ({x}, {y}): {got} meets the scene, the source renders {want}"); + } + } + eprintln!("{id}: worst ink difference {worst}/255"); + (worst, a, b) +} + +#[test] +fn s06_half_black_over_cyan() { + scene_round_trip("S06", 3); +} + +#[test] +fn s07_spot_multiply() { + scene_round_trip("S07", 3); +} + +#[test] +fn s08_non_separable_blend_over_a_spot() { + scene_round_trip("S08", 3); +} + +#[test] +fn s10_isolated_multiply() { + scene_round_trip("S10", 3); +} + +#[test] +fn s11_shadow_over_spot() { + scene_round_trip("S11", 6); +} + +#[test] +fn s14_duotone_gradients() { + scene_round_trip("S14", 6); +} + +#[test] +fn m1_luminosity_mask_rasterizes() { + scene_round_trip("M1", 3); +} + +#[test] +fn m2_mask_transfer_rasterizes() { + scene_round_trip("M2", 3); +} + +#[test] +fn f02_hidden_art_leaves_no_trace() { + // vectorcraft#2161: no pixel of the opaque card and box, anti-aliased as viewers draw them, + // differs from the source: art hidden under them doesn't cut them into pieces whose edges + // show. + let (_, a, b) = scene_planes("F02", 3); + let s = a.dpi / 72.0; + let mut off = Vec::new(); + for (x0, x1) in [(22.0, 168.0), (182.0, 338.0)] { + for y in (22.0 * s) as u32..(158.0 * s) as u32 { + for x in (x0 * s) as u32..(x1 * s) as u32 { + for name in ["Cyan", "Magenta", "Yellow", "Black"] { + let tint = |p: &Plates| p.inks.lookup(name).and_then(|id| p.tint(id, x, y)).unwrap_or(0); + if tint(&a) != tint(&b) { + off.push(format!("{name} {} vs {} at pixel ({x}, {y})", tint(&b), tint(&a))); + } + } + } + } + } + assert!(off.is_empty(), "{} pixels differ, e.g. {:?}", off.len(), &off[..off.len().min(8)]); +} + +#[test] +fn d10_text_over_transparency_stays_live() { + if font().is_none() { + eprintln!("skipped: no test font"); + return; + } + scene_round_trip("D10", 3); + let scenes = scene::load_dir(&scene::scenes_dir()).unwrap(); + let s = scenes.iter().find(|s| s.id == "D10").unwrap(); + let pdf = reference::write(s, &Context { scenes: &scenes, font: font() }).unwrap().pdf; + let (flat, _) = flatten_page(&pdf, 0, &FlattenPageOptions::default()).unwrap(); + let doc = craft_pdf::open_slice(&flat, None).unwrap(); + let pages = doc.pages(); + let mut ops = Vec::new(); + let mut it = pages.first().unwrap().operations(); + while let Some(op) = it.next() { + ops.push(String::from_utf8_lossy(op.operator).into_owned()); + } + assert!(ops.iter().any(|op| op == "Tj" || op == "TJ"), "text drawn as text: {ops:?}"); +} + +fn spot_inks() -> (InkSet, InkId) { + let mut inks = InkSet::new(); + let gold = inks.add(Ink::Spot(Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.3, 1.0, 0.0]), solidity: None })).unwrap(); + (inks, gold) +} + +fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item { + Item::Fill(FillItem { + path: r.to_path(0.1), + rule: FillRule::NonZero, + paint: Paint::Solid(InkColor::Tints(tints.to_vec())), + overprint: Overprint::default(), + }) +} + +fn group(opacity: f32, blend: BlendMode, children: Vec) -> Item { + Item::Group(GroupItem { opacity, blend, isolated: false, knockout: false, clip: None, mask: None, actual_text: None, children }) +} + +fn ramp(r: Rect, from: InkColor, to: InkColor) -> Item { + Item::Fill(FillItem { + path: r.to_path(0.1), + rule: FillRule::NonZero, + paint: Paint::Gradient { + stops: vec![(0.0, from), (1.0, to)], + geometry: GradientGeometry::Axial { + start: Point::new(r.x0, 0.0), + end: Point::new(r.x1, 0.0), + extend: [true; 2], + transform: Affine::IDENTITY, + }, + }, + overprint: Overprint::default(), + }) +} + +/// `flat` written with complex regions rasterized at `dpi`. +fn write(list: &DisplayList, flat: &Flattened, dpi: f32) -> Vec { + write_at(list, flat, dpi, PdfVersion::default()).unwrap() +} + +/// [`write`] for an output of PDF `version`. +fn write_at(list: &DisplayList, flat: &Flattened, dpi: f32, version: PdfVersion) -> Result, WriteError> { + let cms = CmsConversion::fogra39(); + let mut raster = Rasterizer::new(list, settings(), Fallback::Fogra39, &cms, dpi); + let output = SerializeSettings { configuration: configuration(version, None).unwrap(), ..settings() }; + let mut writer = Writer::new(output, Metadata::default()); + let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap(); + let mut page_builder = writer.document_mut().start_page_with(size); + let mut surface = page_builder.surface(); + let drawn = write_flattened(&mut surface, list, flat, &cms, version, &mut |r| raster.region(r)); + surface.finish(); + page_builder.finish(); + drawn?; + Ok(writer.finish().unwrap()) +} + +/// A page of 2 pt stripes, one of `process` tints and one of each of `count` spot inks, under a +/// half-transparent ramp: a complex region that paints them all. +fn many_inks(count: usize, process: &[(InkId, f32)]) -> (DisplayList, Flattened) { + let mut inks = InkSet::new(); + let stripe = |i: usize| Rect::new(2.0 * i as f64, 0.0, 2.0 * (i + 1) as f64, 10.0); + let mut items = vec![solid(stripe(0), process)]; + for i in 0..count { + let spot = Spot { name: format!("Spot {i}"), alternate: Alternate::Cmyk([0.0, 0.0, 0.0, 0.1]), solidity: None }; + items.push(solid(stripe(i + 1), &[(inks.add(Ink::Spot(spot)).unwrap(), 0.5)])); + } + let width = 2.0 * (count + 1) as f64; + let ramp = ramp(Rect::new(0.0, 0.0, width, 10.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)])); + items.push(group(0.5, BlendMode::Multiply, vec![ramp])); + let list = DisplayList { page: Rect::new(0.0, 0.0, width, 10.0), inks, items }; + let flat = flatten(&list, &FlattenOptions::default()).unwrap(); + assert!(!flat.complex.is_empty()); + (list, flat) +} + +#[test] +fn a_region_of_more_spot_inks_than_plates_separates_is_an_error() { + let (list, flat) = many_inks(33, &[(K, 0.5)]); + match write_at(&list, &flat, 72.0, PdfVersion::default()) { + Err(WriteError::SpotInks { inks: 33, max: 32, unseparated }) => assert_eq!(unseparated.len(), 1), + other => panic!("{:?}", other.map(|_| ())), + } +} + +#[test] +fn a_raster_region_of_nine_inks_needs_pdf_1_5() { + let (list, flat) = many_inks(5, &[(C, 0.5), (InkId::process(Process::Magenta), 0.5), (InkId::process(Process::Yellow), 0.5), (K, 0.5)]); + match write_at(&list, &flat, 72.0, PdfVersion::Pdf14) { + Err(WriteError::Colorants { colorants: 9, max: 8 }) => {} + other => panic!("{:?}", other.map(|_| ())), + } + let p = separate(&write_at(&list, &flat, 72.0, PdfVersion::Pdf17).unwrap(), 72.0); + assert!(tint(&p, "Spot 4", 11.0, 5.0) > 0); +} + +#[test] +fn gradient_under_multiply_over_a_spot_prints_the_same_rasterized() { + // A K ramp multiplied over Gold and cyan, half transparent: complex regions over a spot and + // a process backdrop, with a kept K fill above them. + let (inks, gold) = spot_inks(); + let list = DisplayList { + page: Rect::new(0.0, 0.0, 100.0, 100.0), + inks, + items: vec![ + solid(Rect::new(0.0, 0.0, 50.0, 100.0), &[(gold, 1.0)]), + solid(Rect::new(50.0, 0.0, 100.0, 100.0), &[(C, 0.6)]), + group( + 0.5, + BlendMode::Multiply, + vec![ramp(Rect::new(10.0, 20.0, 90.0, 80.0), InkColor::Tints(vec![(K, 0.0)]), InkColor::Tints(vec![(K, 1.0)]))], + ), + solid(Rect::new(40.0, 45.0, 60.0, 55.0), &[(K, 1.0)]), + ], + }; + let flat = flatten(&list, &FlattenOptions::default()).unwrap(); + assert_eq!(flat.complex.len(), 1, "touching complex regions merge: {:?}", flat.complex.iter().map(|c| &c.items).collect::>()); + let start = Instant::now(); + let out = write(&list, &flat, 72.0); + eprintln!("ramp: written in {:?}", start.elapsed()); + assert_opaque(&out); + let (a, b) = (separate(&page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap(), 72.0), separate(&out, 72.0)); + let mut worst = 0; + for (x, y) in [(5.0, 50.0), (20.0, 30.0), (30.0, 70.0), (45.0, 40.0), (50.0, 50.0), (55.0, 60.0), (70.0, 30.0), (85.0, 75.0), (95.0, 50.0)] { + for name in ["Cyan", "Magenta", "Yellow", "Black", "Gold"] { + let (want, got) = (tint(&a, name, x, y), tint(&b, name, x, y)); + assert!(want.abs_diff(got) <= 3, "{name} at ({x}, {y}): {got}, expected {want}"); + worst = worst.max(want.abs_diff(got)); + } + } + eprintln!("ramp: worst ink difference {worst}/255"); +} + +#[test] +fn abutting_complex_regions_leave_no_paper_at_their_edge() { + // Two regions cut off the grid at x = 50.3, each its own image of the same 60% K fill. + let list = DisplayList { + page: Rect::new(0.0, 0.0, 100.0, 100.0), + inks: InkSet::new(), + items: vec![solid(Rect::new(10.0, 10.0, 90.0, 90.0), &[(K, 0.6)])], + }; + let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None }; + let flat = Flattened { + items: Vec::new(), + complex: vec![region(Rect::new(10.0, 10.0, 50.3, 90.0)), region(Rect::new(50.3, 10.0, 90.0, 90.0))], + report: Default::default(), + }; + let dpi = 36.0; + let p = separate(&write(&list, &flat, dpi), 2.0 * dpi); + let k = p.inks.lookup("Black").unwrap(); + let edge = (50.3 * p.dpi / 72.0) as u32; + for y in (12.0 * p.dpi / 72.0) as u32..(88.0 * p.dpi / 72.0) as u32 { + for x in edge.saturating_sub(1)..=edge + 1 { + let v = p.tint(k, x, y).unwrap(); + assert!(v >= 153 / 2, "K {v} at pixel ({x}, {y})"); + } + } +} + +#[test] +fn a_degenerate_group_is_an_opaque_image_over_paper() { + // The region's path is a box larger than the half-black disc in it, with cyan beside it. + // Nothing else is under the box, so its image needs no soft mask: paper where the disc + // doesn't paint, the disc's own color where it does. + let disc = kurbo_circle(Point::new(50.0, 50.0), 20.0); + let list = DisplayList { + page: Rect::new(0.0, 0.0, 100.0, 100.0), + inks: InkSet::new(), + items: vec![ + solid(Rect::new(0.0, 0.0, 20.0, 100.0), &[(C, 1.0)]), + group( + 0.5, + BlendMode::Normal, + vec![Item::Fill(FillItem { + path: disc, + rule: FillRule::NonZero, + paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint::default(), + })], + ), + ], + }; + let flat = Flattened { + items: vec![list.items[0].clone()], + complex: vec![ComplexRegion { + path: Rect::new(25.0, 25.0, 75.0, 75.0).to_path(0.1), + items: vec![1], + at: 1, + whole: Some(ComplexReason::Degenerate), + }], + report: Default::default(), + }; + let out = write(&list, &flat, 72.0); + assert_opaque(&out); + let p = separate(&out, 72.0); + // A corner of the box, outside the disc: paper. + assert!(tint(&p, "Cyan", 28.0, 28.0) <= 3 && tint(&p, "Black", 28.0, 28.0) <= 3); + // The disc: K 50%. + assert!(tint(&p, "Black", 50.0, 50.0).abs_diff(128) <= 3 && tint(&p, "Cyan", 50.0, 50.0) <= 3); + // The cyan beside the box, untouched. + assert!(tint(&p, "Cyan", 10.0, 50.0) >= 250); +} + +fn kurbo_circle(c: Point, r: f64) -> BezPath { + craft_prepress::flatten::kurbo::Circle::new(c, r).to_path(0.01) +} + +#[test] +fn regions_thinner_than_a_point_rasterize() { + // At 300 dpi the hairline region is a tile of 3 px, 0.72 pt; the strip's last tile is 3 px + // wide. + let list = DisplayList { + page: Rect::new(0.0, 0.0, 400.0, 40.0), + inks: InkSet::new(), + items: vec![group(0.5, BlendMode::Normal, vec![solid(Rect::new(10.0, 10.0, 390.0, 30.0), &[(K, 1.0)])])], + }; + let region = |r: Rect| ComplexRegion { path: r.to_path(0.1), items: vec![0], at: 0, whole: None }; + let flat = Flattened { + items: Vec::new(), + complex: vec![region(Rect::new(20.0, 20.0, 20.1, 25.0)), region(Rect::new(10.0, 10.0, 378.71, 15.0))], + report: Default::default(), + }; + let p = separate(&write(&list, &flat, 300.0), 300.0); + assert!(tint(&p, "Black", 20.05, 22.0) > 0, "the hairline prints"); + assert!(tint(&p, "Black", 378.4, 12.0).abs_diff(128) <= 3, "the last tile prints"); +} diff --git a/crates/prepress/tests/flatten_timings.rs b/crates/prepress/tests/flatten_timings.rs new file mode 100644 index 00000000..842c9c50 --- /dev/null +++ b/crates/prepress/tests/flatten_timings.rs @@ -0,0 +1,209 @@ +//! Times the flattener in stages on every plate-test scene and on a synthetic heavy page, at +//! 300 dpi. A report, not a benchmark: run it in release with +//! `cargo test --release -p craft-prepress --features flatten --test flatten_timings -- --ignored --nocapture`. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use craft_pdf::write::backend::SerializeSettings; + +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::print::{self, PrintColor}; +use craft_pdf::write::{Metadata, Writer}; +use craft_plate_test::reference::{self, Context}; +use craft_plate_test::scene; +use craft_prepress::flatten::kurbo::{Affine, Circle, Point, Rect, Shape}; +use craft_prepress::flatten::pdf::{ReadOptions, read_page}; +use craft_prepress::flatten::raster::Rasterizer; +use craft_prepress::flatten::write::{page_pdf, write_flattened}; +use craft_prepress::flatten::{ + BlendMode, DisplayList, FillItem, FillRule, FlattenOptions, FlattenPageOptions, GradientGeometry, GroupItem, Item, Paint, flatten, flatten_page, +}; +use craft_prepress::{CmsConversion, Fallback, InkColor, InkId, InkSet, Options, Overprint, Process, plates}; + +const DPI: f32 = 300.0; + +#[derive(Default)] +struct Stages { + read: Duration, + flatten: Duration, + raster: Duration, + write: Duration, + total: Duration, + regions: usize, + whole: String, + pixels: u64, + fills: usize, + complex: usize, +} + +/// The page read, flattened, and written with its complex regions rasterized, each stage timed; +/// `total` is [`flatten_page`] in one call. +fn stages(pdf: &[u8]) -> Stages { + let mut st = Stages::default(); + let start = Instant::now(); + let opts = FlattenPageOptions { flatten: FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }, ..FlattenPageOptions::default() }; + flatten_page(pdf, 0, &opts).unwrap(); + st.total = start.elapsed(); + + let start = Instant::now(); + let (list, _) = read_page(pdf, 0, &ReadOptions::default()).unwrap(); + st.read = start.elapsed(); + written(&list, &mut st); + st +} + +/// Flattens and writes `list`, timing the stages into `st`. +fn written(list: &DisplayList, st: &mut Stages) -> Vec { + let start = Instant::now(); + let flat = flatten(list, &FlattenOptions { raster_dpi: DPI, ..FlattenOptions::default() }).unwrap(); + st.flatten = start.elapsed(); + st.fills = flat.report.fills; + st.regions = flat.report.regions; + st.whole = flat.report.complex_groups.iter().map(|g| format!("{:?}", g.reason)).collect::>().join(", "); + st.complex = flat.complex.len(); + + let settings = settings(); + let cms = CmsConversion::fogra39(); + let start = Instant::now(); + let mut raster = Rasterizer::new(list, settings.clone(), Fallback::Fogra39, &cms, DPI); + let mut raster_time = Duration::ZERO; + let version = settings.configuration.version(); + let mut writer = Writer::new(settings, Metadata::default()); + let size = PageSettings::from_wh(list.page.width() as f32, list.page.height() as f32).unwrap(); + let mut page_builder = writer.document_mut().start_page_with(size); + let mut surface = page_builder.surface(); + write_flattened(&mut surface, list, &flat, &cms, version, &mut |r| { + let t = Instant::now(); + let image = raster.region(r); + raster_time += t.elapsed(); + image + }) + .unwrap(); + surface.finish(); + page_builder.finish(); + let pdf = writer.finish().unwrap(); + st.raster = raster_time; + st.write = start.elapsed() - raster_time; + st.pixels = raster.report().pixels; + pdf +} + +fn settings() -> SerializeSettings { + static SETTINGS: OnceLock = OnceLock::new(); + SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()).clone() +} + +fn ms(d: Duration) -> String { + format!("{:.1}", d.as_secs_f64() * 1000.0) +} + +fn row(name: &str, st: &Stages) -> String { + format!( + "| {name} | {} | {} | {} | {} | {} | {} | {} | {}{} | {:.1} |", + ms(st.total), + ms(st.read), + ms(st.flatten), + ms(st.raster), + ms(st.write), + st.regions, + st.fills, + st.complex, + if st.whole.is_empty() { String::new() } else { format!(" ({})", st.whole) }, + st.pixels as f64 / 1e6 + ) +} + +/// About 1,000 shapes up to `size` pt on an A4 page, 100 of them transparent (opacity or +/// Multiply); `gradients` of the transparent ones are filled with a ramp. Positions come from a fixed +/// sequence, so every run draws the same page. +fn heavy(size: f64, gradients: usize) -> DisplayList { + let mut seed = 0x2545_f491_u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 10_000) as f64 / 10_000.0 + }; + let ink = |i: usize| InkId::process(Process::ALL[i % 4]); + let mut items = Vec::new(); + let mut transparent = 0; + for i in 0..1000 { + let (x, y) = (20.0 + next() * 520.0, 20.0 + next() * 780.0); + let (w, h) = (2.0 + next() * (size - 2.0), 2.0 + next() * (size - 2.0)); + let path = if i % 3 == 0 { Circle::new(Point::new(x, y), w / 2.0).to_path(0.1) } else { Rect::new(x, y, x + w, y + h).to_path(0.1) }; + let color = InkColor::Tints(vec![(ink(i), 0.2 + next() as f32 * 0.8), (ink(i + 1), next() as f32 * 0.5)]); + let paint = if i % 10 == 9 && transparent < gradients { + Paint::Gradient { + stops: vec![(0.0, color), (1.0, InkColor::Tints(vec![(ink(i + 2), 1.0)]))], + geometry: GradientGeometry::Axial { + start: Point::new(x, y), + end: Point::new(x + w, y), + extend: [true; 2], + transform: Affine::IDENTITY, + }, + } + } else { + Paint::Solid(color) + }; + let fill = Item::Fill(FillItem { path, rule: FillRule::NonZero, paint, overprint: Overprint::default() }); + if i % 10 == 9 { + transparent += 1; + let blend = if i % 40 == 9 { BlendMode::Multiply } else { BlendMode::Normal }; + items.push(Item::Group(GroupItem { + opacity: 0.3 + next() as f32 * 0.5, + blend, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: None, + children: vec![fill], + })); + } else { + items.push(fill); + } + } + DisplayList { page: Rect::new(0.0, 0.0, 595.0, 842.0), inks: InkSet::new(), items } +} + +#[test] +#[ignore = "timing report; run in release"] +fn flatten_timings() { + let scenes = scene::load_dir(&scene::scenes_dir()).unwrap(); + let ctx = Context::from_env(&scenes); + // Load the profiles once, so the first scene doesn't pay for it. + let _ = stages(&reference::write(&scenes[0], &ctx).unwrap().pdf); + println!( + "| Page | flatten_page ms | read | flatten | rasterize | write | regions | fills | complex | Mpx |\n|---|---|---|---|---|---|---|---|---|---|" + ); + for s in scenes.iter().filter(|s| s.pending.is_none()) { + let pdf = reference::write(s, &ctx).unwrap().pdf; + println!("{}", row(&s.id, &stages(&pdf))); + } + let pages = [ + ("heavy, 2–65 pt, flat paints", 65.0, 0), + ("heavy, 2–65 pt, 10 gradients", 65.0, 10), + ("heavy, 2–20 pt, flat paints", 20.0, 0), + ("heavy, 2–20 pt, 10 gradients", 20.0, 10), + ]; + for (name, size, gradients) in pages { + let list = heavy(size, gradients); + let start = Instant::now(); + let pdf = page_pdf(&list, settings(), &CmsConversion::fogra39()).unwrap(); + let wrote = start.elapsed(); + let start = Instant::now(); + let p = plates(&pdf, 0, DPI, &Options::default()).unwrap(); + // What rasterizing a region as large as the page costs: writing its items as a PDF and + // separating that. + println!("", ms(wrote), ms(start.elapsed()), p.width, p.height); + println!("{}", row(name, &stages(&pdf))); + let mut st = Stages::default(); + let start = Instant::now(); + written(&list, &mut st); + st.total = start.elapsed(); + println!("{}", row(&format!("{name} (display list)"), &st)); + } +} diff --git a/crates/prepress/tests/flatten_write.rs b/crates/prepress/tests/flatten_write.rs new file mode 100644 index 00000000..8e0c9875 --- /dev/null +++ b/crates/prepress/tests/flatten_write.rs @@ -0,0 +1,472 @@ +//! The flattener's writer: display lists written with krilla and separated again by `plates`. +//! Pages are 100 pt square and separated at 36 dpi; sample points are in points. +//! +//! Text cases with an embedded font need Inter from `CRAFT_PDF_TEST_FONT_DIR` or +//! `~/.cache/craft-pdf-test-fonts`, and are skipped when it is missing. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; + +use craft_pdf::interpret::font::Glyph; +use craft_pdf::interpret::hayro_cmap::BfString; +use craft_pdf::interpret::{ + BlendMode as HBlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint as HPaint, PathDrawMode, + SoftMask, TransformExt, interpret_page, +}; +use craft_pdf::syntax::object::{Dict, Name, Stream}; +use craft_pdf::write::backend::SerializeSettings; +use craft_pdf::write::backend::configure::PdfVersion; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::print::{self, PrintColor}; +use craft_pdf::write::{Metadata, Writer}; +use craft_prepress::flatten::kurbo::{Affine, BezPath, Point, Rect, Shape}; +use craft_prepress::flatten::write::{WriteError, page_pdf, write_flattened}; +use craft_prepress::flatten::{ + BlendMode, ComplexRegion, DisplayList, FillItem, FillRule, FlattenOptions, GradientGeometry, GroupItem, ImageItem, ImageSpace, Item, Paint, + PlacedGlyph, TextFont, TextItem, flatten, +}; +use craft_prepress::{Alternate, CmsConversion, Ink, InkColor, InkId, InkSet, Options, Overprint, Plates, Process, Spot, plates}; + +const C: InkId = InkId::process(Process::Cyan); +const M: InkId = InkId::process(Process::Magenta); +const Y: InkId = InkId::process(Process::Yellow); +const K: InkId = InkId::process(Process::Black); +const OVERPRINT: Overprint = Overprint { fill: true, stroke: false, nonzero: false }; + +fn fill(r: Rect, tints: &[(InkId, f32)], overprint: Overprint) -> Item { + Item::Fill(FillItem { path: r.to_path(0.1), rule: FillRule::NonZero, paint: Paint::Solid(InkColor::Tints(tints.to_vec())), overprint }) +} + +fn solid(r: Rect, tints: &[(InkId, f32)]) -> Item { + fill(r, tints, Overprint::default()) +} + +fn group(opacity: f32, children: Vec) -> Item { + Item::Group(GroupItem { + opacity, + blend: BlendMode::Normal, + isolated: false, + knockout: false, + clip: None, + mask: None, + actual_text: None, + children, + }) +} + +fn page(inks: InkSet, items: Vec) -> DisplayList { + DisplayList { page: Rect::new(0.0, 0.0, 100.0, 100.0), inks, items } +} + +fn pdf(list: &DisplayList) -> Vec { + // Reading the profile takes a quarter second in a debug build. + static SETTINGS: OnceLock = OnceLock::new(); + let settings = SETTINGS.get_or_init(|| print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap()); + page_pdf(list, settings.clone(), &CmsConversion::fogra39()).unwrap() +} + +/// `list` flattened, with nothing left for the raster step, written. +fn flat_pdf(list: &DisplayList) -> Vec { + let flat = flatten(list, &FlattenOptions::default()).unwrap(); + assert!(flat.complex.is_empty()); + pdf(&DisplayList { items: flat.items, ..list.clone() }) +} + +fn separate(pdf: &[u8]) -> Plates { + plates(pdf, 0, 36.0, &Options::default()).unwrap() +} + +/// Ink `name`'s tint at (`x`, `y`) in points, 0 when the page doesn't use the ink. +fn tint(p: &Plates, name: &str, x: u32, y: u32) -> u8 { + p.inks.lookup(name).and_then(|id| p.tint(id, x / 2, y / 2)).unwrap_or(0) +} + +/// Each ink's tint at (`x`, `y`) is within 3 of `expected`. +fn assert_inks(p: &Plates, x: u32, y: u32, expected: &[(&str, u8)]) { + for (name, want) in expected { + let got = tint(p, name, x, y); + assert!(got.abs_diff(*want) <= 3, "{name} at ({x}, {y}): {got}, expected {want}"); + } +} + +#[test] +fn half_black_over_cyan_in_a_group_composites_the_same_flattened_or_not() { + let list = page( + InkSet::new(), + vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])], + ); + for written in [pdf(&list), flat_pdf(&list)] { + let p = separate(&written); + assert_inks(&p, 50, 50, &[("Cyan", 128), ("Black", 128)]); + assert_inks(&p, 20, 50, &[("Cyan", 255), ("Black", 0)]); + assert_inks(&p, 80, 50, &[("Cyan", 0), ("Black", 128)]); + } +} + +#[test] +fn group_opacity_applies_to_the_group_not_to_each_child() { + // Two overlapping K100 fills in a 50% group: K50 where they overlap, not K75. + let list = page( + InkSet::new(), + vec![group(0.5, vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(K, 1.0)]), solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])])], + ); + for written in [pdf(&list), flat_pdf(&list)] { + let p = separate(&written); + assert_inks(&p, 50, 50, &[("Black", 128)]); + assert_inks(&p, 20, 50, &[("Black", 128)]); + } +} + +#[test] +fn black_overprint_keeps_the_cyan_under_it() { + let list = page( + InkSet::new(), + vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), fill(Rect::new(50.0, 0.0, 100.0, 100.0), &[(K, 1.0)], OVERPRINT)], + ); + for written in [pdf(&list), flat_pdf(&list)] { + let p = separate(&written); + assert_inks(&p, 75, 50, &[("Cyan", 255), ("Black", 255)]); + assert_inks(&p, 25, 50, &[("Cyan", 255), ("Black", 0)]); + } +} + +#[test] +fn overprinting_process_color_leaves_the_plates_of_inks_it_doesnt_list() { + let over = |tints: &[(InkId, f32)], nonzero: bool| { + let list = page( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(C, 1.0)]), + fill(Rect::new(50.0, 0.0, 100.0, 100.0), tints, Overprint { nonzero, ..OVERPRINT }), + ], + ); + separate(&pdf(&list)) + }; + // Magenta and yellow only: cyan stays, black stays empty. + assert_inks(&over(&[(M, 1.0), (Y, 0.5)], false), 75, 50, &[("Cyan", 255), ("Magenta", 255), ("Yellow", 128), ("Black", 0)]); + // A listed cyan at 0 knocks cyan out under OPM 0 ... + assert_inks(&over(&[(C, 0.0), (M, 1.0)], false), 75, 50, &[("Cyan", 0), ("Magenta", 255)]); + // ... and leaves it under OPM 1. + assert_inks(&over(&[(C, 0.0), (M, 1.0)], true), 75, 50, &[("Cyan", 255), ("Magenta", 255)]); + // Nothing named paints nothing. + assert_inks(&over(&[(C, 0.0)], true), 75, 50, &[("Cyan", 255)]); +} + +fn gold() -> Spot { + Spot { name: "Gold".into(), alternate: Alternate::Cmyk([0.0, 0.2, 0.8, 0.1]), solidity: None } +} + +#[test] +fn spot_fills_print_on_their_plate_or_where_the_ink_manager_sends_them() { + let mut inks = InkSet::new(); + let gold = inks.add(Ink::Spot(gold())).unwrap(); + let list = page(inks.clone(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(gold, 0.8)])]); + assert_inks(&separate(&pdf(&list)), 50, 50, &[("Gold", 204), ("Cyan", 0), ("Magenta", 0), ("Yellow", 0)]); + + let mut aliased = inks.clone(); + assert!(aliased.alias(gold, M)); + let p = separate(&pdf(&DisplayList { inks: aliased, ..list.clone() })); + assert!(p.inks.lookup("Gold").is_none()); + assert_inks(&p, 50, 50, &[("Magenta", 204), ("Yellow", 0)]); + + let mut process = inks; + assert!(process.set_to_process(gold, true)); + let p = separate(&pdf(&DisplayList { inks: process, ..list })); + assert!(p.inks.lookup("Gold").is_none()); + assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 41), ("Yellow", 163), ("Black", 20)]); +} + +// ---- text ---------------------------------------------------------------------------------- + +const SAMPLE: &str = "Hamburg"; + +fn inter() -> Option> { + let dir = std::env::var_os("CRAFT_PDF_TEST_FONT_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache/craft-pdf-test-fonts"))); + let data = dir.as_ref().and_then(|d| std::fs::read(d.join("Inter-Regular.ttf")).ok()); + if data.is_none() { + println!("note: Inter-Regular.ttf not found in {dir:?}; case skipped"); + } + data +} + +struct Outline(BezPath); + +impl ttf_parser::OutlineBuilder for Outline { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((f64::from(x), f64::from(y))); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((f64::from(x), f64::from(y))); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.0.quad_to((f64::from(x1), f64::from(y1)), (f64::from(x), f64::from(y))); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.0.curve_to((f64::from(x1), f64::from(y1)), (f64::from(x2), f64::from(y2)), (f64::from(x), f64::from(y))); + } + fn close(&mut self) { + self.0.close_path(); + } +} + +/// `SAMPLE` in K100 at 18 pt on a baseline at (5, 60), with its outlines. +fn text_run(font: &[u8], embed: bool) -> TextItem { + let face = ttf_parser::Face::parse(font, 0).unwrap(); + let upem = f64::from(face.units_per_em()); + let size = 18.0; + let transform = Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]); + let (mut x, mut glyphs, mut outlines) = (0.0, Vec::new(), BezPath::new()); + for (i, c) in SAMPLE.char_indices() { + let id = face.glyph_index(c).unwrap(); + glyphs.push(PlacedGlyph { id: u32::from(id.0), origin: Point::new(x, 0.0), text: i..i + c.len_utf8() }); + let mut outline = Outline(BezPath::new()); + face.outline_glyph(id, &mut outline); + outlines.extend((transform * Affine::translate((x, 0.0)) * Affine::scale(size / upem) * outline.0).iter()); + x += f64::from(face.glyph_hor_advance(id).unwrap()) * size / upem; + } + TextItem { + font: embed.then(|| Arc::new(TextFont { data: Arc::new(font.to_vec()), index: 0, key: 1 })), + size: size as f32, + transform, + glyphs, + text: Some(SAMPLE.to_owned()), + outlines, + paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint::default(), + } +} + +#[derive(Default)] +struct Reader { + unicode: String, + actual_text: String, +} + +impl<'a> Device<'a> for Reader { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: HBlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &HPaint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: HBlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'a>, _: Affine, _: Affine, _: &HPaint<'a>, _: &GlyphDrawMode) { + if let Glyph::Outline(o) = glyph { + match o.as_unicode() { + Some(BfString::Char(c)) => self.unicode.push(c), + Some(BfString::String(s)) => self.unicode.push_str(&s), + None => {} + } + } + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + fn actual_text(&mut self, text: &[u8]) { + self.actual_text.push_str(&match text { + [0xFE, 0xFF, rest @ ..] => String::from_utf16_lossy(&rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect::>()), + _ => text.iter().map(|&b| char::from(b)).collect(), + }); + } +} + +fn read_text(pdf: &[u8]) -> Reader { + let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let page = &doc.pages()[0]; + let cache = InterpreterCache::new(); + let bbox = Rect::new(0.0, 0.0, 100.0, 100.0); + let mut context = Context::new(page.initial_transform(true).to_kurbo(), bbox, &cache, page.xref(), InterpreterSettings::default()); + let mut reader = Reader::default(); + interpret_page(page, &mut context, &mut reader); + reader +} + +#[test] +fn text_with_a_font_is_live_and_prints_like_its_outlines() { + let Some(font) = inter() else { return }; + let live = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, true))])); + let outlined = pdf(&page(InkSet::new(), vec![Item::Text(text_run(&font, false))])); + assert_eq!(read_text(&live).unicode, SAMPLE); + let (a, b) = (separate(&live), separate(&outlined)); + let (a, b) = (a.plane(K).unwrap(), b.plane(K).unwrap()); + assert!(a.iter().map(|v| u64::from(*v)).sum::() > 255 * 50, "the text prints"); + let differ = a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 64).count(); + assert!(differ <= a.len() / 200, "{differ} pixels differ between glyphs and outlines"); +} + +#[test] +fn text_without_a_font_is_drawn_as_outlines_with_its_text() { + let mut run = TextItem { + font: None, + size: 18.0, + transform: Affine::new([1.0, 0.0, 0.0, -1.0, 5.0, 60.0]), + glyphs: Vec::new(), + text: Some(SAMPLE.to_owned()), + outlines: Rect::new(5.0, 45.0, 80.0, 60.0).to_path(0.1), + paint: Paint::Solid(InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint::default(), + }; + if let Some(font) = inter() { + run = TextItem { font: None, ..text_run(&font, false) }; + } + let written = pdf(&page(InkSet::new(), vec![Item::Text(run)])); + let read = read_text(&written); + assert_eq!(read.actual_text, SAMPLE); + assert!(read.unicode.is_empty()); +} + +// ---- structure ----------------------------------------------------------------------------- + +/// Transparency left on a page: groups, soft masks, blend modes other than Normal and +/// constant opacity below 1, in its resources and those of the forms it uses. +fn transparency(pdf: &[u8]) -> Vec { + let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let page = &doc.pages()[0]; + let mut found = Vec::new(); + if page.raw().get::>(b"Group".as_slice()).is_some() { + found.push("page group".to_owned()); + } + visit(&page.resources().x_objects, &page.resources().ext_g_states, &mut found, 0); + found +} + +fn visit(x_objects: &Dict<'_>, states: &Dict<'_>, found: &mut Vec, depth: usize) { + assert!(depth < 16); + for key in states.keys() { + let Some(state) = states.get::>(key.as_ref()) else { continue }; + if state.get::>(b"SMask".as_slice()).is_some() { + found.push("soft mask".to_owned()); + } + if let Some(bm) = state.get::>(b"BM".as_slice()) + && bm.as_str() != "Normal" + { + found.push(format!("blend mode {}", bm.as_str())); + } + for alpha in ["ca", "CA"] { + if state.get::(alpha.as_bytes()).is_some_and(|a| a < 1.0) { + found.push(alpha.to_owned()); + } + } + } + for key in x_objects.keys() { + let Some(x) = x_objects.get::>(key.as_ref()) else { continue }; + let dict = x.dict(); + if dict.get::>(b"Group".as_slice()).is_some() { + found.push("group".to_owned()); + } + if let Some(res) = dict.get::>(b"Resources".as_slice()) { + let sub = |key: &[u8]| res.get::>(key).unwrap_or_default(); + visit(&sub(b"XObject"), &sub(b"ExtGState"), found, depth + 1); + } + } +} + +#[test] +fn a_flattened_page_has_no_transparency_left() { + let multiply = Item::Group(GroupItem { + blend: BlendMode::Multiply, + isolated: true, + ..match group(0.7, vec![solid(Rect::new(30.0, 30.0, 90.0, 90.0), &[(M, 1.0)]), solid(Rect::new(50.0, 10.0, 95.0, 60.0), &[(Y, 0.6)])]) { + Item::Group(g) => g, + _ => unreachable!(), + } + }); + let list = page( + InkSet::new(), + vec![ + solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), + group(0.5, vec![solid(Rect::new(40.0, 0.0, 100.0, 100.0), &[(K, 1.0)])]), + multiply, + ], + ); + let unflattened = transparency(&pdf(&list)); + assert!(unflattened.iter().any(|t| t == "ca") && unflattened.iter().any(|t| t.starts_with("blend mode")), "{unflattened:?}"); + assert_eq!(transparency(&flat_pdf(&list)), Vec::::new()); +} + +// ---- images, gradients and complex regions ------------------------------------------------ + +fn axial(from: InkColor, to: InkColor) -> Paint { + Paint::Gradient { + stops: vec![(0.0, from), (1.0, to)], + geometry: GradientGeometry::Axial { + start: Point::new(0.0, 0.0), + end: Point::new(100.0, 0.0), + extend: [true, true], + transform: Affine::IDENTITY, + }, + } +} + +#[test] +fn an_overprinting_cyan_gradient_keeps_the_yellow_under_it() { + let ramp = Item::Fill(FillItem { + path: Rect::new(0.0, 0.0, 100.0, 100.0).to_path(0.1), + rule: FillRule::NonZero, + paint: axial(InkColor::Tints(vec![(C, 0.0)]), InkColor::Tints(vec![(C, 1.0)])), + overprint: OVERPRINT, + }); + let p = separate(&pdf(&page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 100.0, 100.0), &[(Y, 1.0)]), ramp]))); + assert_inks(&p, 26, 50, &[("Cyan", 66), ("Yellow", 255)]); + assert_inks(&p, 74, 50, &[("Cyan", 189), ("Yellow", 255)]); +} + +#[test] +fn ink_images_print_through_the_ink_manager() { + let mut inks = InkSet::new(); + let gold = inks.add(Ink::Spot(gold())).unwrap(); + assert!(inks.alias(gold, C)); + let image = Item::Image(ImageItem { + space: ImageSpace::Inks(vec![C, gold]), + size: (2, 1), + samples: Arc::from(&[128, 128, 0, 255][..]), + transform: Affine::new([100.0, 0.0, 0.0, 100.0, 0.0, 0.0]), + smask: None, + overprint: Overprint::default(), + jpeg: None, + }); + let p = separate(&pdf(&page(inks, vec![image]))); + // Two half tints on one plate overlap as halftones: 1 − 0.5 × 0.5. + assert_inks(&p, 25, 50, &[("Cyan", 191)]); + assert_inks(&p, 75, 50, &[("Cyan", 255)]); + assert!(p.inks.lookup("Gold").is_none()); +} + +#[test] +fn complex_regions_are_drawn_where_the_flattened_items_put_them() { + let ramp = Item::Fill(FillItem { + path: Rect::new(40.0, 0.0, 100.0, 100.0).to_path(0.1), + rule: FillRule::NonZero, + paint: axial(InkColor::Tints(vec![(K, 0.2)]), InkColor::Tints(vec![(K, 1.0)])), + overprint: Overprint::default(), + }); + let list = page(InkSet::new(), vec![solid(Rect::new(0.0, 0.0, 60.0, 100.0), &[(C, 1.0)]), group(0.5, vec![ramp])]); + let flat = flatten(&list, &FlattenOptions::default()).unwrap(); + assert!(!flat.complex.is_empty()); + // A stand-in for the raster step: magenta over each region's bounds. + let mut raster = |region: &ComplexRegion| -> Result, WriteError> { + let b = region.path.bounding_box(); + Ok(Some(ImageItem { + space: ImageSpace::Inks(vec![M]), + size: (1, 1), + samples: Arc::from(&[255][..]), + transform: Affine::new([b.width(), 0.0, 0.0, b.height(), b.x0, b.y0]), + smask: None, + overprint: Overprint::default(), + jpeg: None, + })) + }; + let settings = print::settings(PrintColor::fogra39(craft_cms::builtin::COATED_FOGRA39_ICC)).unwrap(); + let mut writer = Writer::new(settings, Metadata::default()); + let mut page_builder = writer.document_mut().start_page_with(PageSettings::from_wh(100.0, 100.0).unwrap()); + let mut surface = page_builder.surface(); + write_flattened(&mut surface, &list, &flat, &CmsConversion::fogra39(), PdfVersion::default(), &mut raster).unwrap(); + surface.finish(); + page_builder.finish(); + let p = separate(&writer.finish().unwrap()); + assert_inks(&p, 20, 50, &[("Cyan", 255), ("Magenta", 0)]); + assert_inks(&p, 50, 50, &[("Cyan", 0), ("Magenta", 255)]); + assert_inks(&p, 80, 50, &[("Cyan", 0), ("Magenta", 255), ("Black", 0)]); +} diff --git a/crates/prepress/tests/font_reembed.rs b/crates/prepress/tests/font_reembed.rs new file mode 100644 index 00000000..12288d29 --- /dev/null +++ b/crates/prepress/tests/font_reembed.rs @@ -0,0 +1,521 @@ +//! Text read from a PDF through hayro-interpret and written again with krilla: as live text +//! when the font can be embedded again, as outlines with `/ActualText` otherwise. Covers what +//! the PDF-in adapter relies on: glyph ids, positions, Unicode and plates survive the trip. +//! +//! Fonts not in the repository are read from paths and their cases are skipped when missing: +//! Inter from `CRAFT_PDF_TEST_FONT_DIR`, a CFF-flavored OpenType font from +//! `CRAFT_PREPRESS_CFF_FONT` (default: Debian's `fonts-urw-base35` Nimbus Sans). The Type1C +//! case embeds hayro's Foxit Sans, which is bare CFF. +#![cfg(feature = "flatten")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use craft_pdf::interpret::font::{Glyph, OutlineFontData, StandardFont}; +use craft_pdf::interpret::hayro_cmap::BfString; +use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, TransformExt, + interpret_page, +}; +use craft_pdf::write::backend::color::cmyk; +use craft_pdf::write::backend::geom::{PathBuilder, Point as KPoint, Transform}; +use craft_pdf::write::backend::page::PageSettings; +use craft_pdf::write::backend::paint::Fill; +use craft_pdf::write::backend::surface::Surface; +use craft_pdf::write::backend::tagging::{ContentTag, SpanTag}; +use craft_pdf::write::backend::text::{Font, GlyphId, KrillaGlyph}; +use craft_pdf::write::backend::{self, Data}; +use craft_pdf::write::{Metadata, Writer}; +use craft_prepress::flatten::font::FontCache; +use craft_prepress::flatten::kurbo::{Affine, BezPath, PathEl, Rect, Shape}; +use craft_prepress::flatten::{PlacedGlyph, TextFont, TextItem}; +use craft_prepress::{InkColor, InkId, Options, Overprint, Process, plates}; + +const W: f64 = 300.0; +const H: f64 = 120.0; +const SAMPLE: &str = "Hamburgefonstiv 0123"; + +// ---- fonts --------------------------------------------------------------------------------- + +fn read_font(path: Option, what: &str) -> Option> { + let data = path.as_ref().and_then(|p| std::fs::read(p).ok()); + if data.is_none() { + println!("note: {what} not found at {path:?}; case skipped"); + } + data +} + +fn inter() -> Option> { + read_font(std::env::var_os("CRAFT_PDF_TEST_FONT_DIR").map(|d| PathBuf::from(d).join("Inter-Regular.ttf")), "Inter (CRAFT_PDF_TEST_FONT_DIR)") +} + +fn cff_otf() -> Option> { + let path = std::env::var_os("CRAFT_PREPRESS_CFF_FONT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/usr/share/fonts/opentype/urw-base35/NimbusSans-Regular.otf")); + read_font(Some(path), "a CFF OpenType font (CRAFT_PREPRESS_CFF_FONT)") +} + +// ---- writing ------------------------------------------------------------------------------- + +fn black() -> Fill { + Fill { paint: cmyk::Color::new(0, 0, 0, 255).into(), ..Default::default() } +} + +fn write_page(draw: impl FnOnce(&mut Surface<'_>)) -> Vec { + let settings = backend::SerializeSettings { compress_content_streams: false, ..Default::default() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(W as f32, H as f32).unwrap()); + let mut surface = page.surface(); + draw(&mut surface); + surface.finish(); + page.finish(); + writer.finish().unwrap() +} + +/// The source document: `SAMPLE` mapped through the font's cmap (no shaping), once on a +/// baseline and once rotated and scaled. +fn source_pdf(font_bytes: &[u8]) -> Vec { + let face = ttf_parser::Face::parse(font_bytes, 0).unwrap(); + let upem = f32::from(face.units_per_em()); + let glyphs: Vec<_> = SAMPLE + .char_indices() + .map(|(i, c)| { + let id = face.glyph_index(c).unwrap(); + let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / upem; + KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, i..i + c.len_utf8(), None) + }) + .collect(); + let font = craft_pdf::write::font(font_bytes, 0, None).unwrap(); + write_page(|s| { + s.set_fill(Some(black())); + s.draw_glyphs(KPoint::from_xy(10.0, 40.0), &glyphs, font.clone(), SAMPLE, 24.0, false); + s.push_transform(&Transform::from_row(0.96, -0.26, 0.26, 0.96, 20.0, 100.0)); + s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, SAMPLE, 13.0, false); + s.pop(); + }) +} + +fn to_krilla(a: Affine) -> Transform { + let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v as f32); + Transform::from_row(a, b, c, d, e, f) +} + +fn to_krilla_path(path: &BezPath) -> Option { + let mut b = PathBuilder::new(); + for el in path.elements() { + match *el { + PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32), + PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32), + PathEl::QuadTo(p1, p) => b.quad_to(p1.x as f32, p1.y as f32, p.x as f32, p.y as f32), + PathEl::CurveTo(p1, p2, p) => b.cubic_to(p1.x as f32, p1.y as f32, p2.x as f32, p2.y as f32, p.x as f32, p.y as f32), + PathEl::ClosePath => b.close(), + } + } + b.finish() +} + +/// Writes text runs as the flattener's output would: live text through krilla when the run +/// has a font, else its outlines with the Unicode text as `/ActualText`. +fn rewrite(items: &[TextItem]) -> Vec { + let mut fonts: HashMap = HashMap::new(); + write_page(|s| { + s.set_fill(Some(black())); + for item in items { + let text = item.text.as_deref().unwrap_or(""); + match &item.font { + Some(tf) => { + let font = fonts + .entry(tf.key) + .or_insert_with(|| Font::new(Data::from(tf.data.clone()), tf.index).expect("krilla reads the font")) + .clone(); + let size = item.size as f64; + let glyphs: Vec<_> = item + .glyphs + .iter() + .enumerate() + .map(|(i, g)| { + let next_x = item.glyphs.get(i + 1).map_or(g.origin.x, |n| n.origin.x); + let x_advance = ((next_x - g.origin.x) / size) as f32; + KrillaGlyph::new(GlyphId::new(g.id), x_advance, 0.0, (g.origin.y / size) as f32, 0.0, g.text.clone(), None) + }) + .collect(); + // Text space is y up; krilla's glyph placement is y down. + s.push_transform(&to_krilla(item.transform * Affine::scale_non_uniform(1.0, -1.0))); + s.draw_glyphs(KPoint::from_xy(0.0, 0.0), &glyphs, font, text, item.size, false); + s.pop(); + } + None => { + let span = SpanTag { actual_text: Some(text), ..SpanTag::empty() }; + s.start_tagged(ContentTag::Span(span)); + if let Some(path) = to_krilla_path(&item.outlines) { + s.draw_path(&path); + } + s.end_tagged(); + } + } + } + }) +} + +/// A one-page PDF from raw content and resources, objects numbered from 5. +fn raw_pdf(content: &str, resources: &str, objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + out.extend_from_slice( + format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Contents 4 0 R /Resources << {resources} >> >> endobj\n").as_bytes(), + ); + out.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes()); + for (i, body) in objects.iter().enumerate() { + out.extend_from_slice(format!("{} 0 obj {body} endobj\n", i + 5).as_bytes()); + } + out.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + out +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >> stream\n{data}\nendstream", data.len()) +} + +// ---- reading ------------------------------------------------------------------------------- + +/// What the PDF-in adapter needs from one drawn glyph. +struct Drawn { + /// The font's key in the [`FontCache`]; `None` for Type3 glyphs. + key: Option, + /// What hayro hands out for the font, for the checks. + data: Option, + id: u32, + /// Glyph space (1000 units per em, y up) to page space. + matrix: Affine, + unicode: Option, + vertical: bool, + type3: bool, + outline: BezPath, +} + +#[derive(Default)] +struct Recorder { + glyphs: Vec, + actual_text: String, + fonts: FontCache, +} + +#[derive(Default)] +struct PathSink(BezPath); + +impl Device<'_> for PathSink { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'_>, _: &PathDrawMode) { + self.0.extend((transform * path.clone()).iter()); + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} +} + +fn unicode(s: BfString) -> String { + match s { + BfString::Char(c) => c.to_string(), + BfString::String(s) => s, + } +} + +impl<'a> Device<'a> for Recorder { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) { + if !matches!(mode, GlyphDrawMode::Fill) { + return; + } + let matrix = transform * glyph_transform; + let drawn = match glyph { + Glyph::Outline(o) => Drawn { + key: Some(self.fonts.note(o)), + data: o.font_data(), + id: o.glyph_id().to_u32(), + matrix, + unicode: o.as_unicode().map(unicode), + vertical: o.is_vertical(), + type3: false, + outline: matrix * o.outline(), + }, + Glyph::Type3(t) => { + let mut sink = PathSink::default(); + t.interpret(&mut sink, transform, glyph_transform, paint); + Drawn { key: None, data: None, id: 0, matrix, unicode: t.as_unicode().map(unicode), vertical: false, type3: true, outline: sink.0 } + } + }; + self.glyphs.push(drawn); + } + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + fn actual_text(&mut self, text: &[u8]) { + self.actual_text.push_str(&pdf_text_string(text)); + } +} + +fn pdf_text_string(bytes: &[u8]) -> String { + match bytes { + [0xFE, 0xFF, rest @ ..] => { + let units: Vec = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + String::from_utf16_lossy(&units) + } + _ => bytes.iter().map(|&b| char::from(b)).collect(), + } +} + +fn read(pdf: &[u8]) -> Recorder { + let doc = craft_pdf::syntax::Pdf::new(Arc::new(pdf.to_vec())).unwrap(); + let page = &doc.pages()[0]; + let cache = InterpreterCache::new(); + let mut context = + Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, W, H), &cache, page.xref(), InterpreterSettings::default()); + let mut recorder = Recorder::default(); + interpret_page(page, &mut context, &mut recorder); + recorder +} + +// ---- runs, as the adapter builds them ------------------------------------------------------ + +fn same_linear(a: Affine, b: Affine) -> bool { + let (a, b) = (a.as_coeffs(), b.as_coeffs()); + let scale = a[..4].iter().fold(0.0f64, |m, v| m.max(v.abs())); + a[..4].iter().zip(&b[..4]).all(|(x, y)| (x - y).abs() <= scale * 1e-6) +} + +/// Groups drawn glyphs into runs: one font and one text matrix (up to translation) each. +fn text_items(drawn: &[Drawn], fonts: &HashMap>>) -> Vec { + let mut items: Vec = Vec::new(); + for d in drawn { + let font = d.key.and_then(|key| fonts[&key].clone()); + let linear = d.matrix * Affine::scale(1000.0); + let continues = items + .last() + .is_some_and(|last| last.font == font && last.font.is_some() && same_linear(last.transform * Affine::scale(last.size as f64), linear)); + if !continues { + let size = linear.determinant().abs().sqrt(); + let origin = d.matrix.translation(); + let [a, b, c, dd, ..] = linear.as_coeffs(); + items.push(TextItem { + font: font.clone(), + size: size as f32, + transform: Affine::new([a / size, b / size, c / size, dd / size, origin.x, origin.y]), + glyphs: Vec::new(), + text: Some(String::new()), + outlines: BezPath::new(), + paint: craft_prepress::flatten::Paint::Solid(InkColor::Tints(vec![(InkId::process(Process::Black), 1.0)])), + overprint: Overprint::default(), + }); + } + let item = items.last_mut().unwrap(); + let text = item.text.as_mut().unwrap(); + let start = text.len(); + text.push_str(d.unicode.as_deref().unwrap_or("")); + let origin = item.transform.inverse() * d.matrix.translation().to_point(); + item.glyphs.push(PlacedGlyph { id: d.id, origin, text: start..text.len() }); + item.outlines.extend(d.outline.iter()); + } + items +} + +// ---- checks -------------------------------------------------------------------------------- + +fn black_plate(pdf: &[u8]) -> Vec { + let p = plates(pdf, 0, 72.0, &Options::default()).unwrap(); + p.plane(InkId::process(Process::Black)).unwrap().to_vec() +} + +/// Mean and largest per-pixel difference of the black plates. +fn plate_difference(a: &[u8], b: &[u8]) -> (f64, u8) { + assert_eq!(a.len(), b.len()); + let max = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + let mean = a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::() / a.len() as f64; + (mean, max) +} + +fn text_of(r: &Recorder) -> String { + r.glyphs.iter().filter_map(|g| g.unicode.as_deref()).collect() +} + +/// Reads `source`, writes it again from the runs, and checks positions, outlines, text and +/// plates. Returns the runs, the source and the rewrite as read. +fn round_trip(source: &[u8]) -> (Vec, Recorder, Recorder) { + let mut before = read(source); + assert!(!before.glyphs.is_empty()); + let items = text_items(&before.glyphs, &std::mem::take(&mut before.fonts).finish()); + let pdf = rewrite(&items); + let after = read(&pdf); + + let (mean, max) = plate_difference(&black_plate(source), &black_plate(&pdf)); + println!("black plates: mean difference {mean:.4}, largest {max}"); + assert!(mean < 0.01 && max <= 4, "plates differ: mean {mean}, max {max}"); + + if items.iter().all(|i| i.font.is_some()) { + assert_eq!(after.glyphs.len(), before.glyphs.len()); + for (b, a) in before.glyphs.iter().zip(&after.glyphs) { + let (pa, pb) = (a.matrix.translation(), b.matrix.translation()); + assert!((pa - pb).hypot() < 1e-3, "glyph origin {pa:?} vs {pb:?}"); + assert!(same_linear(a.matrix, b.matrix)); + let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box()); + assert!((ba.min_x() - bb.min_x()).abs() < 1e-3 && (ba.max_y() - bb.max_y()).abs() < 1e-3, "outline {ba:?} vs {bb:?}"); + } + assert_eq!(text_of(&after), text_of(&before), "Unicode survives"); + } else { + assert_eq!(after.actual_text, text_of(&before), "Unicode survives as ActualText"); + } + (items, before, after) +} + +/// krilla keeps glyph ids when the source is one of its own subsets: those are compact in +/// first-use order already. +fn assert_same_ids(before: &Recorder, after: &Recorder) { + assert!(before.glyphs.iter().zip(&after.glyphs).all(|(b, a)| a.id == b.id), "glyph ids match"); +} + +/// Whether the rewrite has the text as live text in an embedded CFF font. +fn has_live_cff(after: &Recorder) -> bool { + after.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref().starts_with(&[1]))) +} + +#[test] +fn truetype_text_reembeds_as_live_text() { + let Some(font) = inter() else { return }; + let source = source_pdf(&font); + let (items, before, after) = round_trip(&source); + assert_eq!(items.len(), 2, "two runs: one per text matrix"); + assert!(items.iter().all(|i| i.font.is_some())); + assert_same_ids(&before, &after); + assert_eq!(text_of(&after), SAMPLE.repeat(2)); +} + +#[test] +fn cff_text_reembeds_after_wrapping_the_bare_cff() { + let Some(font) = cff_otf() else { return }; + let source = source_pdf(&font); + assert!(String::from_utf8_lossy(&source).contains("/CIDFontType0C"), "krilla writes CFF as bare CIDFontType0C"); + let (items, before, after) = round_trip(&source); + let data = before.glyphs[0].data.as_ref().unwrap().data.clone(); + assert_eq!((*data).as_ref()[0], 1, "hayro hands out the bare CFF"); + assert!(Font::new(Data::from(data), 0).is_none(), "krilla needs an OpenType wrapper"); + assert!(items.iter().all(|i| i.font.is_some())); + assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines"); + assert_same_ids(&before, &after); + assert_eq!(text_of(&after), SAMPLE.repeat(2)); +} + +const TYPE3_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /T3 def \ +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <00> endcodespacerange \ +2 beginbfchar <41> <0041> <42> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end"; + +#[test] +fn type3_text_falls_back_to_outlines_with_actual_text() { + let font = "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /a 6 0 R /b 7 0 R >> /Encoding << /Type /Encoding /Differences [65 /a /b] >> \ + /FirstChar 65 /LastChar 66 /Widths [1000 1000] /Resources << >> /ToUnicode 8 0 R >>"; + let objects = [ + font.to_owned(), + stream("", "1000 0 0 0 800 800 d1 0 0 800 800 re f"), + stream("", "1000 0 0 0 800 800 d1 0 0 m 800 0 l 400 800 l f"), + stream("", TYPE3_CMAP), + ]; + let source = raw_pdf("0 0 0 1 k BT /F1 30 Tf 10 30 Td (ABBA) Tj ET", "/Font << /F1 5 0 R >>", &objects); + let (items, before, _) = round_trip(&source); + assert!(before.glyphs.iter().all(|g| g.type3 && g.key.is_none())); + assert!(items.iter().all(|i| i.font.is_none())); +} + +#[test] +fn standard_14_text_has_no_font_bytes_and_falls_back_to_outlines() { + let source = raw_pdf( + "0 0 0 1 k BT /F1 24 Tf 10 40 Td (Hamburg) Tj ET", + "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", + &[], + ); + let (items, before, _) = round_trip(&source); + assert!(before.glyphs.iter().all(|g| !g.type3 && g.data.is_none()), "hayro returns no bytes for standard fonts"); + assert!(items.iter().all(|i| i.font.is_none())); + assert_eq!(text_of(&before), "Hamburg"); +} + +#[test] +fn vertical_text_reembeds_as_horizontal_glyphs_at_their_positions() { + let Some(font) = cff_otf().or_else(inter) else { return }; + let mut source = source_pdf(&font); + let at: Vec = source.windows(11).enumerate().filter(|(_, w)| *w == b"/Identity-H").map(|(i, _)| i).collect(); + assert_eq!(at.len(), 1, "one Type0 font"); + // Same length, so the cross-reference offsets stay valid. + source[at[0] + 10] = b'V'; + let (items, before, after) = round_trip(&source); + assert!(before.glyphs.iter().all(|g| g.vertical)); + assert!(items.iter().all(|i| i.font.is_some())); + assert_same_ids(&before, &after); + assert!(after.glyphs.iter().all(|g| !g.vertical), "krilla writes horizontal fonts only"); +} + +const IDENTITY_CMAP: &str = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /U def \ +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def 1 begincodespacerange <0000> endcodespacerange \ +2 beginbfchar <0041> <0041> <0042> <0042> endbfchar endcmap CMapName currentdict /CMapResource defineresource pop end end"; + +#[test] +fn a_non_embedded_cid_font_falls_back_to_outlines() { + let objects = [ + "<< /Type /Font /Subtype /Type0 /BaseFont /NotEmbedded /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>".to_owned(), + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /NotEmbedded /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \ + /FontDescriptor 7 0 R >>" + .to_owned(), + "<< /Type /FontDescriptor /FontName /NotEmbedded /Flags 32 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 \ + /CapHeight 700 /StemV 80 >>" + .to_owned(), + stream("", IDENTITY_CMAP), + ]; + let source = raw_pdf("0 0 0 1 k BT /F1 24 Tf 10 40 Td <00410042> Tj ET", "/Font << /F1 5 0 R >>", &objects); + let (items, before, _) = round_trip(&source); + // hayro hands out the font resolver's substitute, marked as not embedded. + assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| !d.embedded))); + assert!(items.iter().all(|i| i.font.is_none())); + assert_eq!(text_of(&before), "AB"); +} + +/// A simple font with `FontFile3/Type1C`: hayro's Foxit Sans substitute, which is bare CFF, +/// hex-encoded so the PDF stays text. The widths are made up; positions follow them. +fn type1c_pdf() -> Vec { + let (data, _) = StandardFont::Helvetica.get_font_data(); + let hex: String = (*data).as_ref().iter().map(|b| format!("{b:02x}")).collect(); + let widths: Vec = (32..=126).map(|c| (400 + c % 7 * 50).to_string()).collect(); + let objects = [ + format!( + "<< /Type /Font /Subtype /Type1 /BaseFont /ChromSansOTF /FirstChar 32 /LastChar 126 /Widths [{}] \ + /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>", + widths.join(" ") + ), + "<< /Type /FontDescriptor /FontName /ChromSansOTF /Flags 32 /FontBBox [-166 -225 1000 931] /ItalicAngle 0 /Ascent 718 \ + /Descent -207 /CapHeight 718 /StemV 88 /FontFile3 7 0 R >>" + .to_owned(), + stream("/Subtype /Type1C /Filter /ASCIIHexDecode", &format!("{hex}>")), + ]; + let content = format!("0 0 0 1 k BT /F1 24 Tf 10 40 Td ({SAMPLE}) Tj ET BT /F1 13 Tf 0.96 0.26 -0.26 0.96 20 80 Tm ({SAMPLE}) Tj ET"); + raw_pdf(&content, "/Font << /F1 5 0 R >>", &objects) +} + +#[test] +fn type1c_text_reembeds_after_wrapping_the_cff() { + let source = type1c_pdf(); + let (items, before, after) = round_trip(&source); + assert!(before.glyphs.iter().all(|g| g.data.as_ref().is_some_and(|d| d.embedded && (*d.data).as_ref()[0] == 1)), "bare CFF from the file"); + assert_eq!(items.len(), 2); + assert!(items.iter().all(|i| i.font.is_some())); + assert!(has_live_cff(&after), "the rewrite embeds CFF again, not outlines"); + assert_eq!(text_of(&after), SAMPLE.repeat(2)); +} diff --git a/scripts/plate-check.sh b/scripts/plate-check.sh index 1050e867..d10a4a90 100755 --- a/scripts/plate-check.sh +++ b/scripts/plate-check.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Separate every plate-test scene with Ghostscript (local only, never CI) and judge the plates. +# With --flatten, also flatten each scene with craft-prepress's flattener and judge that page. # Ghostscript is `gs` on PATH, or CRAFT_PLATE_TEST_GS. set -euo pipefail root=$(cd "$(dirname "$0")/.." && pwd) @@ -8,5 +9,17 @@ if [[ -z "${CRAFT_PDF_TEST_FONT_DIR:-}" && -f "$fonts/Inter-Regular.ttf" ]]; the export CRAFT_PDF_TEST_FONT_DIR="$fonts" fi cd "$root" -cargo test -p craft-plate-test --test ghostscript -- --ignored --nocapture -echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/report.md" +case "${1:-}" in + "") + cargo test -p craft-plate-test --test ghostscript -- --ignored --nocapture + echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/report.md" + ;; + --flatten) + cargo test -p craft-prepress --features flatten --test flatten_plates -- --ignored --nocapture + echo "report: ${CARGO_TARGET_DIR:-target}/plate-test/flattened/report.md" + ;; + *) + echo "usage: $0 [--flatten]" >&2 + exit 2 + ;; +esac diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 2c0093cb..1627665f 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -326,3 +326,21 @@ The snapshot also preserves PdfCraft #1477 (WinAnsi Unicode fallback for TrueTyp fonts without Encoding) and #1499 (byte-aligned packed TIFF predictor rows). Synthetic tests cover 1/2/4/8-bit RGB rows and rendering, plus embedded TrueType text with absent or partial ToUnicode maps and explicit encoding precedence. + +## Craft patches for the flattener + +Local changes for craft-prepress's PDF-in adapter (`craft_prepress::flatten::pdf`), which reads +pages through a `Device` that keeps colors as inks and embeds the source fonts again. Code +comments label them `Craft patch (flatten)`. + +| Files | Change | +|---|---| +| hayro-interpret `font/outline.rs`, `font/cid.rs` | `OutlineFontData::embedded`: true when the bytes come from the PDF's `FontFile*`, false for a non-embedded CID font's substitute from the font resolver | +| hayro-interpret `font/outline.rs`, `font/cid.rs`, `font/blob.rs` | `OutlineFontData::index`: the face index of the bytes in a font collection, as the resolver returned it | +| hayro-interpret `font/outline.rs`, `font/type1.rs`, `font/mod.rs` | `OutlineGlyph::font_data` returns the bare CFF bytes of a simple `FontFile3/Type1C` font, with `embedded: true`; its glyph ids index the CFF. The descriptive fields (`postscript_name`, `weight`, flags) are left empty | +| hayro-interpret `device.rs` | `Device::set_overprint(fill, stroke, mode)`: the `op`, `OP` and `OPM` for the paints that follow. `Device::set_isolated`: the group just pushed is isolated (`/I true`). Both default to no-ops | +| hayro-interpret `interpret/path.rs`, `interpret/text.rs`, `x_object.rs` | `set_overprint` is called where `set_blend_mode` is for fills, strokes (the `sh` operator included), glyphs and images; `set_isolated` after an isolated group is pushed | +| hayro-interpret `color.rs` | `Color::ink_paint`: the inks a color paints, as plate mode separates it (`color::InkPaint`). `Color::spot_alternate`: a spot ink at 100% (`color::SpotAlternate`), CMYK when the alternate space is DeviceCMYK. `Color::opacity` and `Color::is_device_cmyk` | +| hayro-interpret `color.rs` | `color::InkSpace`, `ColorSpace::ink_space` and `ColorSpace::ink_values`: how a space's colors are kept as inks (gray, CMYK, its colorants, `All`, `None`) and colors converted in batches, process color as `Color::ink_paint` converts it. `ColorSpace::components`; `ColorSpace::is_device_cmyk` and `ColorSpace::spot_alternate` public | +| hayro-interpret `types.rs`, `x_object.rs` | `RasterImage::with_inks`: an image's samples in its own space's inks (`InkImage`), `/Decode` and `/Matte` applied, transfer functions left out, with its alpha at the alpha's own size | +| hayro-interpret `interpret/mod.rs` | The `sh` operator drops the shading's `/Background`, which only a shading pattern paints (ISO 32000-2 §8.7.4.5.1) | diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index b1a9faac..509fd7da 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -182,7 +182,7 @@ "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "85391b386b0c34b6bb1d6757c3d083ca07764062d085e0324456a377d6d520fc" + "sha256": "025186ea5726339068f66d39bc0f5eafb05f6223e4a9a74b9944c20d0fbe7886" }, { "path": "src/context.rs", @@ -200,7 +200,7 @@ "path": "src/device.rs", "source_sha256": "6f0c1203d1e60724cdd11e9a230249247a70f578c4a7ccb9bf39a23411b32db6", "source_git_blob": "36c64a23fd23f6c4abad941077cf93296a67841f", - "sha256": "abe1a0a835deb409b4d466e1d0f041a0e53ef8981be0929070afc77258cd8434" + "sha256": "4465153ace47c6893958f187a37579dd87139e04505749483aea139f5f2bac99" }, { "path": "src/encode.rs", @@ -212,13 +212,13 @@ "path": "src/font/blob.rs", "source_sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575", "source_git_blob": "6eabc26b9bc30bdc51f0b5fdd5e4b556d7583ec5", - "sha256": "109170b39c6e2c8c95a973c73a174e749b0d25e78b4c1e40c8a3ae4dfb220db7" + "sha256": "8c39f3ef95c48054d4aacaeb17189193c075df42fc767eee596bc4dbf2453d5f" }, { "path": "src/font/cid.rs", "source_sha256": "c1dbe7fe5b7f7092d97b84063008df54aebf87db4ba1fdb3a179fab9fe6674b8", "source_git_blob": "b99dcbc116ca1169df3a229eafd05ee5b9204626", - "sha256": "a0f7e140ddea72e08e78ebc996d4136947fd9bb28911b8745bf05d4941fcf320" + "sha256": "6c9927c47893edacab4a3258d4ec72b4d3cc2dd25c518e308884321110847ab6" }, { "path": "src/font/generated/glyph_names.rs", @@ -290,13 +290,13 @@ "path": "src/font/mod.rs", "source_sha256": "7e6411927d91205ca76c6b59f9d737cd1104d066f8cfcc0a87f481016ef30313", "source_git_blob": "7c8c8d086d59139df791cdb6952506473ec4b5a7", - "sha256": "0de994e24c9413e2be1b6116ffe5ea44659df05bf61afdb60e14107630854492" + "sha256": "c0c903cb8a33ab863412c407ad10181adec0d22b81c899fc5225de14d8878091" }, { "path": "src/font/outline.rs", "source_sha256": "2462d9ae4008c620afb47103bfa55362e75d84a28cb418240439b147de4ada8c", "source_git_blob": "558d886cad5b9f79c6c1422f604c517a5f98ce0d", - "sha256": "2462d9ae4008c620afb47103bfa55362e75d84a28cb418240439b147de4ada8c" + "sha256": "a15235fb600d8bf6496405c125ea03f9196e7428e439ef7e01303eef14efa000" }, { "path": "src/font/standard_font.rs", @@ -314,7 +314,7 @@ "path": "src/font/type1.rs", "source_sha256": "431b1fe86ca51bd993e81084a64c3e8ed8b90a67385a30eb4ffedfdd98c6f96b", "source_git_blob": "02f76bfb8095041e3d54df014190047de46be4c5", - "sha256": "a8eabb741ce3991bca11a4f72f42658b33802b8688dda4cc5904e3e4cdae9955" + "sha256": "af73de5038ee590bb062ee1e610a60e5e4f51d109ae765922ce9669b4e36ce95" }, { "path": "src/font/type3.rs", @@ -362,13 +362,13 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "c83663220d27ecf7f80419aab07d44a7ab1894d4932b71b9f5dee83e7e2ba221" + "sha256": "3f8958e8d234f00b24478acb8081aa1aaffdca6b8a12b24c4abc6f534ce09a05" }, { "path": "src/interpret/path.rs", "source_sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b", "source_git_blob": "838ea099f11d918d6e333e32503368be864689f8", - "sha256": "2731da799d456cbdddc18518f468ba202356b5f255a116fc7801f65a4aedb9b0" + "sha256": "0e4d2dc0a2d5a2c5cdc0d1d9358fcf3b0854e8d24d002ce1830a1b4def786bc8" }, { "path": "src/interpret/state.rs", @@ -380,7 +380,7 @@ "path": "src/interpret/text.rs", "source_sha256": "35eafa80147aea635bdef127c33ace20e1695e11e87c7bfe1e035c886806ff30", "source_git_blob": "ddec0400ea7a639c14769ac9ee7e137d000b3efd", - "sha256": "34911b1b423e24638863c1c9b173a4d1ccddd3bf08789dab226765adc072c10f" + "sha256": "b34117d28ca7987833626f2e938abe4dd5d02ea869db7b0bdc1b8a264d775f9c" }, { "path": "src/lib.rs", @@ -422,7 +422,7 @@ "path": "src/types.rs", "source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46", "source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd", - "sha256": "9b5cc9485c0e4104aee99be773706490e6a33f3c03e796f27940571041864125" + "sha256": "669fb4a9427193a962b7cda70656b688795ea691f67ae52333124807ba88006c" }, { "path": "src/util.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "6adbf88e6bcdde2321c517525c1291ce7ead55a7989c930757ed384c309589d4" + "sha256": "4d2d5a2b8ff6d75308cb4fbb606bb2135073d35e859f1b32a5be5b3276ca2c20" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index e85fd17e..3965581e 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -2073,8 +2073,8 @@ impl ColorSpace { } /// PdfCraft patch (35): DeviceCMYK itself (`CalCMYK` reads as it too), where the nonzero - /// overprint mode applies. - pub(crate) fn is_device_cmyk(&self) -> bool { + /// overprint mode applies. Craft patch (flatten): public. + pub fn is_device_cmyk(&self) -> bool { matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) } @@ -2540,6 +2540,299 @@ pub(crate) trait ToRgb { } } +/// Craft patch (flatten): the inks a colour paints, for devices that keep colours as inks +/// instead of RGB (see [`Color::ink_paint`]). +#[derive(Clone, Debug, PartialEq)] +pub enum InkPaint { + /// Tints `0.0..=1.0` of the listed inks; other inks are unspecified. Process colour lists + /// all four process inks. + Tints(Vec<(Ink, f32)>), + /// The same tint on every plate (the `All` colorant). + All(f32), + /// No ink (the `None` colorant). + None, +} + +/// Craft patch (flatten): how a spot ink looks at 100%, from its colour space's alternate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SpotAlternate { + /// The alternate space is DeviceCMYK: the tint transform's CMYK. + Cmyk([f32; 4]), + /// Any other alternate space: the sRGB appearance. + Srgb([u8; 3]), +} + +impl Color { + /// Craft patch (flatten): the opacity (`ca` or `CA`) the colour paints with. + pub fn opacity(&self) -> f32 { + self.opacity + } + + /// Craft patch (flatten): whether the colour was set in DeviceCMYK itself, where the + /// nonzero overprint mode applies (ISO 32000-2 §8.6.7). + pub fn is_device_cmyk(&self) -> bool { + self.color_space.is_device_cmyk() + } + + /// Craft patch (flatten): the inks the colour paints, as plate mode separates it: + /// `Separation` and `DeviceN` colorants as they are, and process colour as CMYK, converted + /// through `simulation` where plate mode converts it. `None` for a pattern space. + pub fn ink_paint(&self, simulation: Option<&CmykSimulation>) -> Option { + self.color_space + .ink_paint(&self.components, simulation, false) + } + + /// Craft patch (flatten): how spot `ink` of this colour's space looks at 100%; `None` when + /// the space doesn't name it. + pub fn spot_alternate(&self, ink: &Ink) -> Option { + self.color_space.spot_alternate(ink) + } +} + +impl ColorSpace { + /// Craft patch (flatten): see [`Color::ink_paint`]. + fn ink_paint( + &self, + components: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + ) -> Option { + let get = |k: usize| unit(components.get(k).copied().unwrap_or(0.0)); + Some(match self.0.as_ref() { + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::None => InkPaint::None, + Colorant::All => InkPaint::All(get(0)), + Colorant::Ink(ink) => InkPaint::Tints(vec![(ink.clone(), get(0))]), + }, + ColorSpaceType::DeviceN(d) => { + let tints: Vec<_> = d + .colorants + .iter() + .enumerate() + .filter_map(|(k, c)| match c { + Colorant::Ink(ink) => Some((ink.clone(), get(k))), + _ => None, + }) + .collect(); + if d.is_none || tints.is_empty() { + InkPaint::None + } else { + InkPaint::Tints(tints) + } + } + ColorSpaceType::Indexed(i) => { + let index = components.first().copied().unwrap_or(0.0); + let index = (clamp_component(index, 0.0, f32::from(i.hival)) + 0.5) as usize; + return i.base.ink_paint(i.values.get(index)?, simulation, true); + } + ColorSpaceType::Pattern(_) => return None, + ColorSpaceType::Plate(p) => { + return p.inner.ink_paint(components, simulation, manual_scale); + } + _ => { + let cmyk = self.process_cmyk(components, simulation, manual_scale)?; + InkPaint::Tints(Ink::PROCESS.into_iter().zip(cmyk).collect()) + } + }) + } + + /// Craft patch (flatten): see [`Color::spot_alternate`]. + pub fn spot_alternate(&self, ink: &Ink) -> Option { + let (alternate, transform, components) = match self.0.as_ref() { + ColorSpaceType::Separation(s) => { + if s.colorant != Colorant::Ink(ink.clone()) { + return None; + } + (&s.alternate_space, &s.tint_transform, smallvec![1.0]) + } + ColorSpaceType::DeviceN(d) => { + let k = d + .colorants + .iter() + .position(|c| matches!(c, Colorant::Ink(i) if i == ink))?; + let mut components: ColorComponents = smallvec![0.0; d.colorants.len()]; + *components.get_mut(k)? = 1.0; + (&d.alternate_space, &d.tint_transform, components) + } + ColorSpaceType::Indexed(i) => return i.base.spot_alternate(ink), + ColorSpaceType::Pattern(cs) => return cs.spot_alternate(ink), + ColorSpaceType::Plate(p) => return p.inner.spot_alternate(ink), + _ => return None, + }; + if alternate.is_device_cmyk() + && let Some(cmyk) = transform.eval(components.clone()) + && let [c, m, y, k] = cmyk[..] + { + return Some(SpotAlternate::Cmyk([c, m, y, k].map(unit))); + } + Some(SpotAlternate::Srgb(self.appearance(&components))) + } +} + +/// Craft patch (flatten): the components a device that keeps colours as inks stores a space's +/// colours in, as plate mode separates them (see [`ColorSpace::ink_values`]). +#[derive(Clone, Debug, PartialEq)] +pub enum InkSpace { + /// DeviceGray: one gray level, 1 = white. + Gray, + /// The four process tints. `converted` when the colours are converted into CMYK (RGB, Lab, + /// CIE-based and ICC spaces other than a CMYK profile the simulation leaves as it is). + Cmyk { + /// Whether the colours are converted into CMYK. + converted: bool, + }, + /// Tints of these inks: `Separation` and `DeviceN` colorants, `None` colorants left out. + Inks(Vec), + /// One tint for every ink (the `All` colorant). + All, + /// No ink (the `None` colorant, or a pattern space). + None, +} + +impl InkSpace { + /// Values per colour. + pub fn len(&self) -> usize { + match self { + Self::Gray | Self::All => 1, + Self::Cmyk { .. } => 4, + Self::Inks(inks) => inks.len(), + Self::None => 0, + } + } + + /// Whether colours have no values (the space paints nothing). + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl ColorSpace { + /// Craft patch (flatten): the components per colour. + pub fn components(&self) -> usize { + usize::from(self.num_components()) + } + + /// Craft patch (flatten): how [`Self::ink_values`] stores this space's colours. An Indexed + /// space stores its base space's. + pub fn ink_space(&self, simulation: Option<&CmykSimulation>) -> InkSpace { + match self.0.as_ref() { + ColorSpaceType::DeviceGray => InkSpace::Gray, + ColorSpaceType::DeviceCmyk => InkSpace::Cmyk { converted: false }, + ColorSpaceType::ICCBased(icc) if icc.0.number_components == 4 => InkSpace::Cmyk { + converted: simulation.is_some_and(|s| self.simulated_by(s)), + }, + ColorSpaceType::Indexed(i) => i.base.ink_space(simulation), + ColorSpaceType::Separation(s) => match &s.colorant { + Colorant::Ink(ink) => InkSpace::Inks(vec![ink.clone()]), + Colorant::All => InkSpace::All, + Colorant::None => InkSpace::None, + }, + ColorSpaceType::DeviceN(d) => { + let inks: Vec = d + .colorants + .iter() + .filter_map(|c| match c { + Colorant::Ink(ink) => Some(ink.clone()), + _ => None, + }) + .collect(); + if d.is_none || inks.is_empty() { + InkSpace::None + } else { + InkSpace::Inks(inks) + } + } + ColorSpaceType::Pattern(_) => InkSpace::None, + ColorSpaceType::Plate(p) => p.inner.ink_space(simulation), + _ => InkSpace::Cmyk { converted: true }, + } + } + + /// Craft patch (flatten): the colours `colors` (whole colours of [`Self::components`] + /// values each) appended to `out` as [`Self::ink_space`] values, `0.0..=1.0`. Process + /// colour converts as [`Color::ink_paint`] does, in batches. + pub fn ink_values(&self, colors: &[f32], simulation: Option<&CmykSimulation>, out: &mut Vec) { + self.ink_values_scaled(colors, simulation, false, out); + } + + fn ink_values_scaled( + &self, + colors: &[f32], + simulation: Option<&CmykSimulation>, + manual_scale: bool, + out: &mut Vec, + ) { + let n = usize::from(self.num_components()).max(1); + let colors = &colors[..colors.len() / n * n]; + match self.0.as_ref() { + ColorSpaceType::Indexed(i) => { + let width = i.base.ink_space(simulation).len(); + let mut table = Vec::with_capacity((usize::from(i.hival) + 1) * width); + for values in &i.values { + let start = table.len(); + i.base.ink_values_scaled(values, simulation, true, &mut table); + table.resize(start + width, 0.0); + } + for index in colors { + let index = (clamp_component(*index, 0.0, f32::from(i.hival)) + 0.5) as usize; + match table.get(index * width..(index + 1) * width) { + Some(values) => out.extend_from_slice(values), + None => out.extend(std::iter::repeat_n(0.0, width)), + } + } + } + ColorSpaceType::Plate(p) => p.inner.ink_values_scaled(colors, simulation, manual_scale, out), + ColorSpaceType::Pattern(_) => {} + ColorSpaceType::DeviceGray => out.extend(colors.iter().map(|v| unit(*v))), + ColorSpaceType::Separation(s) => { + if s.colorant != Colorant::None { + out.extend(colors.iter().map(|v| unit(*v))); + } + } + ColorSpaceType::DeviceN(d) => { + if d.is_none { + return; + } + for colour in colors.chunks_exact(n) { + for (c, v) in d.colorants.iter().zip(colour) { + if matches!(c, Colorant::Ink(_)) { + out.push(unit(*v)); + } + } + } + } + _ => { + let start = out.len(); + out.resize(start + colors.len() / n * 4, 0.0); + if let Some(simulation) = simulation.filter(|s| self.simulated_by(s)) + && self + .simulate_cmyk(simulation, colors, manual_scale, &mut out[start..]) + .is_some() + { + for v in &mut out[start..] { + *v = unit(*v); + } + return; + } + if self.separates_through_srgb() { + let mut rgb = vec![0_u8; colors.len() / n * 3]; + if self.convert_f32(colors, &mut rgb, manual_scale).is_some() { + for (cmyk, rgb) in out[start..].chunks_exact_mut(4).zip(rgb.chunks_exact(3)) { + cmyk.copy_from_slice(&rgb_to_cmyk([rgb[0], rgb[1], rgb[2]].map(u8_to_f32))); + } + return; + } + } + for (cmyk, colour) in out[start..].chunks_exact_mut(4).zip(colors.chunks_exact(n)) { + if let Some(c) = self.process_cmyk(colour, None, manual_scale) { + cmyk.copy_from_slice(&c); + } + } + } + } + } +} + #[cfg(test)] mod device_color_compatibility_tests { use super::ColorSpace; diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 57d11be2..6f666291 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -107,6 +107,14 @@ pub trait Device<'a> { fn begin_knockout_erase(&mut self) {} /// Knockout patch (pdfcraft #2974): see `knockout_erase_wanted`. fn end_knockout_erase(&mut self) {} + /// Craft patch (flatten): the overprint parameters for the paints that follow, until the + /// next call: `op` (`fill`, for fills, glyph fills and images), `OP` (`stroke`) and `OPM` + /// (`mode`, 0 or 1; ISO 32000-2 §8.6.7). Called where `set_blend_mode` is called for a + /// paint. + fn set_overprint(&mut self, _fill: bool, _stroke: bool, _mode: u8) {} + /// Craft patch (flatten): the transparency group just pushed is isolated (`/I true`), until + /// the matching `pop_transparency_group`. + fn set_isolated(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 8df3611a..53501e5a 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -180,6 +180,8 @@ impl CffFontBlob { pub(crate) struct OpenTypeFontBlob { yoke: Arc, cff_blob: Option, + // Craft patch (flatten): the face index in a collection, for `OutlineFontData::index`. + index: u32, } impl Debug for OpenTypeFontBlob { @@ -248,6 +250,7 @@ impl OpenTypeFontBlob { Some(Self { yoke: Arc::new(font_ref_yoke), cff_blob, + index, }) } @@ -255,6 +258,11 @@ impl OpenTypeFontBlob { self.yoke.backing_cart().clone() } + /// Craft patch (flatten): the face index the blob was read with. + pub(crate) fn index(&self) -> u32 { + self.index + } + pub(crate) fn font_ref(&self) -> &FontRef<'_> { &self.yoke.as_ref().get().font_ref } diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index ed32cfe4..db40d5a7 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -343,6 +343,20 @@ impl Type0Font { } } + /// Craft patch (flatten): the face index of `font_data`. + pub(crate) fn font_index(&self) -> u32 { + match &self.font_type { + FontType::OpenType(t) => t.index(), + FontType::Cff(_) | FontType::Type1(_) => 0, + } + } + + /// Craft patch (flatten): whether `font_data` comes from the file rather than the font + /// resolver. + pub(crate) fn is_embedded(&self) -> bool { + !self.fallback + } + /// Get the PostScript name. pub(crate) fn postscript_name(&self) -> Option<&str> { self.postscript_name.as_deref() diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 103ee396..37f51f8c 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -155,7 +155,8 @@ impl OutlineGlyph { /// Get raw font bytes and metadata for downstream use. /// - /// Returns `None` for Type1 fonts. + /// Returns `None` for Type1 fonts. Craft patch (flatten): Type1C (`FontFile3`) fonts return + /// their CFF bytes. pub fn font_data(&self) -> Option { self.font.font_data() } diff --git a/vendor/hayro-interpret/src/font/outline.rs b/vendor/hayro-interpret/src/font/outline.rs index 558d886c..f46005b9 100644 --- a/vendor/hayro-interpret/src/font/outline.rs +++ b/vendor/hayro-interpret/src/font/outline.rs @@ -15,6 +15,11 @@ pub struct OutlineFontData { pub data: crate::font::FontData, /// Cache key for font deduplication. pub cache_key: u128, + /// Craft patch (flatten): the face index of `data` in a font collection. + pub index: u32, + /// Craft patch (flatten): true when `data` comes from the PDF (`FontFile*`); false when it + /// is the font resolver's substitute for a font the PDF doesn't embed. + pub embedded: bool, /// PostScript name (e.g., "TimesNewRomanPS-BoldMT"). pub postscript_name: Option, /// Font weight (100-900, 400=normal, 700=bold). @@ -119,13 +124,28 @@ impl OutlineFont { /// Get raw font bytes and metadata. /// - /// Returns None for Type1 fonts and non-embedded TrueType fonts. + /// Returns None for Type1 (`FontFile`), standard and non-embedded simple fonts. pub(crate) fn font_data(&self) -> Option { match self { - Self::Type1(_) => None, + // Craft patch (flatten): Type1C (`FontFile3`) fonts hand out their bare CFF bytes. + // The font dictionary's descriptive fields are not read. + Self::Type1(t) => Some(OutlineFontData { + data: t.font_data()?, + cache_key: t.cache_key(), + index: 0, + embedded: true, + postscript_name: None, + weight: None, + is_italic: false, + is_serif: false, + is_monospace: false, + }), Self::TrueType(t) => Some(OutlineFontData { data: t.font_data()?, cache_key: t.cache_key(), + // Craft patch (flatten): only an embedded `FontFile2`, read as face 0, has bytes. + index: 0, + embedded: true, postscript_name: t.postscript_name().map(|s| s.to_string()), weight: t.weight(), is_italic: t.is_italic(), @@ -135,6 +155,9 @@ impl OutlineFont { Self::Type0(t) => Some(OutlineFontData { data: t.font_data()?, cache_key: t.cache_key(), + // Craft patch (flatten): a substitute from the resolver is not embedded. + index: t.font_index(), + embedded: t.is_embedded(), postscript_name: t.postscript_name().map(|s| s.to_string()), weight: t.weight(), is_italic: t.is_italic(), diff --git a/vendor/hayro-interpret/src/font/type1.rs b/vendor/hayro-interpret/src/font/type1.rs index 4ef5f953..3f9bac40 100644 --- a/vendor/hayro-interpret/src/font/type1.rs +++ b/vendor/hayro-interpret/src/font/type1.rs @@ -105,6 +105,15 @@ impl Type1Font { } } + /// Craft patch (flatten): the bare CFF bytes of a `FontFile3/Type1C` font, which the glyph + /// ids index. Type 1 and standard fonts have none. + pub(crate) fn font_data(&self) -> Option { + match &self.1 { + Kind::Cff(c) => Some(c.font.font_data()), + Kind::Standard(_) | Kind::Type1(_) => None, + } + } + pub(crate) fn char_code_to_unicode(&self, char_code: u32) -> Option { if let Some(to_unicode) = &self.2 && let Some(c) = to_unicode.lookup_bf_string(char_code) diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 44cbdc0c..1e9c0b80 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -874,7 +874,10 @@ fn interpret_window<'a>( &defaults, ) }) - .map(|s| { + .map(|mut s| { + // Craft patch (flatten): `sh` ignores `/Background` (ISO 32000-2 + // §8.7.4.5.1); only a shading pattern paints it. + s.background = None; Pattern::Shading(ShadingPattern { shading: Arc::new(s), matrix: Affine::IDENTITY, diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 3fbd1129..07d37b2d 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -76,6 +76,8 @@ pub(crate) fn fill_path_impl<'a>( let paint = get_paint(context, false); device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); let mut draw = |path: &BezPath| { // pdf.js issue 4260: Replace zero-sized paths with a small stroke instead. @@ -136,6 +138,8 @@ pub(crate) fn stroke_path_impl<'a>( let stroke_props = context.stroke_props(); device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); device.set_blend_mode(context.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); let paint = get_paint(context, true); let path = path.unwrap_or(context.path()); @@ -148,6 +152,16 @@ pub(crate) fn stroke_path_impl<'a>( } } +/// Craft patch (flatten): passes the graphics state's `op`, `OP` and `OPM` to the device. +pub(crate) fn set_overprint<'a>(context: &Context<'a>, device: &mut impl Device<'a>) { + let state = &context.get().graphics_state; + device.set_overprint( + state.overprint_fill, + state.overprint_stroke, + state.overprint_mode, + ); +} + pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> { let data = if is_stroke { context.get().stroke_data() diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index 0bf5a996..f9f68b1e 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -127,6 +127,8 @@ fn show_glyph<'a>( device.set_soft_mask(ctx.get().graphics_state.soft_mask.clone()); device.set_blend_mode(ctx.get().graphics_state.blend_mode); + // Craft patch (flatten): tell the device the overprint parameters. + crate::interpret::path::set_overprint(ctx, device); let stroke_props = ctx.stroke_props(); // PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1 diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index af540247..7c46cbc8 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -106,6 +106,39 @@ impl RasterImage<'_> { } } +impl RasterImage<'_> { + /// Craft patch (flatten): the image's samples as inks, in its own color space (see + /// [`InkImage`]); process colour converts through `simulation` where plate mode converts it. + pub fn with_inks( + &self, + simulation: Option<&crate::color::CmykSimulation>, + func: impl FnOnce(InkImage), + ) { + if let Some(image) = crate::x_object::decode_inks(&self.0, simulation) { + func(image); + } + } +} + +/// Craft patch (flatten): a raster image decoded into inks rather than RGB. +#[derive(Clone)] +pub struct InkImage { + /// The width in samples. + pub width: u32, + /// The height in samples. + pub height: u32, + /// What each sample's values are. + pub space: crate::color::InkSpace, + /// The image's color space (an Indexed space itself, not its base). + pub color_space: crate::color::ColorSpace, + /// `space.len()` bytes per sample, rows top to bottom, 255 = 1.0. + pub samples: Vec, + /// The `/SMask`, `/Mask` or `/SMaskInData` alpha, at its own size. + pub alpha: Option, + /// As [`RgbData::scale_factors`]. + pub scale_factors: (f32, f32), +} + impl CacheKey for RasterImage<'_> { fn cache_key(&self) -> u128 { self.0.cache_key() diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 60af0748..d8f01a30 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,15 +1,16 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; use crate::color::{ - ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkUse, Plate, ToRgb, + CmykSimulation, ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkSpace, + InkUse, Plate, ToRgb, }; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; -use crate::interpret::path::get_paint; +use crate::interpret::path::{get_paint, set_overprint}; use crate::interpret::state::ActiveTransferFunction; use crate::{BlendMode, CacheKey, ClipPath, Image, Paint, RasterImage, StencilImage}; use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret}; -use crate::{ImageData, LumaData, RgbData}; +use crate::{ImageData, InkImage, LumaData, RgbData}; use hayro_syntax::bit_reader::BitReader; use hayro_syntax::content::TypedIter; use hayro_syntax::object::Array; @@ -209,6 +210,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>( if x_object.is_knockout { device.set_knockout(); } + // Craft patch (flatten): tell the device the group is isolated. + if x_object.is_isolated { + device.set_isolated(); + } } device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); @@ -340,6 +345,8 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.set_soft_mask(None); device.set_blend_mode(BlendMode::default()); + // Craft patch (flatten): tell the device the overprint parameters. + set_overprint(context, device); // PdfCraft patch (46): the group just pushed applies the fill opacity, so a stencil mask's // paint (the fill color) must not carry it again: at `ca 0.5` a mask was drawn at a // quarter of its opacity (#2975). The alpha is put back below: `restore_state` doesn't @@ -950,6 +957,111 @@ fn decode_raster( Some(DecodedRaster { image, alpha }) } +/// Craft patch (flatten): the image's samples in its own space's inks (see +/// [`RasterImage::with_inks`](crate::RasterImage::with_inks)): `/Decode` and `/Matte` applied, +/// process colour converted as [`ColorSpace::ink_values`] does, transfer functions left out +/// (as on a plate). +pub(crate) fn decode_inks( + obj: &ImageXObject<'_>, + simulation: Option<&CmykSimulation>, +) -> Option { + if obj.is_mask { + return None; + } + let mut ctx = decode_context(obj, None)?; + let cs = ctx.color_space.clone(); + let n = cs.components(); + if n == 0 { + return None; + } + let space = cs.ink_space(simulation); + let bpc = ctx.bits_per_component; + let mut height = ctx.height; + let is_default = ctx.decode_arr == cs.default_decode_arr(bpc as f32); + let is_inverted = ctx.decode_arr == cs.inverted_default_decode_arr(bpc as f32); + let identity = !cs.is_indexed() + && space.len() == n + && !matches!(space, InkSpace::Cmyk { converted: true }); + let matte = obj + .stream + .dict() + .get::>(SMASK) + .and_then(|s| s.dict().get::(MATTE)) + .filter(|m| m.len() == n && !cs.is_indexed()); + + let mut alpha_height = height; + let alpha = resolve_alpha( + obj, + &mut ctx.decoded, + None, + &cs, + bpc, + ctx.width, + &mut alpha_height, + ctx.scale_factors, + None, + ) + .flatten(); + let matte_alpha = alpha + .as_ref() + .filter(|a| a.width == ctx.width && a.height == height && matte.is_some()); + + let samples = if bpc == 8 && identity && (is_default || is_inverted) && matte_alpha.is_none() { + let mut data = ctx.decoded.data.into_owned(); + fix_image_length(&mut data, ctx.width, &mut height, 0, &cs)?; + if is_inverted { + for b in &mut data { + *b = 255 - *b; + } + } + data + } else { + let mut components = get_components(&ctx.decoded.data, ctx.width, height, &cs, bpc)?; + fix_image_length(&mut components, ctx.width, &mut height, 0, &cs)?; + let width = space.len(); + let mut samples = Vec::with_capacity(components.len() / n * width); + let mut colors = Vec::new(); + let mut values = Vec::new(); + const CHUNK: usize = 1 << 14; + for (c, chunk) in components.chunks(CHUNK * n).enumerate() { + colors.clear(); + for pixel in chunk.chunks_exact(n) { + let start = colors.len(); + for (sample, range) in pixel.iter().zip(&ctx.decode_arr) { + colors.push(decode_sample(*sample, bpc, *range)); + } + colors.resize(start + n, 0.0); + } + if let (Some(matte), Some(alpha)) = (&matte, matte_alpha) { + for (k, pixel) in colors.chunks_exact_mut(n).enumerate() { + let a = alpha.data.get(c * CHUNK + k).copied().unwrap_or(255); + if a == 0 { + continue; + } + let a = f32::from(a) / 255.0; + for (v, m) in pixel.iter_mut().zip(matte.iter()) { + *v = m + (*v - m) / a; + } + } + } + values.clear(); + cs.ink_values(&colors, simulation, &mut values); + samples.extend(values.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)); + } + samples + }; + + Some(InkImage { + width: ctx.width, + height, + space, + color_space: cs, + samples, + alpha, + scale_factors: ctx.scale_factors, + }) +} + fn decode_mask_bytes( mut decoded_data: Cow<'_, [u8]>, width: u32, diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index c47779c7..6171ba88 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -23,6 +23,9 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/11-png-decode-cap.patch` | PNG decode cap | `graphics/image.rs` | | `patches/12-optional-content.patch` | Optional content | `chunk_container.rs`, `configure/{PDF_A1,PDF_A2,PDF_A4,PDF_UA1}.md`, `configure/validate.rs`, `content.rs`, `error.rs`, `graphics/{mod,optional_content}.rs`, `lib.rs`, `resource.rs`, `serialize.rs`, `surface.rs` | | `patches/13-annotation-bounds.patch` | Annotation and destination bounds | `error.rs`, `interactive/{annotation,destination}.rs`, `page.rs`, `serialize.rs` | +| `patches/14-devicen-pdf-version.patch` | DeviceN attributes by PDF version | `graphics/device_n.rs` | +| `patches/15-pdfx-conformance.patch` | PDF/X conformance key | `serialize.rs` | +| `patches/16-modification-date.patch` | Modification date | `interchange/metadata.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -181,7 +184,7 @@ written as CMYK, which knocks out or replaces the other plates. - From PDF 1.6, a space with spots gets an attributes dictionary whose `/Colorants` maps each spot to its Separation space. When spots and process inks mix, it is `/Subtype /NChannel` with `/Process << /ColorSpace alternate /Components [/Cyan /Magenta - /Yellow /Black] >>`. + /Yellow /Black] >>`. Patch 14 changes which of these each PDF version gets. - Validation: a spot's CMYK alternate counts as its Separation fallback, so one colorant name keeps one alternate across Separation and DeviceN spaces (`ValidationError::InconsistentSeparationFallback`). `/All` and `/None` are Separation-only @@ -356,3 +359,54 @@ runs at serialization, before the annotation object starts. Refusing a single an instead of skipping it keeps the indices of tagged annotations in the structure tree valid. Tests: `crates/pdf/tests/link_annotations.rs`. + +## 14: DeviceN attributes by PDF version + +Patch 06 writes the DeviceN attributes dictionary only from PDF 1.6. A PDF/X-1a file is PDF +1.3 or 1.4, so its spots then had no `/Colorants`, and a file written at 1.6 or later and +relabeled 1.4 kept `/Subtype /NChannel` and `/Process`, which preflight tools flag in a 1.4 +file. + +ISO 32000-1, 8.6.6.5 (Table 71) marks the attributes dictionary and its `/Colorants` entry +PDF 1.3, and `/Subtype`, `/Process` and `/MixingHints` PDF 1.6; the PDF Reference for 1.3 and +1.4 lists `/Colorants` as the dictionary's only entry. The spec was checked from memory, not +against the text. + +- Every version krilla writes (1.4 and later): a space with spots gets an attributes + dictionary with `/Colorants`, each spot mapped to its Separation space. +- From PDF 1.6, a space that mixes spots and process inks also gets `/Subtype /NChannel` and + `/Process`, as patch 06 writes them. +- A space of process inks only gets no attributes, as before. + +Tests: `crates/pdf/tests/print_color.rs` (`device_n_attributes_follow_the_pdf_version`) and +`crates/prepress/tests/flatten_document.rs` (`pdf_x1a_output_is_pdf_14_without_nchannel`). + +## 15: PDF/X conformance key + +Patch 08 writes only `GTS_PDFXVersion`. PDF/X-1a and X-3 also need `GTS_PDFXConformance` in +the document information dictionary: a PDF/X-1a:2003 file carries `GTS_PDFXVersion +(PDF/X-1:2003)` and `GTS_PDFXConformance (PDF/X-1a:2003)` (ISO 15930-4). Later parts (X-4 +and on) have no conformance key. + +- `SerializeSettings::pdfx_conformance: Option` writes `/GTS_PDFXConformance` to the + dictionary, under the same PDF 2.0 rule as patch 08's entries. It defaults to `None`. +- There is no XMP property for it: the `pdfxid` schema (ISO 15930-7) defines only + `GTS_PDFXVersion`. A PDF 2.0 file therefore omits the key. + +Tests: `crates/pdf/tests/print_color.rs` (`trapped_and_pdfx_version_are_written_to_info_and_xmp`) +and `crates/prepress/tests/flatten_document.rs` (`pdf_x1a_output_is_pdf_14_without_nchannel`). + +## 16: modification date + +Upstream has only a creation date and writes it as the modification date too (`/ModDate` and +`xmp:ModifyDate`), so a document rewritten from another, such as a flattened copy, can't keep +its source's modification date. + +- `Metadata::modification_date(DateTime)` sets the date written as `/ModDate` and + `xmp:ModifyDate`. Without it, the creation date is written there, as upstream does. +- The XMP history events keep the creation date. + +Tests: `crates/pdf/tests/writing.rs` (`writes_pages_metadata_and_selected_version_without_a_renderer`, +`the_creation_date_is_the_modification_date_when_none_is_given`) and +`crates/prepress/tests/flatten_document.rs` +(`a_document_flattens_page_by_page_and_keeps_boxes_metadata_and_shared_fonts`). diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index be542dd6..c802335c 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -157,7 +157,7 @@ { "path": "src/graphics/device_n.rs", "upstream_sha256": null, - "sha256": "29b01e54699f47f02c15288a74eeae265f02f01c3d97a2612c23784b87eaa542" + "sha256": "02f4d1ad8d5e72ddcf633c8d9255632386bd73f19aacf84941746409e0823bed" }, { "path": "src/graphics/graphic.rs", @@ -252,7 +252,7 @@ { "path": "src/interchange/metadata.rs", "upstream_sha256": "14fab986f2436a4275e7cca13e065763b14b938ae180632294455ad0199380ec", - "sha256": "95d5441904b7aef4b0e8812ea536c56e22744886d1c485d9bcc3b08c7ae2b3a7" + "sha256": "7c8a66d89eba13a150295837ef5042d1909cc2901be1433b776597c557345d1f" }, { "path": "src/interchange/mod.rs", @@ -317,7 +317,7 @@ { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "f5ccb6b92f546e1068e51e0791dbac8b802f07f29df0c2a6ccd1dcf8a1bdc938" + "sha256": "031eb4ba074367da1da63ec924ae4e5b67fbbbeb66c8ceef7fafb373bc769af3" }, { "path": "src/stream.rs", diff --git a/vendor/krilla/patches/14-devicen-pdf-version.patch b/vendor/krilla/patches/14-devicen-pdf-version.patch new file mode 100644 index 00000000..3a63160a --- /dev/null +++ b/vendor/krilla/patches/14-devicen-pdf-version.patch @@ -0,0 +1,51 @@ +diff --git a/src/graphics/device_n.rs b/src/graphics/device_n.rs +index 8ee7064..f2873ac 100644 +--- a/src/graphics/device_n.rs ++++ b/src/graphics/device_n.rs +@@ -17,9 +17,10 @@ use crate::stream::FilterStreamBuilder; + /// `[/DeviceN [names] alternate tintTransform attributes?]`. + /// + /// The alternate is DeviceCMYK, or the `cmyk_profile` ICC space with `no_device_cs`. The +-/// attributes dictionary (PDF 1.6+) lists each spot's Separation color space under +-/// `/Colorants`; when spots and process colorants mix, it is an `NChannel` space whose +-/// `/Process` names the CMYK space and components. ++/// attributes dictionary lists each spot's Separation color space under `/Colorants` ++/// (PDF 1.3). From PDF 1.6, when spots and process colorants mix, it is an `NChannel` space ++/// whose `/Process` names the CMYK space and components; `/Subtype` and `/Process` don't ++/// exist before 1.6, and PDF/X-1a preflights reject them. + #[derive(Debug, Eq, PartialEq, Hash, Clone)] + pub(crate) struct DeviceNColorSpace { + space: DeviceNSpace, +@@ -54,21 +55,20 @@ impl Cacheable for DeviceNColorSpace { + + let tint_ref = tint_transform(&space, sc, chunk_container); + +- let with_attrs = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16; + let mut separations = Vec::new(); +- if with_attrs { +- for (name, cmyk) in space.spots() { +- let separation = +- SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); +- let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); +- if let MaybeDeviceColorSpace::ColorSpace(cs) = +- sc.register_colorspace(chunk_container, cs) +- { +- separations.push((name.to_owned(), cs.get_ref())); +- } ++ for (name, cmyk) in space.spots() { ++ let separation = ++ SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); ++ let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); ++ if let MaybeDeviceColorSpace::ColorSpace(cs) = ++ sc.register_colorspace(chunk_container, cs) ++ { ++ separations.push((name.to_owned(), cs.get_ref())); + } + } +- let nchannel = with_attrs && space.has_process() && !separations.is_empty(); ++ let nchannel = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16 ++ && space.has_process() ++ && !separations.is_empty(); + + let chunk = &mut chunk_container.non_stream.color_spaces; + let mut array = chunk.indirect(root_ref).array(); diff --git a/vendor/krilla/patches/15-pdfx-conformance.patch b/vendor/krilla/patches/15-pdfx-conformance.patch new file mode 100644 index 00000000..15c3af63 --- /dev/null +++ b/vendor/krilla/patches/15-pdfx-conformance.patch @@ -0,0 +1,46 @@ +diff --git a/src/serialize.rs b/src/serialize.rs +index 4f4af4c..7f6ece3 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -136,6 +136,11 @@ pub struct SerializeSettings { + /// extension schema for the XMP property, which krilla does not write, so export under + /// them fails with [`ValidationError::PdfxVersionWithoutExtensionSchema`]. + pub pdfx_version: Option, ++ /// The PDF/X conformance level the caller claims, such as `PDF/X-1a:2003`: ++ /// `/GTS_PDFXConformance` in the document information dictionary. PDF/X-1a and X-3 ++ /// require it next to the version (`PDF/X-1:2003` for PDF/X-1a:2003); later parts ++ /// have no conformance key. It has no XMP property, so PDF 2.0 output omits it. ++ pub pdfx_conformance: Option, + } + + /// A CMYK printing condition, written as an `/OutputIntents` entry with `/S /GTS_PDFX`. +@@ -201,7 +206,10 @@ impl SerializeSettings { + /// Whether the document information dictionary carries print markers. Like the rest of + /// the dictionary, they are deprecated in PDF 2.0 and only written to XMP there. + pub(crate) fn has_print_info(&self, version: PdfVersion) -> bool { +- version < PdfVersion::Pdf20 && (self.trapped.is_some() || self.pdfx_version.is_some()) ++ version < PdfVersion::Pdf20 ++ && (self.trapped.is_some() ++ || self.pdfx_version.is_some() ++ || self.pdfx_conformance.is_some()) + } + + pub(crate) fn write_print_info(&self, info: &mut DocumentInfo<'_>) { +@@ -215,6 +223,9 @@ impl SerializeSettings { + if let Some(version) = &self.pdfx_version { + info.pair(Name(b"GTS_PDFXVersion"), TextStr(version)); + } ++ if let Some(conformance) = &self.pdfx_conformance { ++ info.pair(Name(b"GTS_PDFXConformance"), TextStr(conformance)); ++ } + } + + pub(crate) fn write_print_xmp(&self, xmp: &mut XmpWriter) { +@@ -260,6 +271,7 @@ impl Default for SerializeSettings { + output_intent: None, + trapped: None, + pdfx_version: None, ++ pdfx_conformance: None, + } + } + } diff --git a/vendor/krilla/patches/16-modification-date.patch b/vendor/krilla/patches/16-modification-date.patch new file mode 100644 index 00000000..45940b53 --- /dev/null +++ b/vendor/krilla/patches/16-modification-date.patch @@ -0,0 +1,65 @@ +diff --git a/src/interchange/metadata.rs b/src/interchange/metadata.rs +index 4cfc8e5..6c74b36 100644 +--- a/src/interchange/metadata.rs ++++ b/src/interchange/metadata.rs +@@ -24,6 +24,7 @@ pub struct Metadata { + pub(crate) document_id: Option, + pub(crate) language: Option, + pub(crate) creation_date: Option, ++ pub(crate) modification_date: Option, + pub(crate) text_direction: Option, + pub(crate) page_layout: Option, + } +@@ -101,6 +102,18 @@ impl Metadata { + self + } + ++ /// The date the document was last modified. Without it, the creation date is ++ /// written as the modification date. ++ pub fn modification_date(mut self, modification_date: DateTime) -> Self { ++ self.modification_date = Some(modification_date); ++ self ++ } ++ ++ /// The date written as the modification date: the one given, else the creation date. ++ fn modified(&self) -> Option { ++ self.modification_date.or(self.creation_date) ++ } ++ + /// A document ID. + /// + /// This attribute will be used as an identifier for identifying +@@ -129,6 +142,7 @@ impl Metadata { + || self.authors.is_some() + || self.creator.is_some() + || self.creation_date.is_some() ++ || self.modification_date.is_some() + || self.description.is_some() + } + +@@ -195,8 +209,11 @@ impl Metadata { + xmp.language([LangId(lang)]); + } + +- if let Some(date) = self.creation_date.map(xmp_date) { ++ if let Some(date) = self.modified().map(xmp_date) { + xmp.modify_date(date); ++ } ++ ++ if let Some(date) = self.creation_date.map(xmp_date) { + xmp.create_date(date); + + if sc +@@ -289,8 +306,11 @@ impl Metadata { + } + } + +- if let Some(date_time) = self.creation_date { ++ if let Some(date_time) = self.modified() { + document_info.modified_date(pdf_date(date_time)); ++ } ++ ++ if let Some(date_time) = self.creation_date { + document_info.creation_date(pdf_date(date_time)); + } + diff --git a/vendor/krilla/src/graphics/device_n.rs b/vendor/krilla/src/graphics/device_n.rs index 8ee7064f..f2873ac1 100644 --- a/vendor/krilla/src/graphics/device_n.rs +++ b/vendor/krilla/src/graphics/device_n.rs @@ -17,9 +17,10 @@ use crate::stream::FilterStreamBuilder; /// `[/DeviceN [names] alternate tintTransform attributes?]`. /// /// The alternate is DeviceCMYK, or the `cmyk_profile` ICC space with `no_device_cs`. The -/// attributes dictionary (PDF 1.6+) lists each spot's Separation color space under -/// `/Colorants`; when spots and process colorants mix, it is an `NChannel` space whose -/// `/Process` names the CMYK space and components. +/// attributes dictionary lists each spot's Separation color space under `/Colorants` +/// (PDF 1.3). From PDF 1.6, when spots and process colorants mix, it is an `NChannel` space +/// whose `/Process` names the CMYK space and components; `/Subtype` and `/Process` don't +/// exist before 1.6, and PDF/X-1a preflights reject them. #[derive(Debug, Eq, PartialEq, Hash, Clone)] pub(crate) struct DeviceNColorSpace { space: DeviceNSpace, @@ -54,21 +55,20 @@ impl Cacheable for DeviceNColorSpace { let tint_ref = tint_transform(&space, sc, chunk_container); - let with_attrs = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16; let mut separations = Vec::new(); - if with_attrs { - for (name, cmyk) in space.spots() { - let separation = - SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); - let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); - if let MaybeDeviceColorSpace::ColorSpace(cs) = - sc.register_colorspace(chunk_container, cs) - { - separations.push((name.to_owned(), cs.get_ref())); - } + for (name, cmyk) in space.spots() { + let separation = + SeparationSpace::new(SeparationColorant::Custom(name.to_owned()), cmyk.into()); + let cs = ColorSpace::Special(SpecialColorSpace::Separation(separation)); + if let MaybeDeviceColorSpace::ColorSpace(cs) = + sc.register_colorspace(chunk_container, cs) + { + separations.push((name.to_owned(), cs.get_ref())); } } - let nchannel = with_attrs && space.has_process() && !separations.is_empty(); + let nchannel = sc.serialize_settings().pdf_version() >= PdfVersion::Pdf16 + && space.has_process() + && !separations.is_empty(); let chunk = &mut chunk_container.non_stream.color_spaces; let mut array = chunk.indirect(root_ref).array(); diff --git a/vendor/krilla/src/interchange/metadata.rs b/vendor/krilla/src/interchange/metadata.rs index 4cfc8e59..6c74b361 100644 --- a/vendor/krilla/src/interchange/metadata.rs +++ b/vendor/krilla/src/interchange/metadata.rs @@ -24,6 +24,7 @@ pub struct Metadata { pub(crate) document_id: Option, pub(crate) language: Option, pub(crate) creation_date: Option, + pub(crate) modification_date: Option, pub(crate) text_direction: Option, pub(crate) page_layout: Option, } @@ -101,6 +102,18 @@ impl Metadata { self } + /// The date the document was last modified. Without it, the creation date is + /// written as the modification date. + pub fn modification_date(mut self, modification_date: DateTime) -> Self { + self.modification_date = Some(modification_date); + self + } + + /// The date written as the modification date: the one given, else the creation date. + fn modified(&self) -> Option { + self.modification_date.or(self.creation_date) + } + /// A document ID. /// /// This attribute will be used as an identifier for identifying @@ -129,6 +142,7 @@ impl Metadata { || self.authors.is_some() || self.creator.is_some() || self.creation_date.is_some() + || self.modification_date.is_some() || self.description.is_some() } @@ -195,8 +209,11 @@ impl Metadata { xmp.language([LangId(lang)]); } - if let Some(date) = self.creation_date.map(xmp_date) { + if let Some(date) = self.modified().map(xmp_date) { xmp.modify_date(date); + } + + if let Some(date) = self.creation_date.map(xmp_date) { xmp.create_date(date); if sc @@ -289,8 +306,11 @@ impl Metadata { } } - if let Some(date_time) = self.creation_date { + if let Some(date_time) = self.modified() { document_info.modified_date(pdf_date(date_time)); + } + + if let Some(date_time) = self.creation_date { document_info.creation_date(pdf_date(date_time)); } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index f2986fc8..4a415337 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -136,6 +136,11 @@ pub struct SerializeSettings { /// extension schema for the XMP property, which krilla does not write, so export under /// them fails with [`ValidationError::PdfxVersionWithoutExtensionSchema`]. pub pdfx_version: Option, + /// The PDF/X conformance level the caller claims, such as `PDF/X-1a:2003`: + /// `/GTS_PDFXConformance` in the document information dictionary. PDF/X-1a and X-3 + /// require it next to the version (`PDF/X-1:2003` for PDF/X-1a:2003); later parts + /// have no conformance key. It has no XMP property, so PDF 2.0 output omits it. + pub pdfx_conformance: Option, } /// A CMYK printing condition, written as an `/OutputIntents` entry with `/S /GTS_PDFX`. @@ -201,7 +206,10 @@ impl SerializeSettings { /// Whether the document information dictionary carries print markers. Like the rest of /// the dictionary, they are deprecated in PDF 2.0 and only written to XMP there. pub(crate) fn has_print_info(&self, version: PdfVersion) -> bool { - version < PdfVersion::Pdf20 && (self.trapped.is_some() || self.pdfx_version.is_some()) + version < PdfVersion::Pdf20 + && (self.trapped.is_some() + || self.pdfx_version.is_some() + || self.pdfx_conformance.is_some()) } pub(crate) fn write_print_info(&self, info: &mut DocumentInfo<'_>) { @@ -215,6 +223,9 @@ impl SerializeSettings { if let Some(version) = &self.pdfx_version { info.pair(Name(b"GTS_PDFXVersion"), TextStr(version)); } + if let Some(conformance) = &self.pdfx_conformance { + info.pair(Name(b"GTS_PDFXConformance"), TextStr(conformance)); + } } pub(crate) fn write_print_xmp(&self, xmp: &mut XmpWriter) { @@ -260,6 +271,7 @@ impl Default for SerializeSettings { output_intent: None, trapped: None, pdfx_version: None, + pdfx_conformance: None, } } } From 17744430be327d0257496a6a31c8d38fe20a6756 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:18:39 -0400 Subject: [PATCH 132/160] docking: a divider drag keeps the split's fixed or ratio size (#291) --- crates/ui/src/docking/render/sizes.rs | 26 +++++++++----- crates/ui/src/docking/split_tests.rs | 51 +++++++++++++++++++++++++++ crates/ui/src/docking/tests.rs | 6 ++-- docs/ui-docking.md | 2 +- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/ui/src/docking/render/sizes.rs b/crates/ui/src/docking/render/sizes.rs index 4d3fae3e..b1c9fd49 100644 --- a/crates/ui/src/docking/render/sizes.rs +++ b/crates/ui/src/docking/render/sizes.rs @@ -131,7 +131,7 @@ impl

Sizer<'_, P> { out: &mut Vec<(Vec, SplitSize)>, ) { let Some([a, b]) = self.children(node, rect) else { return }; - let Node::Split { axis: split_axis, first, second, .. } = node else { return }; + let Node::Split { axis: split_axis, size, first, second } = node else { return }; let docked = matches!(**first, Node::Dock { .. }) || matches!(**second, Node::Dock { .. }); let visit = |child: (&Node

, Rect), second_child: bool, grow: f32, path: &mut Vec, out: &mut Vec<(Vec, SplitSize)>| { path.push(second_child); @@ -147,7 +147,7 @@ impl

Sizer<'_, P> { let far_grow = grow - near_grow; let (near_size, far_size) = (extent(near.1, axis) + near_grow, extent(far.1, axis) + far_grow); let first_size = if end { far_size } else { near_size }; - push_ratio(path, first_size, near_size + far_size, out); + push_size(path, *size, first_size, near_size + far_size, out); visit(near, end, near_grow, path, out); if far_grow != 0.0 { visit(far, !end, far_grow, path, out); @@ -214,7 +214,7 @@ impl

Sizer<'_, P> { let mut out = Vec::new(); let mut path = path.to_vec(); let first_size = split.first_size() + first_grow; - push_ratio(&path, first_size, first_size + extent(split.second, axis) + second_grow, &mut out); + push_size(&path, *size, first_size, first_size + extent(split.second, axis) + second_grow, &mut out); path.push(false); self.follow((first, split.first), axis, true, first_grow, &mut path, &mut out); path.pop(); @@ -252,7 +252,9 @@ impl

Sizer<'_, P> { (true, true) => (a_size, b_size + rest), (false, false) => (a_size + rest, b_size), }; - push_ratio(&path, first_size, first_size + second_size, &mut out); + if let Some((Node::Split { size, .. }, _)) = chain.get(depth) { + push_size(&path, *size, first_size, first_size + second_size, &mut out); + } } out } @@ -307,11 +309,19 @@ impl

Sizer<'_, P> { } } -/// The size of a split at `path` whose first child is `first` of `available` points. -fn push_ratio(path: &[bool], first: f32, available: f32, out: &mut Vec<(Vec, SplitSize)>) { - if available > 0.0 { - out.push((path.to_vec(), SplitSize::Ratio((first / available).clamp(0.0, 1.0)))); +/// The size of a split at `path` whose first child is `first` of `available` points, in the +/// split's own mode `size`: a fixed side keeps its points, a ratio stays a ratio. +fn push_size(path: &[bool], size: SplitSize, first: f32, available: f32, out: &mut Vec<(Vec, SplitSize)>) { + if available <= 0.0 || !available.is_finite() || !first.is_finite() { + return; } + let first = first.clamp(0.0, available); + let size = match size { + SplitSize::Ratio(_) => SplitSize::Ratio((first / available).clamp(0.0, 1.0)), + SplitSize::FixedFirst(_) => SplitSize::FixedFirst(first), + SplitSize::FixedSecond(_) => SplitSize::FixedSecond(available - first), + }; + out.push((path.to_vec(), size)); } /// One group of a flat row, such as the panels of a stack or the groups of a dock column: diff --git a/crates/ui/src/docking/split_tests.rs b/crates/ui/src/docking/split_tests.rs index e4b6774f..dbfccf97 100644 --- a/crates/ui/src/docking/split_tests.rs +++ b/crates/ui/src/docking/split_tests.rs @@ -247,6 +247,57 @@ fn a_divider_dragged_past_a_groups_minimum_shrinks_the_next_group_along_the_row( assert_eq!(widths(&d), [40.0, 1308.0, 40.0, 40.0]); } +#[test] +fn a_divider_drag_keeps_the_splits_fixed_or_ratio_size() { + let hsized = |size, first, second| Node::Split { axis: SplitAxis::Horizontal, size, first: Box::new(first), second: Box::new(second) }; + let start = |d: &Driver| match &d.layout.root { + Some(Node::Split { size, .. }) => *size, + other => panic!("{other:?}"), + }; + let wider = |d: &mut Driver| { + d.screen = Rect::from_min_size(Pos2::ZERO, SCREEN + vec2(400.0, 0.0)); + d.area = d.screen; + d.settle(); + }; + let layout = |root| Layout { root: Some(root), floating: Vec::new() }; + + // A fixed start panel keeps its dragged width when the window grows. + let mut d = driver(layout(hsized(SplitSize::FixedFirst(240.0), tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::FixedFirst(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!((width - 300.0).abs() < 1.0, "{width}"); + let panel = group(&d, 1).width(); + wider(&mut d); + assert!((group(&d, 1).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 1).width()); + + // A fixed end panel too. + let mut d = driver(layout(hsized(SplitSize::FixedSecond(240.0), tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::FixedSecond(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!((width - 180.0).abs() < 1.0, "{width}"); + let panel = group(&d, 2).width(); + wider(&mut d); + assert!((group(&d, 2).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 2).width()); + + // A ratio stays a ratio, and both sides grow with the window. + let mut d = driver(layout(hsplit(0.5, tabs(&[1]), tabs(&[2]))), Film::default()); + drag_hit(&mut d, divider(&[]), vec2(60.0, 0.0)); + let SplitSize::Ratio(ratio) = start(&d) else { panic!("{:?}", d.layout) }; + assert!(ratio > 0.5 && ratio < 1.0, "{ratio}"); + let (first, second) = (group(&d, 1).width(), group(&d, 2).width()); + wider(&mut d); + assert!(group(&d, 1).width() > first + 100.0 && group(&d, 2).width() > second + 100.0); + + // A drag that runs on past its own split into a fixed ancestor keeps the ancestor fixed. + let mut d = driver(layout(hsized(SplitSize::FixedSecond(300.0), hsplit(0.5, tabs(&[1]), tabs(&[2])), tabs(&[3]))), Film::default()); + drag_hit(&mut d, divider(&[false]), vec2(600.0, 0.0)); + let SplitSize::FixedSecond(width) = start(&d) else { panic!("{:?}", d.layout) }; + assert!(width < 299.0, "{width}"); + let panel = group(&d, 3).width(); + wider(&mut d); + assert!((group(&d, 3).width() - panel).abs() < 1.0, "{} {panel}", group(&d, 3).width()); +} + #[test] fn an_intersection_moves_both_dividers() { let mut d = film(); diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 80c1fb97..d9abfaeb 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -728,10 +728,8 @@ fn split_inner_edge_remains_draggable_beside_a_scrollbar() { frame(&mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]); frame(&mut layout, vec![Event::PointerMoved(start + vec2(40.0, 0.0))]); frame(&mut layout, vec![pointer_button(start + vec2(40.0, 0.0), false)]); - // A dragged divider keeps a ratio of the space, whatever mode it was saved with. - let Node::Split { size: SplitSize::Ratio(ratio), .. } = layout.root.unwrap() else { panic!() }; - // 800 points less the 4 point divider. - assert!((ratio * 796.0 - 260.0).abs() < 0.01, "sidebar width after inner-edge drag: {}", ratio * 796.0); + let Node::Split { size: SplitSize::FixedFirst(width), .. } = layout.root.unwrap() else { panic!() }; + assert!((width - 260.0).abs() < 0.01, "sidebar width after inner-edge drag: {width}"); } #[test] diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 05ee7e14..2a6d48db 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -33,7 +33,7 @@ their sides. | Node | Holds | |---|---| | `Tabs` | A tab group: panels and the active index. | -| `Split` | Two children, horizontal or vertical, with a ratio. A fixed first or second size from a saved layout is drawn at that size until the divider moves; a moved divider stores a ratio. | +| `Split` | Two children, horizontal or vertical, and a size: a ratio, a fixed first size or a fixed second size. A moved divider keeps the mode: a fixed side stores its new size in points, a ratio its new ratio. | | `Stack` | Accordion entries, each open or closed, with an optional height. | | `Dock` | A side and its columns. A `Column` has `Group`s, a width, and `iconic`. | From 8566c598e520d46c2b7ed6767e5ae5a788e55869 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 15 Oct 2026 20:28:41 -0400 Subject: [PATCH 133/160] JPEG: 12-bit and arithmetic-coded files open (#4934, #5003) zune-jpeg decodes neither 12-bit extended sequential (SOF1, P=12) nor arithmetic-coded (SOF9/SOF10, DAC) JPEGs. Route those files to a new clean-room T.81 decoder (codecs/jpeg_ext.rs): Huffman sequential 12-bit, Annex D arithmetic decoding with DAC conditioning (sequential F.2.4 and progressive G.1.3, 8 or 12-bit), restart intervals, 1/3/4 components and any sampling factors. Float IDCT, centred linear upsampling, the existing YCbCr / Adobe CMYK / YCCK conventions; 12-bit output is U16 (scaled). Allocations are checked against Limits first; every stream read is bounds-checked and damaged entropy data stops the scan with a Truncated warning instead of panicking. Tests: a test-only arithmetic encoder round-trips coefficients through sequential and progressive (incl. successive approximation) scans with restarts; a hand-built 12-bit SOF1 block; baseline files relabelled SOF1 match zune; truncation/byte-flip sweeps never panic. Locally the conformance samples testorig12.jpg and testimgari.jpg decode with a mean abs diff < 1 level vs testorig.jpg (samples not committed). Co-Authored-By: Claude Opus 5.5 --- crates/jpeg/src/lib.rs | 1120 ++++++++++++++++++++++++++++++++++++++ crates/jpeg/src/tests.rs | 501 +++++++++++++++++ 2 files changed, 1621 insertions(+) create mode 100644 crates/jpeg/src/lib.rs create mode 100644 crates/jpeg/src/tests.rs diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs new file mode 100644 index 00000000..f1d3be2b --- /dev/null +++ b/crates/jpeg/src/lib.rs @@ -0,0 +1,1120 @@ +//! Clean-room decoder for the JPEG processes `zune-jpeg` doesn't handle, written from ITU-T T.81 +//! (ISO/IEC 10918-1): +//! +//! - extended sequential DCT, Huffman coded, 12-bit samples (SOF1, and SOF0 files that declare +//! 12 bits) (#4934); +//! - extended sequential DCT, arithmetic coded, 8 or 12-bit (SOF9) (#5003); +//! - progressive DCT, arithmetic coded, 8 or 12-bit (SOF10). +//! +//! Every scan is decoded into a whole-image buffer of quantized coefficients (Annex F for the +//! sequential scans, Annex G for the progressive ones; the arithmetic decoder of Annex D with the +//! conditioning of `DAC`), then dequantized, inverse transformed in floating point (A.3.3), +//! upsampled and colour converted. 12-bit images come out as 16-bit samples (scaled, not +//! truncated). Restart intervals, 1, 3 and 4 components and any sampling factors are supported. +//! +//! The decoder never panics on input: every read from the stream is bounds-checked, the work +//! is bounded by the frame size, and allocations are checked against the [`Limits`] first. +//! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told. + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, SampleType}; +use crate::options::Limits; + +const F: Format = Format::Jpeg; + +fn err(m: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, m) +} + +fn unsupported(m: impl Into) -> CodecError { + CodecError::unsupported(F, m) +} + +/// Zigzag scan position `k` → natural (row-major) coefficient index (Figure A.6). +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, + 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +/// The first frame marker (SOFn) and its sample precision. +fn frame_marker(b: &[u8]) -> Option<(u8, u8)> { + let mut i = 2; + loop { + if *b.get(i)? != 0xFF { + return None; + } + let m = *b.get(i + 1)?; + match m { + 0xFF => i += 1, + 0x01 | 0xD0..=0xD8 => i += 2, + 0xD9 | 0xDA => return None, + _ => { + let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?])); + if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) { + return Some((m, *b.get(i + 4)?)); + } + i = i.checked_add(2 + len)?; + } + } + } +} + +/// Whether this decoder (rather than `zune-jpeg`) should decode the file: arithmetic coding +/// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits. +pub(crate) fn handles(b: &[u8]) -> bool { + match frame_marker(b) { + Some((0xC9..=0xCB, _)) => true, + Some((0xC0 | 0xC1, p)) => p != 8, + _ => false, + } +} + +/// A decoded image: layout, sample type and interleaved native-endian sample bytes, plus +/// whether entropy-coded data was damaged (the rest of that scan was left out). +pub(crate) struct Decoded { + pub width: u32, + pub height: u32, + pub layout: ChannelLayout, + pub sample: SampleType, + pub data: Vec, + pub damaged: bool, +} + +// --------------------------------------------------------------------------------------------- +// Tables and frame + +/// A Huffman table in the decoding form of F.2.2.3 (`MAXCODE`, `VALPTR`, `MINCODE`). +#[derive(Clone)] +struct Huffman { + maxcode: [i32; 17], + valptr: [i32; 17], + mincode: [i32; 17], + values: Vec, +} + +impl Huffman { + fn new(counts: &[u8], values: &[u8]) -> Result { + let mut h = Huffman { maxcode: [-1; 17], valptr: [0; 17], mincode: [0; 17], values: values.to_vec() }; + let mut code: i32 = 0; + let mut k: i32 = 0; + for (l, &n) in (1..=16usize).zip(counts) { + let n = i32::from(n); + if n > 0 { + h.valptr[l] = k; + h.mincode[l] = code; + code += n; + k += n; + h.maxcode[l] = code - 1; + if code > 1 << l { + return Err(err("Huffman table has too many codes")); + } + } + code <<= 1; + } + Ok(h) + } + + fn decode(&self, bits: &mut Bits) -> Result { + let mut code: i32 = 0; + for l in 1..=16 { + code = (code << 1) | bits.bit() as i32; + if code <= self.maxcode[l] { + let idx = self.valptr[l] + code - self.mincode[l]; + return usize::try_from(idx).ok().and_then(|i| self.values.get(i)).copied().ok_or_else(|| err("bad Huffman code")); + } + } + Err(err("bad Huffman code")) + } +} + +struct Tables { + /// Quantization tables in zigzag order. + quant: [Option<[u16; 64]>; 4], + dc: [Option; 4], + ac: [Option; 4], + /// Arithmetic DC conditioning (L, U) and AC conditioning (Kx) per table (F.1.4.4; defaults + /// of B.2.4.3: L = 0, U = 1, Kx = 5). + dc_l: [u8; 4], + dc_u: [u8; 4], + ac_kx: [u8; 4], + restart: usize, +} + +impl Default for Tables { + fn default() -> Self { + Tables { quant: [None; 4], dc: Default::default(), ac: Default::default(), dc_l: [0; 4], dc_u: [1; 4], ac_kx: [5; 4], restart: 0 } + } +} + +struct Component { + id: u8, + h: usize, + v: usize, + tq: usize, + /// Samples in this component (B.2.2: ⌈X·H/Hmax⌉ × ⌈Y·V/Vmax⌉). + cw: usize, + ch: usize, + /// Blocks per line and block lines, padded to whole MCUs. + bw: usize, + bh: usize, + /// Quantized coefficients, one block per entry, in zigzag order. + coefs: Vec<[i32; 64]>, +} + +struct Frame { + arithmetic: bool, + progressive: bool, + precision: u8, + width: usize, + height: usize, + hmax: usize, + vmax: usize, + mcux: usize, + mcuy: usize, + comps: Vec, +} + +fn be16(s: &[u8], at: usize) -> Result { + match (s.get(at), s.get(at + 1)) { + (Some(&a), Some(&b)) => Ok(u16::from_be_bytes([a, b])), + _ => Err(err("segment too short")), + } +} + +fn byte(s: &[u8], at: usize) -> Result { + s.get(at).copied().ok_or_else(|| err("segment too short")) +} + +fn parse_dqt(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { + let mut i = 0; + while i < seg.len() { + let pq_tq = byte(seg, i)?; + let (pq, tq) = (pq_tq >> 4, usize::from(pq_tq & 15)); + i += 1; + let mut q = [0u16; 64]; + for v in &mut q { + if pq == 0 { + *v = u16::from(byte(seg, i)?); + i += 1; + } else { + *v = be16(seg, i)?; + i += 2; + } + } + let slot = t.quant.get_mut(tq).ok_or_else(|| err("bad quantization table id"))?; + *slot = Some(q); + } + Ok(()) +} + +fn parse_dht(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { + let mut i = 0; + while i < seg.len() { + let tc_th = byte(seg, i)?; + let counts = seg.get(i + 1..i + 17).ok_or_else(|| err("Huffman table too short"))?; + let total: usize = counts.iter().map(|&c| usize::from(c)).sum(); + let values = seg.get(i + 17..i + 17 + total).ok_or_else(|| err("Huffman table too short"))?; + let table = Huffman::new(counts, values)?; + let slot = match tc_th >> 4 { + 0 => t.dc.get_mut(usize::from(tc_th & 15)), + 1 => t.ac.get_mut(usize::from(tc_th & 15)), + _ => None, + }; + *slot.ok_or_else(|| err("bad Huffman table id"))? = Some(table); + i += 17 + total; + } + Ok(()) +} + +fn parse_dac(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { + for pair in seg.chunks(2) { + let [tc_tb, cs] = *pair else { return Err(err("DAC segment too short")) }; + let tb = usize::from(tc_tb & 15); + match tc_tb >> 4 { + 0 => { + let (l, u) = (cs & 15, cs >> 4); + if l > u { + return Err(err("DAC conditioning has L > U")); + } + *t.dc_l.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = l; + *t.dc_u.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = u; + } + 1 => { + if !(1..=63).contains(&cs) { + return Err(err("DAC Kx out of range")); + } + *t.ac_kx.get_mut(tb).ok_or_else(|| err("bad DAC table id"))? = cs; + } + _ => return Err(err("bad DAC table class")), + } + } + Ok(()) +} + +fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result { + let (arithmetic, progressive) = match marker { + 0xC0 | 0xC1 => (false, false), + 0xC9 => (true, false), + 0xCA => (true, true), + 0xC2 => return Err(unsupported("progressive JPEG with 12-bit samples is not supported")), + 0xC3 | 0xCB => return Err(unsupported("lossless JPEG is not supported")), + _ => return Err(unsupported("hierarchical JPEG is not supported")), + }; + let precision = byte(seg, 0)?; + if precision != 8 && precision != 12 { + return Err(unsupported(format!("{precision}-bit DCT JPEG is not supported"))); + } + let height = usize::from(be16(seg, 1)?); + let width = usize::from(be16(seg, 3)?); + if height == 0 { + return Err(unsupported("JPEG with the height in a DNL marker is not supported")); + } + let nf = usize::from(byte(seg, 5)?); + if !matches!(nf, 1 | 3 | 4) { + return Err(unsupported(format!("JPEG with {nf} components is not supported"))); + } + let mut raw = Vec::with_capacity(nf); + for c in 0..nf { + let at = 6 + 3 * c; + let (id, hv, tq) = (byte(seg, at)?, byte(seg, at + 1)?, byte(seg, at + 2)?); + let (h, v) = (usize::from(hv >> 4), usize::from(hv & 15)); + if !(1..=4).contains(&h) || !(1..=4).contains(&v) || tq > 3 { + return Err(err("bad component sampling factors or table")); + } + raw.push((id, h, v, usize::from(tq))); + } + let hmax = raw.iter().map(|c| c.1).max().unwrap_or(1); + let vmax = raw.iter().map(|c| c.2).max().unwrap_or(1); + let (layout, sample) = output_format(nf, precision); + limits.check(width as u32, height as u32, layout, sample)?; + let mcux = width.div_ceil(8 * hmax); + let mcuy = height.div_ceil(8 * vmax); + // Coefficients (4 bytes) and the reconstructed plane (4 bytes) per padded sample. + let mut internal: u64 = 0; + for &(_, h, v, _) in &raw { + let samples = (mcux * h * 8) as u64 * (mcuy * v * 8) as u64; + internal = internal.saturating_add(samples.saturating_mul(8)); + } + if internal > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!("{internal} bytes of decoder buffers exceed max_alloc {}", limits.max_alloc))); + } + let mut comps = Vec::with_capacity(nf); + for (id, h, v, tq) in raw { + let (bw, bh) = (mcux * h, mcuy * v); + let blocks = bw.checked_mul(bh).ok_or_else(|| CodecError::LimitExceeded("too many blocks".into()))?; + comps.push(Component { id, h, v, tq, cw: (width * h).div_ceil(hmax), ch: (height * v).div_ceil(vmax), bw, bh, coefs: vec![[0; 64]; blocks] }); + } + Ok(Frame { arithmetic, progressive, precision, width, height, hmax, vmax, mcux, mcuy, comps }) +} + +fn output_format(nf: usize, precision: u8) -> (ChannelLayout, SampleType) { + let layout = match nf { + 1 => ChannelLayout::Gray, + 4 => ChannelLayout::Cmyk, + _ => ChannelLayout::Rgb, + }; + (layout, if precision > 8 { SampleType::U16 } else { SampleType::U8 }) +} + +// --------------------------------------------------------------------------------------------- +// Entropy-coded segments + +/// Where the entropy-coded data starting at `start` ends: the first marker that isn't a +/// stuffed `FF 00`, a fill byte or a restart marker. +fn scan_end(b: &[u8], start: usize) -> usize { + let mut i = start; + while let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) { + i += p; + match b.get(i + 1) { + Some(0x00 | 0xD0..=0xD7) => i += 2, + Some(0xFF) => i += 1, + Some(_) => return i, + None => return b.len(), + } + } + b.len() +} + +/// Past the next restart marker at or after `pos` (or at the next other marker, or the end). +fn skip_restart(d: &[u8], mut pos: usize) -> usize { + while let Some(&v) = d.get(pos) { + if v != 0xFF { + pos += 1; + continue; + } + match d.get(pos + 1) { + Some(0xD0..=0xD7) => return pos + 2, + Some(0x00) => pos += 2, + Some(0xFF) => pos += 1, + _ => return pos, + } + } + pos +} + +/// Next data byte for the decoders: `FF 00` is a data `FF`; at a marker (or the end) zeros +/// are supplied and the marker is not consumed (D.2.6, F.2.2.5). +fn next_byte(d: &[u8], pos: &mut usize) -> Option { + let &v = d.get(*pos)?; + if v != 0xFF { + *pos += 1; + return Some(v); + } + let mut p = *pos + 1; + while d.get(p) == Some(&0xFF) { + p += 1; + } + if d.get(p) == Some(&0x00) { + *pos = p + 1; + Some(0xFF) + } else { + *pos = p - 1; + None + } +} + +/// Huffman bit reader (F.2.2.5). +struct Bits<'a> { + d: &'a [u8], + pos: usize, + acc: u32, + n: u32, +} + +impl Bits<'_> { + fn fill(&mut self) { + while self.n <= 24 { + let v = next_byte(self.d, &mut self.pos).unwrap_or(0); + self.acc |= u32::from(v) << (24 - self.n); + self.n += 8; + } + } + + fn bit(&mut self) -> u32 { + self.get(1) + } + + /// The next `n` (≤ 16) bits. + fn get(&mut self, n: u32) -> u32 { + if n == 0 { + return 0; + } + if self.n < n { + self.fill(); + } + let v = self.acc >> (32 - n); + self.acc <<= n; + self.n -= n; + v + } + + fn restart(&mut self) { + self.pos = skip_restart(self.d, self.pos); + self.acc = 0; + self.n = 0; + } +} + +/// `EXTEND` of F.2.2.1: the signed value of the `t`-bit magnitude `v`. +fn extend(v: u32, t: u32) -> i32 { + let v = v as i32; + if t == 0 { + 0 + } else if v < 1 << (t - 1) { + v - (1 << t) + 1 + } else { + v + } +} + +/// Qe, next index after LPS, next index after MPS, MPS switch (Table D.2). +#[rustfmt::skip] +const QE: [(u32, u8, u8, bool); 113] = [ + (0x5A1D, 1, 1, true), (0x2586, 14, 2, false), (0x1114, 16, 3, false), (0x080B, 18, 4, false), + (0x03D8, 20, 5, false), (0x01DA, 23, 6, false), (0x00E5, 25, 7, false), (0x006F, 28, 8, false), + (0x0036, 30, 9, false), (0x001A, 33, 10, false), (0x000D, 35, 11, false), (0x0006, 9, 12, false), + (0x0003, 10, 13, false), (0x0001, 12, 13, false), (0x5A7F, 15, 15, true), (0x3F25, 36, 16, false), + (0x2CF2, 38, 17, false), (0x207C, 39, 18, false), (0x17B9, 40, 19, false), (0x1182, 42, 20, false), + (0x0CEF, 43, 21, false), (0x09A1, 45, 22, false), (0x072F, 46, 23, false), (0x055C, 48, 24, false), + (0x0406, 49, 25, false), (0x0303, 51, 26, false), (0x0240, 52, 27, false), (0x01B1, 54, 28, false), + (0x0144, 56, 29, false), (0x00F5, 57, 30, false), (0x00B7, 59, 31, false), (0x008A, 60, 32, false), + (0x0068, 62, 33, false), (0x004E, 63, 34, false), (0x003B, 32, 35, false), (0x002C, 33, 9, false), + (0x5AE1, 37, 37, true), (0x484C, 64, 38, false), (0x3A0D, 65, 39, false), (0x2EF1, 67, 40, false), + (0x261F, 68, 41, false), (0x1F33, 69, 42, false), (0x19A8, 70, 43, false), (0x1518, 72, 44, false), + (0x1177, 73, 45, false), (0x0E74, 74, 46, false), (0x0BFB, 75, 47, false), (0x09F8, 77, 48, false), + (0x0861, 78, 49, false), (0x0706, 79, 50, false), (0x05CD, 48, 51, false), (0x04DE, 50, 52, false), + (0x040F, 50, 53, false), (0x0363, 51, 54, false), (0x02D4, 52, 55, false), (0x025C, 53, 56, false), + (0x01F8, 54, 57, false), (0x01A4, 55, 58, false), (0x0160, 56, 59, false), (0x0125, 57, 60, false), + (0x00F6, 58, 61, false), (0x00CB, 59, 62, false), (0x00AB, 61, 63, false), (0x008F, 61, 32, false), + (0x5B12, 65, 65, true), (0x4D04, 80, 66, false), (0x412C, 81, 67, false), (0x37D8, 82, 68, false), + (0x2FE8, 83, 69, false), (0x293C, 84, 70, false), (0x2379, 86, 71, false), (0x1EDF, 87, 72, false), + (0x1AA9, 87, 73, false), (0x174E, 72, 74, false), (0x1424, 72, 75, false), (0x119C, 74, 76, false), + (0x0F6B, 74, 77, false), (0x0D51, 75, 78, false), (0x0BB6, 77, 79, false), (0x0A40, 77, 48, false), + (0x5832, 80, 81, true), (0x4D1C, 88, 82, false), (0x438E, 89, 83, false), (0x3BDD, 90, 84, false), + (0x34EE, 91, 85, false), (0x2EAE, 92, 86, false), (0x299A, 93, 87, false), (0x2516, 86, 71, false), + (0x5570, 88, 89, true), (0x4CA9, 95, 90, false), (0x44D9, 96, 91, false), (0x3E22, 97, 92, false), + (0x3824, 99, 93, false), (0x32B4, 99, 94, false), (0x2E17, 93, 86, false), (0x56A8, 95, 96, true), + (0x4F46, 101, 97, false), (0x47E5, 102, 98, false), (0x41CF, 103, 99, false), (0x3C3D, 104, 100, false), + (0x375E, 99, 93, false), (0x5231, 105, 102, false), (0x4C0F, 106, 103, false), (0x4639, 107, 104, false), + (0x415E, 103, 99, false), (0x5627, 105, 106, true), (0x50E7, 108, 107, false), (0x4B85, 109, 103, false), + (0x5597, 110, 109, false), (0x504F, 111, 107, false), (0x5A10, 110, 111, true), (0x5522, 112, 109, false), + (0x59EB, 112, 111, true), +]; + +/// The arithmetic decoder of Annex D (D.2): `C` holds `Cx` in its high 16 bits, `A` the +/// interval, `CT` the bits left before the next byte. A statistics bin is one byte: the +/// Qe index in bits 0–6 and the MPS in bit 7. +struct Arith<'a> { + d: &'a [u8], + pos: usize, + c: u32, + a: u32, + ct: u32, +} + +impl Arith<'_> { + fn byte_in(&mut self) { + let v = next_byte(self.d, &mut self.pos).unwrap_or(0); + self.c = self.c.wrapping_add(u32::from(v) << 8); + } + + /// INITDEC (D.2.7). + fn init(&mut self) { + self.a = 0x10000; + self.c = 0; + self.byte_in(); + self.c <<= 8; + self.byte_in(); + self.c <<= 8; + self.ct = 0; + } + + /// DECODE (D.2.2–D.2.5) of one binary decision with the adaptive bin `st`. + fn decode(&mut self, st: &mut u8) -> u8 { + let mps = *st >> 7; + let (qe, nlps, nmps, switch) = QE.get(usize::from(*st & 0x7F)).copied().unwrap_or(QE[0]); + self.a = self.a.wrapping_sub(qe); + let d; + if (self.c >> 16) < self.a { + if self.a >= 0x8000 { + return mps; + } + // Conditional MPS exchange. + if self.a < qe { + d = 1 - mps; + *st = nlps | ((mps ^ u8::from(switch)) << 7); + } else { + d = mps; + *st = nmps | (mps << 7); + } + } else { + // Conditional LPS exchange. + self.c = self.c.wrapping_sub(self.a << 16); + if self.a < qe { + d = mps; + *st = nmps | (mps << 7); + } else { + d = 1 - mps; + *st = nlps | ((mps ^ u8::from(switch)) << 7); + } + self.a = qe; + } + // RENORM_D: A ≥ 1 (every Qe is), so this runs at most 16 times. + while self.a < 0x8000 { + if self.ct == 0 { + self.byte_in(); + self.ct = 8; + } + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + } + d + } + + /// A decision coded with the fixed estimate Qe = 0x5A1D (signs, DC refinement bits). + fn decode_fixed(&mut self) -> u8 { + let mut st = 0; + self.decode(&mut st) + } + + fn restart(&mut self) { + self.pos = skip_restart(self.d, self.pos); + self.init(); + } +} + +fn bin(st: &mut [u8], i: usize) -> Result<&mut u8, CodecError> { + st.get_mut(i).ok_or_else(|| err("arithmetic statistics index out of range")) +} + +/// One scan's decoding of a block, after a restart marker, or at its start. +trait ScanDecoder { + fn restart(&mut self); + /// Decode one block of scan component `sc` into `blk` (zigzag order). + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError>; +} + +/// The parameters of a scan header (B.2.3). +struct Scan { + /// Frame component index of each scan component, with its DC and AC table ids. + comps: Vec<(usize, usize, usize)>, + ss: usize, + se: usize, + ah: u8, + al: u8, +} + +/// Huffman sequential decoding (F.2.2). +struct HuffmanScan<'a> { + bits: Bits<'a>, + tables: Vec<(&'a Huffman, &'a Huffman)>, + pred: [i32; 4], +} + +impl ScanDecoder for HuffmanScan<'_> { + fn restart(&mut self) { + self.bits.restart(); + self.pred = [0; 4]; + } + + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + let &(dc, ac) = self.tables.get(sc).ok_or_else(|| err("bad scan component"))?; + let pred = self.pred.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + let t = u32::from(dc.decode(&mut self.bits)?); + if t > 15 { + return Err(err("bad DC difference category")); + } + let diff = extend(self.bits.get(t), t); + *pred = pred.wrapping_add(diff); + blk[0] = *pred; + let mut k = 1; + while k < 64 { + let rs = ac.decode(&mut self.bits)?; + let (r, s) = (usize::from(rs >> 4), u32::from(rs & 15)); + if s == 0 { + if r != 15 { + break; + } + k += 16; + continue; + } + k += r; + let slot = blk.get_mut(k).ok_or_else(|| err("AC coefficient past the end of the block"))?; + *slot = extend(self.bits.get(s), s); + k += 1; + } + Ok(()) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ArithMode { + Sequential, + DcFirst, + DcRefine, + AcFirst, + AcRefine, +} + +/// Arithmetic decoding: sequential (F.2.4) and progressive (G.1.3). +struct ArithScan<'a> { + ar: Arith<'a>, + mode: ArithMode, + ss: usize, + se: usize, + al: u8, + /// DC and AC table ids of each scan component. + ids: Vec<(usize, usize)>, + dc_stats: [[u8; 64]; 4], + ac_stats: [[u8; 256]; 4], + dc_l: [u8; 4], + dc_u: [u8; 4], + ac_kx: [u8; 4], + /// DC prediction and conditioning context (offset of S0) per scan component. + pred: [i32; 4], + ctx: [usize; 4], +} + +impl ArithScan<'_> { + /// Decode_DC_DIFF (F.2.4.1, Figures F.19 and F.22). + fn dc_diff(&mut self, sc: usize, tbl: usize) -> Result { + let st = self.dc_stats.get_mut(tbl).ok_or_else(|| err("bad DC table"))?; + let ctx = self.ctx.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + let s0 = *ctx; + if self.ar.decode(bin(st, s0)?) == 0 { + *ctx = 0; + return Ok(0); + } + let sign = self.ar.decode(bin(st, s0 + 1)?); + let mut s = s0 + 2 + usize::from(sign); + let mut m: i32 = i32::from(self.ar.decode(bin(st, s)?)); + if m != 0 { + // Magnitude category: X1 … X15 at bins 20 … 34. + s = 20; + while self.ar.decode(bin(st, s)?) != 0 { + m <<= 1; + s += 1; + if s > 34 { + return Err(err("DC difference out of range")); + } + } + } + // Magnitude bits: M2 … M15 at bins 35 … 48. + let mut v = m; + let mut bit = m >> 1; + while bit != 0 { + if self.ar.decode(bin(st, s + 14)?) != 0 { + v |= bit; + } + bit >>= 1; + } + let magnitude = v + 1; + // Conditioning category of the next difference (F.1.4.4.1.2). + let l = self.dc_l.get(tbl).copied().unwrap_or(0); + let u = self.dc_u.get(tbl).copied().unwrap_or(1); + *ctx = if magnitude <= (1 << l) >> 1 { + 0 + } else if magnitude <= 1 << u { + 4 + 4 * usize::from(sign) + } else { + 12 + 4 * usize::from(sign) + }; + Ok(if sign != 0 { -magnitude } else { magnitude }) + } + + /// Decode_AC_coefficients (F.2.4.2, Figures F.20 and F.24) over `ss..=se`, scaled by `al`. + fn ac_first(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + let kx = usize::from(self.ac_kx.get(tbl).copied().unwrap_or(5)); + let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; + let mut k = self.ss.max(1); + while k <= self.se { + if self.ar.decode(bin(st, 3 * (k - 1))?) != 0 { + break; // end of block + } + while self.ar.decode(bin(st, 3 * (k - 1) + 1)?) == 0 { + k += 1; + if k > self.se { + return Err(err("AC coefficient past the end of the band")); + } + } + let sign = self.ar.decode_fixed(); + let sp = 3 * (k - 1) + 2; + let mut m: i32 = i32::from(self.ar.decode(bin(st, sp)?)); + let mut s = sp; + if m != 0 && self.ar.decode(bin(st, sp)?) != 0 { + m = 2; + s = if k <= kx { 189 } else { 217 }; + let last = s + 13; + while self.ar.decode(bin(st, s)?) != 0 { + m <<= 1; + s += 1; + if s > last { + return Err(err("AC coefficient out of range")); + } + } + } + let mut v = m; + let mut bit = m >> 1; + while bit != 0 { + if self.ar.decode(bin(st, s + 14)?) != 0 { + v |= bit; + } + bit >>= 1; + } + let value = if sign != 0 { -(v + 1) } else { v + 1 }; + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = value << self.al; + k += 1; + } + Ok(()) + } + + /// Successive approximation of AC coefficients (G.1.3.3, Figure G.10). + fn ac_refine(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; + let p1: i32 = 1 << self.al; + let m1: i32 = -1 << self.al; + let (ss, se) = (self.ss.max(1), self.se); + // Past the last coefficient already non-zero, an end-of-block decision is coded. + let eob = (ss..=se).rev().find(|&k| blk.get(k).is_some_and(|&c| c != 0)).map_or(ss, |k| k + 1); + let mut k = ss; + while k <= se { + if k >= eob && self.ar.decode(bin(st, 3 * (k - 1))?) != 0 { + break; + } + loop { + let c = *blk.get(k).ok_or_else(|| err("bad AC index"))?; + if c != 0 { + if self.ar.decode(bin(st, 3 * (k - 1) + 2)?) != 0 { + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = if c < 0 { c.wrapping_add(m1) } else { c.wrapping_add(p1) }; + } + break; + } + if self.ar.decode(bin(st, 3 * (k - 1) + 1)?) != 0 { + *blk.get_mut(k).ok_or_else(|| err("bad AC index"))? = if self.ar.decode_fixed() != 0 { m1 } else { p1 }; + break; + } + k += 1; + if k > se { + return Err(err("AC coefficient past the end of the band")); + } + } + k += 1; + } + Ok(()) + } +} + +impl ScanDecoder for ArithScan<'_> { + fn restart(&mut self) { + self.ar.restart(); + self.reset(); + } + + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + let &(td, ta) = self.ids.get(sc).ok_or_else(|| err("bad scan component"))?; + match self.mode { + ArithMode::Sequential | ArithMode::DcFirst => { + let diff = self.dc_diff(sc, td)?; + let pred = self.pred.get_mut(sc).ok_or_else(|| err("bad scan component"))?; + *pred = pred.wrapping_add(diff); + blk[0] = pred.wrapping_shl(u32::from(self.al)); + if self.mode == ArithMode::Sequential { + self.ac_first(ta, blk)?; + } + } + ArithMode::DcRefine => { + if self.ar.decode_fixed() != 0 { + blk[0] |= 1 << self.al; + } + } + ArithMode::AcFirst => self.ac_first(ta, blk)?, + ArithMode::AcRefine => self.ac_refine(ta, blk)?, + } + Ok(()) + } +} + +impl ArithScan<'_> { + fn reset(&mut self) { + self.dc_stats = [[0; 64]; 4]; + self.ac_stats = [[0; 256]; 4]; + self.pred = [0; 4]; + self.ctx = [0; 4]; + } +} + +/// Walk the scan's MCUs in order (A.2), handing each block to `dec`; counts decoded MCUs. +fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanDecoder, done: &mut usize) -> Result<(), CodecError> { + let single = scan.comps.len() == 1; + let (mcux, mcuy) = if single { + let &(ci, _, _) = scan.comps.first().ok_or_else(|| err("empty scan"))?; + let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; + (c.cw.div_ceil(8), c.ch.div_ceil(8)) + } else { + (frame.mcux, frame.mcuy) + }; + let total = mcux.saturating_mul(mcuy); + for m in 0..total { + if restart > 0 && m > 0 && m % restart == 0 { + dec.restart(); + } + let (mx, my) = (m % mcux, m / mcux); + for (sc, &(ci, _, _)) in scan.comps.iter().enumerate() { + let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?; + let (h, v) = if single { (1, 1) } else { (comp.h, comp.v) }; + for by in my * v..(my + 1) * v { + for bx in mx * h..(mx + 1) * h { + let idx = by * comp.bw + bx; + let blk = comp.coefs.get_mut(idx).ok_or_else(|| err("block out of range"))?; + dec.block(sc, blk)?; + } + } + } + *done += 1; + } + Ok(()) +} + +fn parse_scan(seg: &[u8], frame: &Frame) -> Result { + let ns = usize::from(byte(seg, 0)?); + if !(1..=4).contains(&ns) { + return Err(err("bad number of scan components")); + } + let mut comps = Vec::with_capacity(ns); + for s in 0..ns { + let (cs, t) = (byte(seg, 1 + 2 * s)?, byte(seg, 2 + 2 * s)?); + let ci = frame.comps.iter().position(|c| c.id == cs).ok_or_else(|| err("scan names an unknown component"))?; + let (td, ta) = (usize::from(t >> 4), usize::from(t & 15)); + if td > 3 || ta > 3 { + return Err(err("bad entropy table id")); + } + comps.push((ci, td, ta)); + } + let at = 1 + 2 * ns; + let (ss, se, a) = (usize::from(byte(seg, at)?), usize::from(byte(seg, at + 1)?), byte(seg, at + 2)?); + let (ah, al) = (a >> 4, a & 15); + if frame.progressive { + let ok = ss <= se && se <= 63 && (ss != 0 || se == 0) && (ss == 0 || ns == 1) && al <= 13; + if !ok { + return Err(err("bad progressive scan parameters")); + } + Ok(Scan { comps, ss, se, ah, al }) + } else { + Ok(Scan { comps, ss: 0, se: 63, ah: 0, al: 0 }) + } +} + +/// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`. +fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), CodecError> { + if frame.arithmetic { + let mode = match (frame.progressive, scan.ss, scan.ah) { + (false, _, _) => ArithMode::Sequential, + (true, 0, 0) => ArithMode::DcFirst, + (true, 0, _) => ArithMode::DcRefine, + (true, _, 0) => ArithMode::AcFirst, + (true, _, _) => ArithMode::AcRefine, + }; + let mut dec = ArithScan { + ar: Arith { d, pos: 0, c: 0, a: 0, ct: 0 }, + mode, + ss: scan.ss, + se: scan.se, + al: scan.al, + ids: scan.comps.iter().map(|&(_, td, ta)| (td, ta)).collect(), + dc_stats: [[0; 64]; 4], + ac_stats: [[0; 256]; 4], + dc_l: tables.dc_l, + dc_u: tables.dc_u, + ac_kx: tables.ac_kx, + pred: [0; 4], + ctx: [0; 4], + }; + dec.ar.init(); + walk_scan(frame, scan, tables.restart, &mut dec, done) + } else { + let mut sel = Vec::with_capacity(scan.comps.len()); + for &(_, td, ta) in &scan.comps { + let dc = tables.dc.get(td).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; + let ac = tables.ac.get(ta).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; + sel.push((dc, ac)); + } + let mut dec = HuffmanScan { bits: Bits { d, pos: 0, acc: 0, n: 0 }, tables: sel, pred: [0; 4] }; + walk_scan(frame, scan, tables.restart, &mut dec, done) + } +} + +/// Parse the stream and decode every scan into coefficients. +fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), CodecError> { + if b.get(..2) != Some(&[0xFF, 0xD8]) { + return Err(err("not a JPEG stream")); + } + let mut tables = Tables::default(); + let mut frame: Option = None; + let mut damaged = false; + let mut mcus = 0usize; + let mut scans = 0usize; + let mut i = 2; + while let Some(&v) = b.get(i) { + if v != 0xFF { + i += 1; + continue; + } + let Some(&m) = b.get(i + 1) else { break }; + match m { + 0xFF => { + i += 1; + continue; + } + 0x00 | 0x01 | 0xD0..=0xD8 => { + i += 2; + continue; + } + 0xD9 => break, + _ => {} + } + let Ok(len) = be16(b, i + 2) else { break }; + let len = usize::from(len); + if len < 2 { + return Err(err("bad marker segment length")); + } + let Some(seg) = b.get(i + 4..i + 2 + len) else { + if frame.is_some() && scans > 0 { + damaged = true; + break; + } + return Err(err("marker segment runs past the end of the file")); + }; + i += 2 + len; + match m { + 0xDB => parse_dqt(seg, &mut tables)?, + 0xC4 => parse_dht(seg, &mut tables)?, + 0xCC => parse_dac(seg, &mut tables)?, + 0xDD => tables.restart = usize::from(be16(seg, 0)?), + 0xC0..=0xCF => { + if frame.is_some() { + return Err(unsupported("JPEG with more than one frame is not supported")); + } + frame = Some(parse_frame(m, seg, limits)?); + } + 0xDA => { + let f = frame.as_mut().ok_or_else(|| err("scan before the frame header"))?; + let scan = parse_scan(seg, f)?; + let end = scan_end(b, i); + let data = b.get(i..end).unwrap_or_default(); + scans += 1; + if let Err(e) = decode_scan(f, &tables, &scan, data, &mut mcus) { + if mcus == 0 { + return Err(e); + } + damaged = true; + } + i = end; + } + _ => {} + } + } + let frame = frame.ok_or_else(|| err("no frame header"))?; + if scans == 0 { + return Err(err("the file ends before any image data")); + } + Ok((frame, tables, damaged)) +} + +// --------------------------------------------------------------------------------------------- +// Reconstruction + +/// `T[x][u] = C(u)/2 · cos((2x+1)uπ/16)`: one dimension of the inverse DCT (A.3.3). +fn idct_basis() -> [[f32; 8]; 8] { + let mut t = [[0f32; 8]; 8]; + for (x, row) in t.iter_mut().enumerate() { + for (u, v) in row.iter_mut().enumerate() { + let c = if u == 0 { std::f32::consts::FRAC_1_SQRT_2 } else { 1.0 }; + *v = c / 2.0 * (((2 * x + 1) * u) as f32 * std::f32::consts::PI / 16.0).cos(); + } + } + t +} + +/// The component's samples (padded to whole blocks, row stride `bw * 8`), level-shifted and +/// clamped to `0..=max`. +fn reconstruct(comp: &Component, q: &[u16; 64], basis: &[[f32; 8]; 8], max: f32) -> Vec { + let stride = comp.bw * 8; + let mut plane = vec![0f32; stride * comp.bh * 8]; + let shift = (max + 1.0) / 2.0; + for (n, blk) in comp.coefs.iter().enumerate() { + let (bx, by) = (n % comp.bw, n / comp.bw); + let mut f = [0f32; 64]; + for (k, &c) in blk.iter().enumerate() { + f[ZIGZAG[k]] = c as f32 * f32::from(q[k]); + } + // Rows: r[v][x] = Σu T[x][u] F[v][u]; then columns: s[y][x] = Σv T[y][v] r[v][x]. + let mut r = [0f32; 64]; + for v in 0..8 { + for x in 0..8 { + r[v * 8 + x] = (0..8).map(|u| basis[x][u] * f[v * 8 + u]).sum(); + } + } + for (y, ty) in basis.iter().enumerate() { + let row = (by * 8 + y) * stride + bx * 8; + if let Some(out) = plane.get_mut(row..row + 8) { + for (x, o) in out.iter_mut().enumerate() { + let s: f32 = ty.iter().enumerate().map(|(v, t)| t * r[v * 8 + x]).sum(); + *o = (s + shift).clamp(0.0, max); + } + } + } + } + plane +} + +/// For each output coordinate: the two neighbouring component samples and the weight of the +/// second (centred linear interpolation of a subsampled component). +fn upsample_map(out: usize, samples: usize, factor: usize, fmax: usize) -> Vec<(usize, usize, f32)> { + let last = samples.saturating_sub(1); + (0..out) + .map(|x| { + let s = ((x as f32 + 0.5) * factor as f32 / fmax as f32 - 0.5).max(0.0); + let x0 = (s.floor() as usize).min(last); + ((x0), (x0 + 1).min(last), (s - x0 as f32).clamp(0.0, 1.0)) + }) + .collect() +} + +/// Turn the coefficients into interleaved output samples. +fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result<(ChannelLayout, SampleType, Vec), CodecError> { + let max = if frame.precision > 8 { 4095.0 } else { 255.0 }; + let basis = idct_basis(); + let (w, h) = (frame.width, frame.height); + let mut planes = Vec::with_capacity(frame.comps.len()); + for comp in &frame.comps { + let q = tables.quant.get(comp.tq).and_then(Option::as_ref).ok_or_else(|| err("component uses an undefined quantization table"))?; + let plane = reconstruct(comp, q, &basis, max); + let xs = upsample_map(w, comp.cw, comp.h, frame.hmax); + let ys = upsample_map(h, comp.ch, comp.v, frame.vmax); + planes.push((plane, comp.bw * 8, xs, ys)); + } + let nc = planes.len(); + let (layout, sample) = output_format(nc, frame.precision); + let rgb_ids = frame.comps.iter().map(|c| c.id).eq(*b"RGB"); + let ycc = match nc { + 3 => adobe_transform != Some(0) && !rgb_ids, + 4 => adobe_transform == Some(2), + _ => false, + }; + let mut data = Vec::with_capacity(w * h * nc * sample.bytes()); + let mut px = [0f32; 4]; + let center = (max + 1.0) / 2.0; + for y in 0..h { + for x in 0..w { + for (c, (plane, stride, xs, ys)) in planes.iter().enumerate() { + let (Some(&(x0, x1, fx)), Some(&(y0, y1, fy))) = (xs.get(x), ys.get(y)) else { + return Err(err("sample map out of range")); + }; + let at = |xx: usize, yy: usize| plane.get(yy * stride + xx).copied().unwrap_or(0.0); + let top = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * fx; + let bottom = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * fx; + if let Some(p) = px.get_mut(c) { + *p = top + (bottom - top) * fy; + } + } + if ycc { + // YCbCr → RGB (JFIF / T.871); for YCCK the result is the inverted CMY of Adobe's + // convention, i.e. the ink amounts. + let (yy, cb, cr) = (px[0], px[1] - center, px[2] - center); + px[0] = yy + 1.402 * cr; + px[1] = yy - 0.344_136 * cb - 0.714_136 * cr; + px[2] = yy + 1.772 * cb; + if nc == 4 { + px[3] = max - px[3]; + } + } else if nc == 4 { + // Adobe-style CMYK is stored inverted (max = no ink). + for p in &mut px { + *p = max - *p; + } + } + for &p in px.iter().take(nc) { + let p = p.clamp(0.0, max); + if sample == SampleType::U16 { + data.extend_from_slice(&((p * 65535.0 / max).round() as u16).to_ne_bytes()); + } else { + data.push(p.round() as u8); + } + } + } + } + Ok((layout, sample, data)) +} + +/// Decode a JPEG this module [`handles`]. +pub(crate) fn decode(b: &[u8], limits: &Limits, adobe_transform: Option) -> Result { + let (frame, tables, damaged) = decode_coefficients(b, limits)?; + let (width, height) = (frame.width as u32, frame.height as u32); + let (layout, sample, data) = render(frame, &tables, adobe_transform)?; + Ok(Decoded { width, height, layout, sample, data, damaged }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs new file mode 100644 index 00000000..54699dc3 --- /dev/null +++ b/crates/jpeg/src/tests.rs @@ -0,0 +1,501 @@ +use super::*; + +/// Local check against the conformance samples (never committed): set `PC_JPEG_SAMPLES` to a +/// directory holding testorig.jpg, testorig12.jpg and testimgari.jpg. +#[test] +#[ignore] +fn conformance_samples() { + let dir = std::env::var("PC_JPEG_SAMPLES").unwrap(); + let read = |n: &str| std::fs::read(format!("{dir}/{n}")).unwrap(); + let reference = crate::decode(&read("testorig.jpg")).unwrap(); + let ref8 = reference.data().to_vec(); + for (name, depth) in [("testorig12.jpg", SampleType::U16), ("testimgari.jpg", SampleType::U8)] { + let img = crate::decode(&read(name)).unwrap(); + assert_eq!(img.dimensions(), reference.dimensions(), "{name}"); + assert_eq!(img.sample_type(), depth, "{name}"); + assert!(img.warnings.is_empty(), "{name}: {:?}", img.warnings); + let px: Vec = match depth { + SampleType::U16 => img.to_u16_samples().unwrap().iter().map(|&v| v as f32 / 257.0).collect(), + _ => img.data().iter().map(|&v| v as f32).collect(), + }; + let mad = px.iter().zip(&ref8).map(|(a, &b)| (a - b as f32).abs()).sum::() / px.len() as f32; + eprintln!("{name}: mean abs diff vs testorig.jpg {mad}"); + assert!(mad < 3.0, "{name}: {mad}"); + } +} + +// --- A test-only arithmetic encoder (T.81 D.1, F.1.4 and G.1.3), mirroring the decoder --- + +struct Enc { + out: Vec, + c: u32, + a: u32, + ct: u32, + stacked: u32, + b: Option, +} + +impl Enc { + fn new() -> Self { + Enc { out: Vec::new(), c: 0, a: 0x10000, ct: 11, stacked: 0, b: None } + } + + fn emit(&mut self, v: u8) { + self.out.push(v); + if v == 0xFF { + self.out.push(0); + } + } + + fn byte_out(&mut self) { + let t = self.c >> 19; + if t > 0xFF { + let b = self.b.unwrap() + 1; + self.emit(b); + for _ in 0..self.stacked { + self.emit(0); + } + self.stacked = 0; + self.b = Some((t & 0xFF) as u8); + } else if t == 0xFF { + self.stacked += 1; + } else { + if let Some(b) = self.b { + self.emit(b); + } + for _ in 0..self.stacked { + self.emit(0xFF); + } + self.stacked = 0; + self.b = Some(t as u8); + } + self.c &= 0x7FFFF; + } + + fn renorm(&mut self) { + while self.a < 0x8000 { + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + if self.ct == 0 { + self.byte_out(); + self.ct = 8; + } + } + } + + fn code(&mut self, st: &mut u8, d: u8) { + let mps = *st >> 7; + let (qe, nlps, nmps, switch) = QE[usize::from(*st & 0x7F)]; + self.a -= qe; + if d == mps { + if self.a < 0x8000 { + if self.a < qe { + self.c += self.a; + self.a = qe; + } + *st = nmps | (mps << 7); + self.renorm(); + } + } else { + if self.a >= qe { + self.c += self.a; + self.a = qe; + } + *st = nlps | ((mps ^ u8::from(switch)) << 7); + self.renorm(); + } + } + + fn code_fixed(&mut self, d: u8) { + let mut st = 0; + self.code(&mut st, d); + } + + fn finish(mut self) -> Vec { + let mut t = (self.c + self.a - 1) & 0xFFFF_0000; + if t < self.c { + t += 0x8000; + } + self.c = t << self.ct; + self.byte_out(); + self.c <<= 8; + self.byte_out(); + if let Some(b) = self.b { + self.emit(b); + } + for _ in 0..self.stacked { + self.emit(0xFF); + } + self.out + } +} + +struct EncScan { + dc: [u8; 64], + ac: [u8; 256], + ctx: usize, + pred: i32, +} + +impl EncScan { + fn new() -> Self { + EncScan { dc: [0; 64], ac: [0; 256], ctx: 0, pred: 0 } + } + + /// Encode_DC_DIFF with the default conditioning (L = 0, U = 1). + fn dc(&mut self, e: &mut Enc, value: i32) { + let diff = value - self.pred; + self.pred = value; + let st = &mut self.dc; + let s0 = self.ctx; + if diff == 0 { + e.code(&mut st[s0], 0); + self.ctx = 0; + return; + } + e.code(&mut st[s0], 1); + let sign = u8::from(diff < 0); + e.code(&mut st[s0 + 1], sign); + let sp = s0 + 2 + usize::from(sign); + let sz = diff.unsigned_abs() - 1; + if sz == 0 { + e.code(&mut st[sp], 0); + } else { + e.code(&mut st[sp], 1); + let mut m = 1u32; + let mut s = 20; + while m << 1 <= sz { + e.code(&mut st[s], 1); + m <<= 1; + s += 1; + } + e.code(&mut st[s], 0); + let mut bit = m >> 1; + while bit != 0 { + e.code(&mut st[s + 14], u8::from(sz & bit != 0)); + bit >>= 1; + } + } + let mag = diff.unsigned_abs(); + self.ctx = if mag <= 2 { 4 + 4 * usize::from(sign) } else { 12 + 4 * usize::from(sign) }; + } + + /// Encode_AC_coefficients over `ss..=se` of the coefficients scaled down by `al` (Kx = 5). + fn ac(&mut self, e: &mut Enc, blk: &[i32; 64], ss: usize, se: usize, al: u8) { + let st = &mut self.ac; + let v = |k: usize| if blk[k] < 0 { -((-blk[k]) >> al) } else { blk[k] >> al }; + let last = (ss..=se).rev().find(|&k| v(k) != 0); + let mut k = ss; + while k <= se { + if last.is_none_or(|l| k > l) { + e.code(&mut st[3 * (k - 1)], 1); + return; + } + e.code(&mut st[3 * (k - 1)], 0); + while v(k) == 0 { + e.code(&mut st[3 * (k - 1) + 1], 0); + k += 1; + } + e.code(&mut st[3 * (k - 1) + 1], 1); + e.code_fixed(u8::from(v(k) < 0)); + let sz = v(k).unsigned_abs() - 1; + let sp = 3 * (k - 1) + 2; + if sz == 0 { + e.code(&mut st[sp], 0); + } else { + e.code(&mut st[sp], 1); + if sz == 1 { + e.code(&mut st[sp], 0); + } else { + e.code(&mut st[sp], 1); + let mut m = 2u32; + let mut s = if k <= 5 { 189 } else { 217 }; + while m << 1 <= sz { + e.code(&mut st[s], 1); + m <<= 1; + s += 1; + } + e.code(&mut st[s], 0); + let mut bit = m >> 1; + while bit != 0 { + e.code(&mut st[s + 14], u8::from(sz & bit != 0)); + bit >>= 1; + } + } + } + k += 1; + } + } + + /// Successive approximation of AC coefficients: bit `al` of each (G.1.3.3). + fn ac_refine(&mut self, e: &mut Enc, blk: &[i32; 64], ss: usize, se: usize, al: u8) { + let st = &mut self.ac; + let prior = |k: usize| blk[k].unsigned_abs() >> (al + 1) != 0; + let now = |k: usize| blk[k].unsigned_abs() >> al != 0; + let eob_prev = (ss..=se).rev().find(|&k| prior(k)).map_or(ss, |k| k + 1); + let eob_now = (ss..=se).rev().find(|&k| now(k)).map_or(ss, |k| k + 1); + let mut k = ss; + while k <= se { + if k >= eob_prev { + if k >= eob_now { + e.code(&mut st[3 * (k - 1)], 1); + return; + } + e.code(&mut st[3 * (k - 1)], 0); + } + loop { + if prior(k) { + e.code(&mut st[3 * (k - 1) + 2], ((blk[k].unsigned_abs() >> al) & 1) as u8); + break; + } + if now(k) { + e.code(&mut st[3 * (k - 1) + 1], 1); + e.code_fixed(u8::from(blk[k] < 0)); + break; + } + e.code(&mut st[3 * (k - 1) + 1], 0); + k += 1; + } + k += 1; + } + } +} + +/// Deterministic pseudo-random coefficients: mostly small, zero runs, a few large. +fn coefficients(n: usize, seed: u32, big: i32) -> Vec<[i32; 64]> { + let mut s = seed; + let mut next = move || { + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + s >> 8 + }; + (0..n) + .map(|_| { + let mut b = [0i32; 64]; + b[0] = (next() % (2 * big as u32 + 1)) as i32 - big; + for v in b.iter_mut().skip(1) { + let mag = match next() % 100 { + 0..=59 => 0, + 60..=89 => 1 + (next() % 4) as i32, + 90..=97 => 1 + (next() % 60) as i32, + _ => 1 + (next() % big as u32) as i32, + }; + *v = if next() % 2 == 0 { mag } else { -mag }; + } + if next() % 3 == 0 { + for v in &mut b[20..] { + *v = 0; + } + } + b + }) + .collect() +} + +fn segment(out: &mut Vec, marker: u8, body: &[u8]) { + out.extend_from_slice(&[0xFF, marker]); + out.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(body); +} + +/// A gray SOF9 stream (SOF10 when there are several scans) holding `blocks` (`bw` × `bh`), +/// with the scans `(ss, se, ah, al)` and restart interval `ri` (in MCUs). +fn arith_stream(precision: u8, bw: usize, bh: usize, blocks: &[[i32; 64]], scans: &[(usize, usize, u8, u8)], ri: usize) -> Vec { + let progressive = scans.len() > 1; + let mut s = vec![0xFF, 0xD8]; + let mut dqt = vec![0u8]; + dqt.extend([1u8; 64]); + segment(&mut s, 0xDB, &dqt); + let (w, h) = ((bw * 8) as u16, (bh * 8) as u16); + let mut sof = vec![precision]; + sof.extend(h.to_be_bytes()); + sof.extend(w.to_be_bytes()); + sof.extend([1, 1, 0x11, 0]); + segment(&mut s, if progressive { 0xCA } else { 0xC9 }, &sof); + if ri > 0 { + segment(&mut s, 0xDD, &(ri as u16).to_be_bytes()); + } + for &(ss, se, ah, al) in scans { + segment(&mut s, 0xDA, &[1, 1, 0x00, ss as u8, se as u8, (ah << 4) | al]); + let mut e = Enc::new(); + let mut st = EncScan::new(); + let mut rst = 0u8; + for (n, blk) in blocks.iter().enumerate() { + if ri > 0 && n > 0 && n % ri == 0 { + s.extend(std::mem::replace(&mut e, Enc::new()).finish()); + s.extend([0xFF, 0xD0 + rst]); + rst = (rst + 1) & 7; + st = EncScan::new(); + } + match (ss, ah) { + (0, 0) => { + st.dc(&mut e, blk[0] >> al); + if !progressive { + st.ac(&mut e, blk, 1, 63, 0); + } + } + (0, _) => e.code_fixed(((blk[0] >> al) & 1) as u8), + (_, 0) => st.ac(&mut e, blk, ss, se, al), + _ => st.ac_refine(&mut e, blk, ss, se, al), + } + } + s.extend(e.finish()); + } + s.extend([0xFF, 0xD9]); + s +} + +fn decoded_blocks(stream: &[u8]) -> Vec<[i32; 64]> { + let (mut frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap(); + assert!(!damaged); + std::mem::take(&mut frame.comps[0].coefs) +} + +#[test] +fn arithmetic_sequential_round_trips_coefficients() { + for (precision, big, ri) in [(8u8, 1000, 0usize), (12, 16000, 0), (8, 300, 1), (12, 3000, 5)] { + let blocks = coefficients(12, u32::from(precision) * 7 + ri as u32, big); + let stream = arith_stream(precision, 4, 3, &blocks, &[(0, 63, 0, 0)], ri); + assert_eq!(decoded_blocks(&stream), blocks, "P={precision} ri={ri}"); + } +} + +#[test] +fn arithmetic_progressive_round_trips_coefficients() { + let blocks = coefficients(6, 99, 2000); + let scans = [(0, 0, 0, 1), (1, 5, 0, 2), (6, 63, 0, 1), (1, 5, 2, 1), (0, 0, 1, 0), (1, 5, 1, 0), (6, 63, 1, 0)]; + for ri in [0, 2] { + let stream = arith_stream(8, 3, 2, &blocks, &scans, ri); + assert_eq!(decoded_blocks(&stream), blocks, "ri={ri}"); + } +} + +#[test] +fn arithmetic_twelve_bit_flat_block_is_sixteen_bit() { + // DC only: sample = DC / 8 + 2048 with a quantizer of 1; 1000 → 2173. + let mut blk = [0i32; 64]; + blk[0] = 1000; + let stream = arith_stream(12, 1, 1, &[blk], &[(0, 63, 0, 0)], 0); + assert!(handles(&stream)); + let img = crate::decode(&stream).unwrap(); + assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U16, ChannelLayout::Gray)); + let want = (2173.0f32 * 65535.0 / 4095.0).round() as u16; + assert!(img.to_u16_samples().unwrap().iter().all(|&v| v.abs_diff(want) <= 1)); +} + +/// A 12-bit SOF1 stream built by hand: one 8×8 gray block with only a DC coefficient. The DC +/// table has one code (`0`, category 13), the AC table one (`0`, end of block). +fn twelve_bit_huffman_block() -> Vec { + let mut s = vec![0xFF, 0xD8]; + let mut dqt = vec![0u8]; + dqt.extend([1u8; 64]); + segment(&mut s, 0xDB, &dqt); + segment(&mut s, 0xC1, &[12, 0, 8, 0, 8, 1, 1, 0x11, 0]); + let mut dht = vec![0x00, 1]; + dht.extend([0u8; 15]); + dht.push(13); + dht.extend([0x10, 1]); + dht.extend([0u8; 15]); + dht.push(0x00); + segment(&mut s, 0xC4, &dht); + segment(&mut s, 0xDA, &[1, 1, 0x00, 0, 63, 0]); + // DC = 7616 → sample 7616 / 8 + 2048 = 3000. Bits: `0`, 7616 in 13 bits, `0`, one pad `1`. + let word: u16 = (7616 << 2) | 1; + s.extend(word.to_be_bytes()); + s.extend([0xFF, 0xD9]); + s +} + +#[test] +fn twelve_bit_huffman_block_decodes_to_sixteen_bit() { + let stream = twelve_bit_huffman_block(); + assert!(handles(&stream)); + let img = crate::decode(&stream).unwrap(); + assert_eq!((img.dimensions(), img.sample_type()), ((8, 8), SampleType::U16)); + let want = (3000.0f32 * 65535.0 / 4095.0).round() as u16; + let px = img.to_u16_samples().unwrap(); + assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]); +} + +/// A baseline file from our encoder, relabelled SOF1, decodes like zune decodes the original. +#[test] +fn extended_huffman_matches_zune_on_baseline_data() { + let (w, h) = (37u16, 29u16); + let rgb: Vec = (0..usize::from(w) * usize::from(h)) + .flat_map(|i| { + let (x, y) = (i % usize::from(w), i / usize::from(w)); + [(x * 7) as u8, (y * 9) as u8, ((x + y) * 3) as u8] + }) + .collect(); + for (sampling, restart) in + [(jpeg_encoder::SamplingFactor::R_4_2_0, 0u16), (jpeg_encoder::SamplingFactor::R_4_4_4, 3), (jpeg_encoder::SamplingFactor::R_4_2_2, 1)] + { + let mut out = Vec::new(); + let mut enc = jpeg_encoder::Encoder::new(&mut out, 90); + enc.set_sampling_factor(sampling); + if restart > 0 { + enc.set_restart_interval(restart); + } + enc.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap(); + let zune = crate::decode(&out).unwrap(); + let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap(); + out[sof + 1] = 0xC1; + let ours = decode(&out, &Limits::default(), None).unwrap(); + assert_eq!((ours.width, ours.height, ours.layout, ours.sample), (u32::from(w), u32::from(h), ChannelLayout::Rgb, SampleType::U8)); + assert!(!ours.damaged); + let mad = ours.data.iter().zip(zune.data()).map(|(&a, &b)| f32::from(a.abs_diff(b))).sum::() / ours.data.len() as f32; + assert!(mad < 1.5, "{sampling:?} restart {restart}: mean abs diff {mad}"); + } +} + +#[test] +fn malformed_streams_return_errors_not_panics() { + let blocks = coefficients(6, 5, 500); + let mut streams = vec![ + arith_stream(12, 3, 2, &blocks, &[(0, 63, 0, 0)], 2), + arith_stream(8, 3, 2, &blocks, &[(0, 0, 0, 1), (1, 63, 0, 0), (0, 0, 1, 0)], 0), + twelve_bit_huffman_block(), + ]; + // Header damage: zero sizes, bad component counts, sampling factors and tables, bad DAC. + let base = arith_stream(8, 1, 1, &blocks[..1], &[(0, 63, 0, 0)], 0); + let sof = base.windows(2).position(|p| p == [0xFF, 0xC9]).unwrap(); + for (offset, value) in [(4, 16u8), (6, 0), (8, 0), (9, 7), (10, 9), (11, 0x50), (12, 9)] { + let mut s = base.clone(); + s[sof + offset] = value; + assert!(crate::decode(&s).is_err(), "SOF byte {offset} = {value}"); + streams.push(s); + } + let mut dac = base.clone(); + dac.splice(2..2, [0xFF, 0xCC, 0, 4, 0x00, 0x13]); + assert!(crate::decode(&dac).is_err()); + streams.push(dac); + for s in &streams { + for cut in 0..s.len() { + let _ = crate::decode(&s[..cut]); + } + for i in 2..s.len() { + for v in [0x00, 0xFF, 0x5A] { + let mut t = s.clone(); + t[i] = v; + let _ = crate::decode(&t); + } + } + } + assert!(crate::decode(&streams[0][..40]).is_err()); +} + +#[test] +fn limits_are_checked_before_allocating() { + let mut s = vec![0xFF, 0xD8]; + segment(&mut s, 0xC9, &[8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x44, 0, 2, 0x11, 0, 3, 0x11, 0]); + let small = Limits { max_alloc: 1 << 20, ..Limits::default() }; + assert!(matches!(decode(&s, &small, None), Err(CodecError::LimitExceeded(_)))); +} + +#[test] +fn routing() { + assert!(!handles(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(handles(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(handles(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0])); + assert!(!handles(&[0xFF, 0xD8])); +} From 5e2d33cd8225030ae38729233a6a2495f51bfa2c Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sat, 17 Oct 2026 11:15:28 -0400 Subject: [PATCH 134/160] JPEG: a Huffman scan that runs out of data is damaged, not clean Bits now tracks how many buffered bits are zero padding supplied at a marker or the end of the data; a read that consumes padding marks the scan exhausted and the block returns an error, so the scan is reported as damaged (or fails if no MCU decoded) even when EOI is present. Arithmetic termination is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/jpeg/src/lib.rs | 29 ++++++++++++++++++++++++++--- crates/jpeg/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index f1d3be2b..3885724c 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -380,12 +380,27 @@ struct Bits<'a> { pos: usize, acc: u32, n: u32, + /// How many of the `n` buffered bits (always the last ones) are zero padding supplied at + /// a marker or the end of the data, not real entropy-coded bits. + pad: u32, + /// Set once a read consumed padding: the scan's data ended before the scan did. + exhausted: bool, } -impl Bits<'_> { +impl<'a> Bits<'a> { + fn new(d: &'a [u8]) -> Self { + Bits { d, pos: 0, acc: 0, n: 0, pad: 0, exhausted: false } + } + fn fill(&mut self) { while self.n <= 24 { - let v = next_byte(self.d, &mut self.pos).unwrap_or(0); + let v = match next_byte(self.d, &mut self.pos) { + Some(v) => v, + None => { + self.pad += 8; + 0 + } + }; self.acc |= u32::from(v) << (24 - self.n); self.n += 8; } @@ -403,9 +418,13 @@ impl Bits<'_> { if self.n < n { self.fill(); } + if n > self.n.saturating_sub(self.pad) { + self.exhausted = true; + } let v = self.acc >> (32 - n); self.acc <<= n; self.n -= n; + self.pad = self.pad.min(self.n); v } @@ -413,6 +432,7 @@ impl Bits<'_> { self.pos = skip_restart(self.d, self.pos); self.acc = 0; self.n = 0; + self.pad = 0; } } @@ -605,6 +625,9 @@ impl ScanDecoder for HuffmanScan<'_> { *slot = extend(self.bits.get(s), s); k += 1; } + if self.bits.exhausted { + return Err(err("entropy-coded data ends before the scan does")); + } Ok(()) } } @@ -900,7 +923,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: let ac = tables.ac.get(ta).and_then(Option::as_ref).ok_or_else(|| err("scan uses an undefined Huffman table"))?; sel.push((dc, ac)); } - let mut dec = HuffmanScan { bits: Bits { d, pos: 0, acc: 0, n: 0 }, tables: sel, pred: [0; 4] }; + let mut dec = HuffmanScan { bits: Bits::new(d), tables: sel, pred: [0; 4] }; walk_scan(frame, scan, tables.restart, &mut dec, done) } } diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 54699dc3..3b5acadd 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -417,6 +417,44 @@ fn twelve_bit_huffman_block_decodes_to_sixteen_bit() { assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]); } +/// Where the entropy-coded data of the last scan starts. +fn last_scan_data(s: &[u8]) -> usize { + let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap(); + sos + 2 + usize::from(u16::from_be_bytes([s[sos + 2], s[sos + 3]])) +} + +/// A Huffman scan whose entropy data runs out is not a clean image, even with EOI present: +/// the zero padding supplied at the marker must not be decoded as real coefficients. +#[test] +fn huffman_scan_out_of_data_is_not_clean() { + let full = twelve_bit_huffman_block(); + let data = last_scan_data(&full); + for keep in 0..2 { + let mut s = full[..data + keep].to_vec(); + s.extend([0xFF, 0xD9]); + assert!(decode(&s, &Limits::default(), None).is_err(), "keep {keep}"); + if let Ok(img) = crate::decode(&s) { + assert!(!img.warnings.is_empty(), "keep {keep}: clean decode of missing data"); + } + } + // A multi-MCU scan cut in half, EOI kept, decodes as damaged. + let (w, h) = (64usize, 64usize); + let rgb: Vec = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 4) as u8, (i * 7) as u8]).collect(); + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, 90).encode(&rgb, w as u16, h as u16, jpeg_encoder::ColorType::Rgb).unwrap(); + let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap(); + out[sof + 1] = 0xC1; + let data = last_scan_data(&out); + let end = out.len() - 2; + let mut cut = out[..data + (end - data) / 2].to_vec(); + if cut.last() == Some(&0xFF) { + cut.pop(); + } + cut.extend([0xFF, 0xD9]); + assert!(!decode(&out, &Limits::default(), None).unwrap().damaged); + assert!(decode(&cut, &Limits::default(), None).unwrap().damaged); +} + /// A baseline file from our encoder, relabelled SOF1, decodes like zune decodes the original. #[test] fn extended_huffman_matches_zune_on_baseline_data() { From 425bd319c09c2aea993ef01177b9dc65b21dbb49 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Sun, 18 Oct 2026 14:25:10 -0400 Subject: [PATCH 135/160] JPEG: 12-bit and arithmetic-coded files keep their Photoshop resources The jpeg_ext path set exif, xmp and dpi but not photoshop_resources, so APP13 8BIM blocks (paths, Photoshop settings) were lost on import and re-export. Both decoder paths now fill the same Metadata. Test: a 12-bit JPEG with an APP13 block keeps it. Co-Authored-By: Claude Opus 5.5 --- crates/jpeg/src/tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 3b5acadd..60b005c6 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -417,6 +417,21 @@ fn twelve_bit_huffman_block_decodes_to_sixteen_bit() { assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]); } +#[test] +fn twelve_bit_jpeg_keeps_its_photoshop_resources() { + // An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path. + let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec(); + let mut app13 = b"Photoshop 3.0\0".to_vec(); + app13.extend_from_slice(&resources); + let stream = twelve_bit_huffman_block(); + let mut with = stream[..2].to_vec(); + segment(&mut with, 0xED, &app13); + with.extend_from_slice(&stream[2..]); + assert!(handles(&with)); + let img = crate::decode(&with).unwrap(); + assert_eq!(img.meta.photoshop_resources.as_deref(), Some(resources.as_slice())); +} + /// Where the entropy-coded data of the last scan starts. fn last_scan_data(s: &[u8]) -> usize { let sos = s.windows(2).rposition(|p| p == [0xFF, 0xDA]).unwrap(); From 537ac17c7c67e9729f9f331a76a4e20408e188e5 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Tue, 20 Oct 2026 20:52:26 -0400 Subject: [PATCH 136/160] JPEG: cap the scans decoded from one stream at 500 Every scan walks its whole MCU grid, and an arithmetic-coded scan does so even with no entropy-coded data (the decoder is fed zeros), so a small file repeating empty scan headers multiplied the work by the frame size. Scans past the cap are dropped and the image is reported damaged, keeping what the earlier scans decoded. Co-Authored-By: Claude Opus 5.5 --- crates/jpeg/src/lib.rs | 13 ++++++++++++- crates/jpeg/src/tests.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 3885724c..7405347a 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -13,7 +13,8 @@ //! truncated). Restart intervals, 1, 3 and 4 components and any sampling factors are supported. //! //! The decoder never panics on input: every read from the stream is bounds-checked, the work -//! is bounded by the frame size, and allocations are checked against the [`Limits`] first. +//! is bounded by the frame size times the scan count (capped at `MAX_SCANS`), and +//! allocations are checked against the [`Limits`] first. //! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told. use crate::Format; @@ -928,6 +929,12 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: } } +/// The most scans decoded from one stream; later ones are dropped and the image is reported +/// damaged. Every scan walks its whole MCU grid, and an arithmetic-coded scan does so even with +/// no data (the decoder is fed zeros, D.2.6), so without a cap a small file repeating empty +/// scan headers multiplies the work by the frame size. Real files have a few dozen at most. +const MAX_SCANS: usize = 500; + /// Parse the stream and decode every scan into coefficients. fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), CodecError> { if b.get(..2) != Some(&[0xFF, 0xD8]) { @@ -983,6 +990,10 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool } 0xDA => { let f = frame.as_mut().ok_or_else(|| err("scan before the frame header"))?; + if scans >= MAX_SCANS { + damaged = true; + break; + } let scan = parse_scan(seg, f)?; let end = scan_end(b, i); let data = b.get(i..end).unwrap_or_default(); diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 60b005c6..7cf1b4fd 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -537,6 +537,19 @@ fn malformed_streams_return_errors_not_panics() { assert!(crate::decode(&streams[0][..40]).is_err()); } +/// Scans past `MAX_SCANS` aren't decoded (each one walks the whole MCU grid, with or without +/// data): the image keeps what the earlier scans decoded and is reported damaged. +#[test] +fn scans_past_the_limit_are_not_decoded() { + let blocks = coefficients(1, 3, 500); + for (count, damaged) in [(MAX_SCANS, false), (MAX_SCANS + 1, true)] { + let stream = arith_stream(8, 1, 1, &blocks, &vec![(0, 0, 0, 0); count], 0); + let (frame, _, d) = decode_coefficients(&stream, &Limits::default()).unwrap(); + assert_eq!(d, damaged, "{count} scans"); + assert_eq!(frame.comps[0].coefs[0][0], blocks[0][0], "{count} scans"); + } +} + #[test] fn limits_are_checked_before_allocating() { let mut s = vec![0xFF, 0xD8]; From 5b609acb6f71852cf94f2c5ef0609c82c0f24fd7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:43:14 +0000 Subject: [PATCH 137/160] jpeg: make the decoder a standalone crate Own Error and Limits types, a Decoded image with typed samples, a public frame() probe for routing, and the tests adapted to them. --- Cargo.lock | 15 +++ Cargo.toml | 1 + crates/jpeg/Cargo.toml | 19 +++ crates/jpeg/src/lib.rs | 268 +++++++++++++++++++++++++++------------ crates/jpeg/src/tests.rs | 105 ++++++++------- 5 files changed, 281 insertions(+), 127 deletions(-) create mode 100644 crates/jpeg/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index c089cc7a..d299330a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,6 +855,15 @@ dependencies = [ "writeable", ] +[[package]] +name = "craft-jpeg" +version = "0.1.0" +dependencies = [ + "jpeg-encoder", + "thiserror 2.0.21", + "zune-jpeg", +] + [[package]] name = "craft-keymap" version = "0.1.0" @@ -2631,6 +2640,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-encoder" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f" + [[package]] name = "js-sys" version = "0.3.106" diff --git a/Cargo.toml b/Cargo.toml index 01c390df..8000fb5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } +craft-jpeg = { path = "crates/jpeg" } craft-keymap = { path = "crates/keymap" } craft-media = { path = "crates/media" } craft-prepress = { path = "crates/prepress" } diff --git a/crates/jpeg/Cargo.toml b/crates/jpeg/Cargo.toml new file mode 100644 index 00000000..76806112 --- /dev/null +++ b/crates/jpeg/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "craft-jpeg" +description = "Clean-room decoder for 12-bit and arithmetic-coded JPEG (ITU-T T.81)." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +thiserror = { workspace = true } + +[dev-dependencies] +# Reference encoder and baseline decoder for the comparison tests. +jpeg-encoder = "0.6" +zune-jpeg = "0.5" diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 7405347a..88edd253 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -1,45 +1,136 @@ -//! Clean-room decoder for the JPEG processes `zune-jpeg` doesn't handle, written from ITU-T T.81 -//! (ISO/IEC 10918-1): +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Clean-room decoder for the JPEG processes most Rust JPEG decoders don't handle, written from +//! ITU-T T.81 (ISO/IEC 10918-1). //! -//! - extended sequential DCT, Huffman coded, 12-bit samples (SOF1, and SOF0 files that declare -//! 12 bits) (#4934); -//! - extended sequential DCT, arithmetic coded, 8 or 12-bit (SOF9) (#5003); +//! [`decode`] supports these frames: +//! +//! - sequential DCT, Huffman coded, 8 or 12-bit samples (SOF0 and SOF1; SOF0 files that declare +//! 12 bits are read as SOF1); +//! - extended sequential DCT, arithmetic coded, 8 or 12-bit (SOF9); //! - progressive DCT, arithmetic coded, 8 or 12-bit (SOF10). //! +//! Every other frame returns [`Error::Unsupported`]: progressive Huffman (SOF2), lossless (SOF3, +//! SOF11) and hierarchical (SOF5–SOF7, SOF13–SOF15) files, and DCT precisions other than 8 and +//! 12 bits. +//! +//! An app that also uses a baseline decoder routes each file with [`frame`], which reports the +//! first frame marker and its precision without decoding. For example, to send this crate the +//! arithmetic-coded files and the Huffman-coded ones that aren't 8-bit: +//! +//! ``` +//! fn use_craft_jpeg(bytes: &[u8]) -> bool { +//! match craft_jpeg::frame(bytes) { +//! Some(f) if (0xC9..=0xCB).contains(&f.marker) => true, +//! Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8, +//! _ => false, +//! } +//! } +//! # assert!(!use_craft_jpeg(&[0xFF, 0xD8])); +//! ``` +//! //! Every scan is decoded into a whole-image buffer of quantized coefficients (Annex F for the //! sequential scans, Annex G for the progressive ones; the arithmetic decoder of Annex D with the //! conditioning of `DAC`), then dequantized, inverse transformed in floating point (A.3.3), -//! upsampled and colour converted. 12-bit images come out as 16-bit samples (scaled, not +//! upsampled and color converted. 12-bit images come out as 16-bit samples (scaled, not //! truncated). Restart intervals, 1, 3 and 4 components and any sampling factors are supported. +//! Metadata (ICC profiles, EXIF, XMP, Photoshop resources, resolution) is left to the caller, +//! which also reads the APP14 `Adobe` segment and passes its transform byte to [`decode`]. //! //! The decoder never panics on input: every read from the stream is bounds-checked, the work //! is bounded by the frame size times the scan count (capped at `MAX_SCANS`), and //! allocations are checked against the [`Limits`] first. -//! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told. +//! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told +//! through [`Decoded::damaged`]. -use crate::Format; -use crate::error::CodecError; -use crate::image::{ChannelLayout, SampleType}; -use crate::options::Limits; - -const F: Format = Format::Jpeg; - -fn err(m: impl std::fmt::Display) -> CodecError { - CodecError::malformed(F, m) +/// Why a stream could not be decoded. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The stream breaks T.81 in a way the decoder cannot recover from. + #[error("malformed JPEG: {0}")] + Malformed(String), + /// A valid JPEG process or feature this decoder does not implement. + #[error("{0}")] + Unsupported(String), + /// Decoding would exceed the caller's [`Limits`]. + #[error("limit exceeded: {0}")] + LimitExceeded(String), } -fn unsupported(m: impl Into) -> CodecError { - CodecError::unsupported(F, m) +fn err(m: impl std::fmt::Display) -> Error { + Error::Malformed(m.to_string()) +} + +fn unsupported(m: impl Into) -> Error { + Error::Unsupported(m.into()) +} + +/// Decompression-bomb guards, checked against the frame header before any image buffer is +/// allocated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Maximum `width * height`. + pub max_pixels: u64, + /// Maximum bytes for the decoded output, and separately for the decoder's own + /// coefficient and sample buffers. + pub max_alloc: u64, +} + +impl Default for Limits { + fn default() -> Self { + // 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB. On 32-bit targets (wasm32 has + // a 4 GiB address space shared with the rest of the app) 1 GiB is the most one buffer can + // safely ask for. + let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 }; + Limits { max_pixels: 1 << 30, max_alloc } + } +} + +impl Limits { + /// Check an output buffer of `width × height` pixels of `bytes_per_pixel` bytes. + fn check(&self, width: usize, height: usize, bytes_per_pixel: u64) -> Result<(), Error> { + if width == 0 || height == 0 { + return Err(err(format!("zero-sized image {width}x{height}"))); + } + let pixels = (width as u64).saturating_mul(height as u64); + if pixels > self.max_pixels { + return Err(Error::LimitExceeded(format!("{pixels} pixels exceed max {}", self.max_pixels))); + } + let bytes = pixels.saturating_mul(bytes_per_pixel); + if bytes > self.max_alloc { + return Err(Error::LimitExceeded(format!("{bytes} bytes exceed max_alloc {}", self.max_alloc))); + } + if usize::try_from(bytes).is_err() { + return Err(Error::LimitExceeded(format!("{bytes} bytes do not fit in memory"))); + } + Ok(()) + } } /// Zigzag scan position `k` → natural (row-major) coefficient index (Figure A.6). const ZIGZAG: [usize; 64] = [ - 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, - 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, + 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, ]; -/// The first frame marker (SOFn) and its sample precision. -fn frame_marker(b: &[u8]) -> Option<(u8, u8)> { +/// The first frame header of a stream, as reported by [`frame`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameKind { + /// The second byte of the SOFn marker: `0xC0` (SOF0) to `0xCF` (SOF15), never `0xC4`, + /// `0xC8` or `0xCC`. + pub marker: u8, + /// The sample precision in bits (P in B.2.2), as declared; not validated. + pub precision: u8, +} + +/// The first frame marker (SOFn) and its sample precision, found by walking the marker +/// segments that precede it. `None` when the stream doesn't start with SOI or reaches a scan, +/// EOI or the end of the data first. Reads only the headers, so it is cheap enough to route +/// every JPEG an app opens. +pub fn frame(b: &[u8]) -> Option { + if b.get(..2) != Some(&[0xFF, 0xD8]) { + return None; + } let mut i = 2; loop { if *b.get(i)? != 0xFF { @@ -53,7 +144,7 @@ fn frame_marker(b: &[u8]) -> Option<(u8, u8)> { _ => { let len = usize::from(u16::from_be_bytes([*b.get(i + 2)?, *b.get(i + 3)?])); if matches!(m, 0xC0..=0xCF) && !matches!(m, 0xC4 | 0xC8 | 0xCC) { - return Some((m, *b.get(i + 4)?)); + return Some(FrameKind { marker: m, precision: *b.get(i + 4)? }); } i = i.checked_add(2 + len)?; } @@ -61,24 +152,32 @@ fn frame_marker(b: &[u8]) -> Option<(u8, u8)> { } } -/// Whether this decoder (rather than `zune-jpeg`) should decode the file: arithmetic coding -/// (SOF9–SOF11) or a DCT frame whose precision isn't 8 bits. -pub(crate) fn handles(b: &[u8]) -> bool { - match frame_marker(b) { - Some((0xC9..=0xCB, _)) => true, - Some((0xC0 | 0xC1, p)) => p != 8, - _ => false, - } +/// Decoded samples, interleaved by pixel (`components` values per pixel), rows top to bottom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Samples { + /// 8-bit frames: `0..=255`. + U8(Vec), + /// 12-bit frames, scaled to the full 16-bit range: `round(v × 65535 / 4095)`. + U16(Vec), } -/// A decoded image: layout, sample type and interleaved native-endian sample bytes, plus -/// whether entropy-coded data was damaged (the rest of that scan was left out). -pub(crate) struct Decoded { +/// A decoded image. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decoded { pub width: u32, pub height: u32, - pub layout: ChannelLayout, - pub sample: SampleType, - pub data: Vec, + /// Samples per pixel: 1 for gray, 3 for RGB, 4 for CMYK. + /// + /// Three-component frames are converted from YCbCr to RGB (JFIF, T.871) unless the Adobe + /// transform is 0 or the component IDs are `R`, `G`, `B`. Four-component samples are ink + /// amounts (0 = no ink): the stored values are taken to follow Adobe's convention, inverted + /// (maximum = no ink), and are inverted back. With Adobe transform 2 (YCCK) the first three + /// components are converted from YCbCr, which yields the ink amounts of C, M and Y directly, + /// and K is inverted. + pub components: u8, + pub samples: Samples, + /// Entropy-coded data was damaged or cut short: the rest of that scan, and any scans past + /// the decoder's cap, were left out, and those blocks keep what earlier scans decoded. pub damaged: bool, } @@ -95,7 +194,7 @@ struct Huffman { } impl Huffman { - fn new(counts: &[u8], values: &[u8]) -> Result { + fn new(counts: &[u8], values: &[u8]) -> Result { let mut h = Huffman { maxcode: [-1; 17], valptr: [0; 17], mincode: [0; 17], values: values.to_vec() }; let mut code: i32 = 0; let mut k: i32 = 0; @@ -116,7 +215,7 @@ impl Huffman { Ok(h) } - fn decode(&self, bits: &mut Bits) -> Result { + fn decode(&self, bits: &mut Bits) -> Result { let mut code: i32 = 0; for l in 1..=16 { code = (code << 1) | bits.bit() as i32; @@ -176,18 +275,18 @@ struct Frame { comps: Vec, } -fn be16(s: &[u8], at: usize) -> Result { +fn be16(s: &[u8], at: usize) -> Result { match (s.get(at), s.get(at + 1)) { (Some(&a), Some(&b)) => Ok(u16::from_be_bytes([a, b])), _ => Err(err("segment too short")), } } -fn byte(s: &[u8], at: usize) -> Result { +fn byte(s: &[u8], at: usize) -> Result { s.get(at).copied().ok_or_else(|| err("segment too short")) } -fn parse_dqt(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { +fn parse_dqt(seg: &[u8], t: &mut Tables) -> Result<(), Error> { let mut i = 0; while i < seg.len() { let pq_tq = byte(seg, i)?; @@ -209,7 +308,7 @@ fn parse_dqt(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { Ok(()) } -fn parse_dht(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { +fn parse_dht(seg: &[u8], t: &mut Tables) -> Result<(), Error> { let mut i = 0; while i < seg.len() { let tc_th = byte(seg, i)?; @@ -228,7 +327,7 @@ fn parse_dht(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { Ok(()) } -fn parse_dac(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { +fn parse_dac(seg: &[u8], t: &mut Tables) -> Result<(), Error> { for pair in seg.chunks(2) { let [tc_tb, cs] = *pair else { return Err(err("DAC segment too short")) }; let tb = usize::from(tc_tb & 15); @@ -253,7 +352,7 @@ fn parse_dac(seg: &[u8], t: &mut Tables) -> Result<(), CodecError> { Ok(()) } -fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result { +fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result { let (arithmetic, progressive) = match marker { 0xC0 | 0xC1 => (false, false), 0xC9 => (true, false), @@ -287,8 +386,7 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result 8 { 2 } else { 1 })?; let mcux = width.div_ceil(8 * hmax); let mcuy = height.div_ceil(8 * vmax); // Coefficients (4 bytes) and the reconstructed plane (4 bytes) per padded sample. @@ -298,26 +396,17 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result limits.max_alloc { - return Err(CodecError::LimitExceeded(format!("{internal} bytes of decoder buffers exceed max_alloc {}", limits.max_alloc))); + return Err(Error::LimitExceeded(format!("{internal} bytes of decoder buffers exceed max_alloc {}", limits.max_alloc))); } let mut comps = Vec::with_capacity(nf); for (id, h, v, tq) in raw { let (bw, bh) = (mcux * h, mcuy * v); - let blocks = bw.checked_mul(bh).ok_or_else(|| CodecError::LimitExceeded("too many blocks".into()))?; + let blocks = bw.checked_mul(bh).ok_or_else(|| Error::LimitExceeded("too many blocks".into()))?; comps.push(Component { id, h, v, tq, cw: (width * h).div_ceil(hmax), ch: (height * v).div_ceil(vmax), bw, bh, coefs: vec![[0; 64]; blocks] }); } Ok(Frame { arithmetic, progressive, precision, width, height, hmax, vmax, mcux, mcuy, comps }) } -fn output_format(nf: usize, precision: u8) -> (ChannelLayout, SampleType) { - let layout = match nf { - 1 => ChannelLayout::Gray, - 4 => ChannelLayout::Cmyk, - _ => ChannelLayout::Rgb, - }; - (layout, if precision > 8 { SampleType::U16 } else { SampleType::U8 }) -} - // --------------------------------------------------------------------------------------------- // Entropy-coded segments @@ -566,7 +655,7 @@ impl Arith<'_> { } } -fn bin(st: &mut [u8], i: usize) -> Result<&mut u8, CodecError> { +fn bin(st: &mut [u8], i: usize) -> Result<&mut u8, Error> { st.get_mut(i).ok_or_else(|| err("arithmetic statistics index out of range")) } @@ -574,7 +663,7 @@ fn bin(st: &mut [u8], i: usize) -> Result<&mut u8, CodecError> { trait ScanDecoder { fn restart(&mut self); /// Decode one block of scan component `sc` into `blk` (zigzag order). - fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError>; + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error>; } /// The parameters of a scan header (B.2.3). @@ -600,7 +689,7 @@ impl ScanDecoder for HuffmanScan<'_> { self.pred = [0; 4]; } - fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error> { let &(dc, ac) = self.tables.get(sc).ok_or_else(|| err("bad scan component"))?; let pred = self.pred.get_mut(sc).ok_or_else(|| err("bad scan component"))?; let t = u32::from(dc.decode(&mut self.bits)?); @@ -663,7 +752,7 @@ struct ArithScan<'a> { impl ArithScan<'_> { /// Decode_DC_DIFF (F.2.4.1, Figures F.19 and F.22). - fn dc_diff(&mut self, sc: usize, tbl: usize) -> Result { + fn dc_diff(&mut self, sc: usize, tbl: usize) -> Result { let st = self.dc_stats.get_mut(tbl).ok_or_else(|| err("bad DC table"))?; let ctx = self.ctx.get_mut(sc).ok_or_else(|| err("bad scan component"))?; let s0 = *ctx; @@ -709,7 +798,7 @@ impl ArithScan<'_> { } /// Decode_AC_coefficients (F.2.4.2, Figures F.20 and F.24) over `ss..=se`, scaled by `al`. - fn ac_first(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + fn ac_first(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), Error> { let kx = usize::from(self.ac_kx.get(tbl).copied().unwrap_or(5)); let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; let mut k = self.ss.max(1); @@ -755,7 +844,7 @@ impl ArithScan<'_> { } /// Successive approximation of AC coefficients (G.1.3.3, Figure G.10). - fn ac_refine(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + fn ac_refine(&mut self, tbl: usize, blk: &mut [i32; 64]) -> Result<(), Error> { let st = self.ac_stats.get_mut(tbl).ok_or_else(|| err("bad AC table"))?; let p1: i32 = 1 << self.al; let m1: i32 = -1 << self.al; @@ -796,7 +885,7 @@ impl ScanDecoder for ArithScan<'_> { self.reset(); } - fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), CodecError> { + fn block(&mut self, sc: usize, blk: &mut [i32; 64]) -> Result<(), Error> { let &(td, ta) = self.ids.get(sc).ok_or_else(|| err("bad scan component"))?; match self.mode { ArithMode::Sequential | ArithMode::DcFirst => { @@ -830,7 +919,7 @@ impl ArithScan<'_> { } /// Walk the scan's MCUs in order (A.2), handing each block to `dec`; counts decoded MCUs. -fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanDecoder, done: &mut usize) -> Result<(), CodecError> { +fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanDecoder, done: &mut usize) -> Result<(), Error> { let single = scan.comps.len() == 1; let (mcux, mcuy) = if single { let &(ci, _, _) = scan.comps.first().ok_or_else(|| err("empty scan"))?; @@ -861,7 +950,7 @@ fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanD Ok(()) } -fn parse_scan(seg: &[u8], frame: &Frame) -> Result { +fn parse_scan(seg: &[u8], frame: &Frame) -> Result { let ns = usize::from(byte(seg, 0)?); if !(1..=4).contains(&ns) { return Err(err("bad number of scan components")); @@ -891,7 +980,7 @@ fn parse_scan(seg: &[u8], frame: &Frame) -> Result { } /// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`. -fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), CodecError> { +fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), Error> { if frame.arithmetic { let mode = match (frame.progressive, scan.ss, scan.ah) { (false, _, _) => ArithMode::Sequential, @@ -936,7 +1025,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: const MAX_SCANS: usize = 500; /// Parse the stream and decode every scan into coefficients. -fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), CodecError> { +fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), Error> { if b.get(..2) != Some(&[0xFF, 0xD8]) { return Err(err("not a JPEG stream")); } @@ -1063,8 +1152,8 @@ fn reconstruct(comp: &Component, q: &[u16; 64], basis: &[[f32; 8]; 8], max: f32) plane } -/// For each output coordinate: the two neighbouring component samples and the weight of the -/// second (centred linear interpolation of a subsampled component). +/// For each output coordinate: the two neighboring component samples and the weight of the +/// second (centered linear interpolation of a subsampled component). fn upsample_map(out: usize, samples: usize, factor: usize, fmax: usize) -> Vec<(usize, usize, f32)> { let last = samples.saturating_sub(1); (0..out) @@ -1077,7 +1166,7 @@ fn upsample_map(out: usize, samples: usize, factor: usize, fmax: usize) -> Vec<( } /// Turn the coefficients into interleaved output samples. -fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result<(ChannelLayout, SampleType, Vec), CodecError> { +fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result<(u8, Samples), Error> { let max = if frame.precision > 8 { 4095.0 } else { 255.0 }; let basis = idct_basis(); let (w, h) = (frame.width, frame.height); @@ -1090,14 +1179,19 @@ fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result< planes.push((plane, comp.bw * 8, xs, ys)); } let nc = planes.len(); - let (layout, sample) = output_format(nc, frame.precision); + let sixteen = frame.precision > 8; let rgb_ids = frame.comps.iter().map(|c| c.id).eq(*b"RGB"); let ycc = match nc { 3 => adobe_transform != Some(0) && !rgb_ids, 4 => adobe_transform == Some(2), _ => false, }; - let mut data = Vec::with_capacity(w * h * nc * sample.bytes()); + let (mut wide, mut narrow) = (Vec::new(), Vec::new()); + if sixteen { + wide.reserve_exact(w * h * nc); + } else { + narrow.reserve_exact(w * h * nc); + } let mut px = [0f32; 4]; let center = (max + 1.0) / 2.0; for y in 0..h { @@ -1131,23 +1225,31 @@ fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result< } for &p in px.iter().take(nc) { let p = p.clamp(0.0, max); - if sample == SampleType::U16 { - data.extend_from_slice(&((p * 65535.0 / max).round() as u16).to_ne_bytes()); + if sixteen { + wide.push((p * 65535.0 / max).round() as u16); } else { - data.push(p.round() as u8); + narrow.push(p.round() as u8); } } } } - Ok((layout, sample, data)) + let samples = if sixteen { Samples::U16(wide) } else { Samples::U8(narrow) }; + Ok((nc as u8, samples)) } -/// Decode a JPEG this module [`handles`]. -pub(crate) fn decode(b: &[u8], limits: &Limits, adobe_transform: Option) -> Result { - let (frame, tables, damaged) = decode_coefficients(b, limits)?; +/// Decode a JPEG stream of one of the frame types listed in the [crate docs](crate). +/// +/// `adobe_transform` is the transform byte of the stream's APP14 `Adobe` segment, if the caller +/// found one: it selects the color conversion of 3 and 4-component images (see +/// [`Decoded::components`]). The decoder ignores every APPn segment itself. +/// +/// Damaged entropy-coded data after at least one decoded MCU returns `Ok` with +/// [`Decoded::damaged`] set; a stream that yields no image data returns an error. +pub fn decode(bytes: &[u8], limits: &Limits, adobe_transform: Option) -> Result { + let (frame, tables, damaged) = decode_coefficients(bytes, limits)?; let (width, height) = (frame.width as u32, frame.height as u32); - let (layout, sample, data) = render(frame, &tables, adobe_transform)?; - Ok(Decoded { width, height, layout, sample, data, damaged }) + let (components, samples) = render(frame, &tables, adobe_transform)?; + Ok(Decoded { width, height, components, samples, damaged }) } #[cfg(test)] diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 7cf1b4fd..02b6657c 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -1,22 +1,40 @@ use super::*; -/// Local check against the conformance samples (never committed): set `PC_JPEG_SAMPLES` to a +/// The test image decoded by `zune-jpeg`, as interleaved 8-bit samples, and its size. +fn zune_decode(bytes: &[u8]) -> (Vec, (u32, u32)) { + let mut dec = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(bytes)); + let px = dec.decode().unwrap(); + let (w, h) = dec.dimensions().unwrap(); + (px, (w as u32, h as u32)) +} + +fn decode_default(bytes: &[u8]) -> Result { + decode(bytes, &Limits::default(), None) +} + +fn u16_samples(img: &Decoded) -> &[u16] { + match &img.samples { + Samples::U16(v) => v, + Samples::U8(_) => panic!("8-bit samples"), + } +} + +/// Local check against the conformance samples (never committed): set `CRAFT_JPEG_SAMPLES` to a /// directory holding testorig.jpg, testorig12.jpg and testimgari.jpg. #[test] #[ignore] fn conformance_samples() { - let dir = std::env::var("PC_JPEG_SAMPLES").unwrap(); + let dir = std::env::var("CRAFT_JPEG_SAMPLES").unwrap(); let read = |n: &str| std::fs::read(format!("{dir}/{n}")).unwrap(); - let reference = crate::decode(&read("testorig.jpg")).unwrap(); - let ref8 = reference.data().to_vec(); - for (name, depth) in [("testorig12.jpg", SampleType::U16), ("testimgari.jpg", SampleType::U8)] { - let img = crate::decode(&read(name)).unwrap(); - assert_eq!(img.dimensions(), reference.dimensions(), "{name}"); - assert_eq!(img.sample_type(), depth, "{name}"); - assert!(img.warnings.is_empty(), "{name}: {:?}", img.warnings); - let px: Vec = match depth { - SampleType::U16 => img.to_u16_samples().unwrap().iter().map(|&v| v as f32 / 257.0).collect(), - _ => img.data().iter().map(|&v| v as f32).collect(), + let (ref8, dimensions) = zune_decode(&read("testorig.jpg")); + for (name, sixteen) in [("testorig12.jpg", true), ("testimgari.jpg", false)] { + let img = decode_default(&read(name)).unwrap(); + assert_eq!((img.width, img.height), dimensions, "{name}"); + assert_eq!(matches!(img.samples, Samples::U16(_)), sixteen, "{name}"); + assert!(!img.damaged, "{name}"); + let px: Vec = match &img.samples { + Samples::U16(v) => v.iter().map(|&v| v as f32 / 257.0).collect(), + Samples::U8(v) => v.iter().map(|&v| v as f32).collect(), }; let mad = px.iter().zip(&ref8).map(|(a, &b)| (a - b as f32).abs()).sum::() / px.len() as f32; eprintln!("{name}: mean abs diff vs testorig.jpg {mad}"); @@ -376,11 +394,11 @@ fn arithmetic_twelve_bit_flat_block_is_sixteen_bit() { let mut blk = [0i32; 64]; blk[0] = 1000; let stream = arith_stream(12, 1, 1, &[blk], &[(0, 63, 0, 0)], 0); - assert!(handles(&stream)); - let img = crate::decode(&stream).unwrap(); - assert_eq!((img.dimensions(), img.sample_type(), img.layout()), ((8, 8), SampleType::U16, ChannelLayout::Gray)); + assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC9, precision: 12 })); + let img = decode_default(&stream).unwrap(); + assert_eq!((img.width, img.height, img.components), (8, 8, 1)); let want = (2173.0f32 * 65535.0 / 4095.0).round() as u16; - assert!(img.to_u16_samples().unwrap().iter().all(|&v| v.abs_diff(want) <= 1)); + assert!(u16_samples(&img).iter().all(|&v| v.abs_diff(want) <= 1)); } /// A 12-bit SOF1 stream built by hand: one 8×8 gray block with only a DC coefficient. The DC @@ -409,17 +427,18 @@ fn twelve_bit_huffman_block() -> Vec { #[test] fn twelve_bit_huffman_block_decodes_to_sixteen_bit() { let stream = twelve_bit_huffman_block(); - assert!(handles(&stream)); - let img = crate::decode(&stream).unwrap(); - assert_eq!((img.dimensions(), img.sample_type()), ((8, 8), SampleType::U16)); + assert_eq!(frame(&stream), Some(FrameKind { marker: 0xC1, precision: 12 })); + let img = decode_default(&stream).unwrap(); + assert_eq!((img.width, img.height), (8, 8)); let want = (3000.0f32 * 65535.0 / 4095.0).round() as u16; - let px = img.to_u16_samples().unwrap(); + let px = u16_samples(&img); assert!(px.iter().all(|&v| v.abs_diff(want) <= 1), "{:?} vs {want}", &px[..4]); } +/// APPn segments before the frame (here an APP13 `8BIM` block right after SOI) are skipped by +/// the probe and the decoder alike; reading them is the caller's job. #[test] -fn twelve_bit_jpeg_keeps_its_photoshop_resources() { - // An APP13 `8BIM` block (paths, Photoshop settings) right after SOI, as on the zune-jpeg path. +fn twelve_bit_jpeg_with_photoshop_resources_decodes() { let resources = b"8BIM\x04\x04\0\0\0\0\0\0".to_vec(); let mut app13 = b"Photoshop 3.0\0".to_vec(); app13.extend_from_slice(&resources); @@ -427,9 +446,8 @@ fn twelve_bit_jpeg_keeps_its_photoshop_resources() { let mut with = stream[..2].to_vec(); segment(&mut with, 0xED, &app13); with.extend_from_slice(&stream[2..]); - assert!(handles(&with)); - let img = crate::decode(&with).unwrap(); - assert_eq!(img.meta.photoshop_resources.as_deref(), Some(resources.as_slice())); + assert_eq!(frame(&with), frame(&stream)); + assert_eq!(decode_default(&with).unwrap(), decode_default(&stream).unwrap()); } /// Where the entropy-coded data of the last scan starts. @@ -447,10 +465,7 @@ fn huffman_scan_out_of_data_is_not_clean() { for keep in 0..2 { let mut s = full[..data + keep].to_vec(); s.extend([0xFF, 0xD9]); - assert!(decode(&s, &Limits::default(), None).is_err(), "keep {keep}"); - if let Ok(img) = crate::decode(&s) { - assert!(!img.warnings.is_empty(), "keep {keep}: clean decode of missing data"); - } + assert!(decode_default(&s).is_err(), "keep {keep}"); } // A multi-MCU scan cut in half, EOI kept, decodes as damaged. let (w, h) = (64usize, 64usize); @@ -470,7 +485,7 @@ fn huffman_scan_out_of_data_is_not_clean() { assert!(decode(&cut, &Limits::default(), None).unwrap().damaged); } -/// A baseline file from our encoder, relabelled SOF1, decodes like zune decodes the original. +/// A baseline file from `jpeg-encoder`, relabeled SOF1, decodes like zune decodes the original. #[test] fn extended_huffman_matches_zune_on_baseline_data() { let (w, h) = (37u16, 29u16); @@ -490,13 +505,14 @@ fn extended_huffman_matches_zune_on_baseline_data() { enc.set_restart_interval(restart); } enc.encode(&rgb, w, h, jpeg_encoder::ColorType::Rgb).unwrap(); - let zune = crate::decode(&out).unwrap(); + let (zune, _) = zune_decode(&out); let sof = out.windows(2).position(|p| p == [0xFF, 0xC0]).unwrap(); out[sof + 1] = 0xC1; let ours = decode(&out, &Limits::default(), None).unwrap(); - assert_eq!((ours.width, ours.height, ours.layout, ours.sample), (u32::from(w), u32::from(h), ChannelLayout::Rgb, SampleType::U8)); + assert_eq!((ours.width, ours.height, ours.components), (u32::from(w), u32::from(h), 3)); assert!(!ours.damaged); - let mad = ours.data.iter().zip(zune.data()).map(|(&a, &b)| f32::from(a.abs_diff(b))).sum::() / ours.data.len() as f32; + let Samples::U8(data) = &ours.samples else { panic!("16-bit samples") }; + let mad = data.iter().zip(&zune).map(|(&a, &b)| f32::from(a.abs_diff(b))).sum::() / data.len() as f32; assert!(mad < 1.5, "{sampling:?} restart {restart}: mean abs diff {mad}"); } } @@ -515,26 +531,26 @@ fn malformed_streams_return_errors_not_panics() { for (offset, value) in [(4, 16u8), (6, 0), (8, 0), (9, 7), (10, 9), (11, 0x50), (12, 9)] { let mut s = base.clone(); s[sof + offset] = value; - assert!(crate::decode(&s).is_err(), "SOF byte {offset} = {value}"); + assert!(decode_default(&s).is_err(), "SOF byte {offset} = {value}"); streams.push(s); } let mut dac = base.clone(); dac.splice(2..2, [0xFF, 0xCC, 0, 4, 0x00, 0x13]); - assert!(crate::decode(&dac).is_err()); + assert!(decode_default(&dac).is_err()); streams.push(dac); for s in &streams { for cut in 0..s.len() { - let _ = crate::decode(&s[..cut]); + let _ = decode_default(&s[..cut]); } for i in 2..s.len() { for v in [0x00, 0xFF, 0x5A] { let mut t = s.clone(); t[i] = v; - let _ = crate::decode(&t); + let _ = decode_default(&t); } } } - assert!(crate::decode(&streams[0][..40]).is_err()); + assert!(decode_default(&streams[0][..40]).is_err()); } /// Scans past `MAX_SCANS` aren't decoded (each one walks the whole MCU grid, with or without @@ -555,13 +571,14 @@ fn limits_are_checked_before_allocating() { let mut s = vec![0xFF, 0xD8]; segment(&mut s, 0xC9, &[8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x44, 0, 2, 0x11, 0, 3, 0x11, 0]); let small = Limits { max_alloc: 1 << 20, ..Limits::default() }; - assert!(matches!(decode(&s, &small, None), Err(CodecError::LimitExceeded(_)))); + assert!(matches!(decode(&s, &small, None), Err(Error::LimitExceeded(_)))); } #[test] -fn routing() { - assert!(!handles(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); - assert!(handles(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0])); - assert!(handles(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0])); - assert!(!handles(&[0xFF, 0xD8])); +fn frame_reports_the_marker_and_precision() { + let kind = |marker, precision| Some(FrameKind { marker, precision }); + assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC0, 8)); + assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC9, 0, 11, 8, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC9, 8)); + assert_eq!(frame(&[0xFF, 0xD8, 0xFF, 0xC1, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0]), kind(0xC1, 12)); + assert_eq!(frame(&[0xFF, 0xD8]), None); } From 6354ef2728a8b6f22b221665ab4be8c56bf48288 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:43:51 +0000 Subject: [PATCH 138/160] jpeg: README and crate list entry --- README.md | 3 ++ crates/jpeg/README.md | 100 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 crates/jpeg/README.md diff --git a/README.md b/README.md index 11a13d3f..9ec49a2a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " { /* 8-bit frames */ } + Samples::U16(px) => { /* 12-bit frames, scaled to 16 bits */ } +} +if img.damaged { /* warn: part of the image is missing */ } +``` + +- `decode(bytes, &Limits, adobe_transform: Option) -> Result`. + `adobe_transform` is the transform byte of the APP14 `Adobe` segment, when the app found one. +- `frame(bytes) -> Option` reads the headers up to the first SOFn marker and returns + that marker and the declared sample precision, without decoding. +- `Error` is `Malformed`, `Unsupported` or `LimitExceeded`, each with a message. + +## Output + +`Decoded` has `width`, `height`, `components`, `samples` and `damaged`. + +- `samples` is interleaved by pixel, rows top to bottom: `Samples::U8` for 8-bit frames, + `Samples::U16` for 12-bit frames, scaled to the full 16-bit range + (`round(v × 65535 / 4095)`). +- `components` is 1 (gray), 3 (RGB) or 4 (CMYK). + - 3 components are converted from YCbCr to RGB (JFIF, T.871), unless the Adobe transform is + 0 or the component IDs are `R`, `G`, `B`. + - 4 components are ink amounts (0 = no ink). The stored values are taken to follow Adobe's + inverted convention (maximum = no ink) and are inverted back. With Adobe transform 2 (YCCK), + C, M and Y are converted from YCbCr, which yields ink amounts directly, and K is inverted. +- `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had + decoded, or the stream had more than 500 scans. The rest of that scan is left out; those + blocks keep what earlier scans decoded. A stream with no decodable image data is an error. + +## Limits + +`Limits { max_pixels, max_alloc }` is checked against the frame header before anything is +allocated: `width × height` against `max_pixels`, and both the output buffer and the decoder's +own coefficient and sample buffers against `max_alloc`. The defaults are 2³⁰ pixels and 8 GiB on +64-bit targets, 1 GiB on 32-bit ones (wasm32). Fields are public, so an app sets one and keeps +the other default: + +```rust +let limits = craft_jpeg::Limits { max_alloc: 512 << 20, ..Default::default() }; +``` + +## Routing + +An app that decodes baseline JPEG with another decoder keeps its own rule for which files come +here, built on `frame`. PhotoCraft sends arithmetic-coded files (SOF9 to SOF11) and SOF0/SOF1 +files whose precision is not 8 bits: + +```rust +fn use_craft_jpeg(bytes: &[u8]) -> bool { + match craft_jpeg::frame(bytes) { + Some(f) if (0xC9..=0xCB).contains(&f.marker) => true, + Some(f) if matches!(f.marker, 0xC0 | 0xC1) => f.precision != 8, + _ => false, + } +} +``` + +SOF11 (lossless) falls in that range and returns `Unsupported`. + +## Boundaries + +- In scope: decoding the frames above into samples. +- Out of scope: metadata. The decoder skips every APPn and COM segment. ICC profiles, EXIF, + XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read. + Color management and encoding also stay in the apps. + +## Credits + +The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft. From f28de79037618e5e33fdde48570fa58b2e5742f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:49:41 +0000 Subject: [PATCH 139/160] jpeg: conformance tests against the libjpeg-turbo test images Commit testorig.jpg, testimgari.jpg and testorig12.jpg with a jpegtran Huffman transcode of testimgari.jpg and libjpeg-turbo's 12-bit decode of testorig12.jpg. The arithmetic-coded image must match its transcode exactly; the 8-bit and 12-bit decodes are compared against zune-jpeg and libjpeg-turbo within a small tolerance. Replaces the ignored conformance_samples test. --- crates/jpeg/src/tests.rs | 23 -- crates/jpeg/tests/conformance.rs | 98 +++++++ crates/jpeg/tests/data/README.ijg | 258 ++++++++++++++++++ crates/jpeg/tests/data/SHA256SUMS | 5 + crates/jpeg/tests/data/SOURCE.md | 46 ++++ crates/jpeg/tests/data/testimgari-huffman.jpg | Bin 0 -> 5756 bytes crates/jpeg/tests/data/testimgari.jpg | Bin 0 -> 5126 bytes crates/jpeg/tests/data/testorig.jpg | Bin 0 -> 5770 bytes crates/jpeg/tests/data/testorig12.jpg | Bin 0 -> 12394 bytes crates/jpeg/tests/data/testorig12.ppm | Bin 0 -> 202954 bytes 10 files changed, 407 insertions(+), 23 deletions(-) create mode 100644 crates/jpeg/tests/conformance.rs create mode 100644 crates/jpeg/tests/data/README.ijg create mode 100644 crates/jpeg/tests/data/SHA256SUMS create mode 100644 crates/jpeg/tests/data/SOURCE.md create mode 100644 crates/jpeg/tests/data/testimgari-huffman.jpg create mode 100644 crates/jpeg/tests/data/testimgari.jpg create mode 100644 crates/jpeg/tests/data/testorig.jpg create mode 100644 crates/jpeg/tests/data/testorig12.jpg create mode 100644 crates/jpeg/tests/data/testorig12.ppm diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 02b6657c..3929e643 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -19,29 +19,6 @@ fn u16_samples(img: &Decoded) -> &[u16] { } } -/// Local check against the conformance samples (never committed): set `CRAFT_JPEG_SAMPLES` to a -/// directory holding testorig.jpg, testorig12.jpg and testimgari.jpg. -#[test] -#[ignore] -fn conformance_samples() { - let dir = std::env::var("CRAFT_JPEG_SAMPLES").unwrap(); - let read = |n: &str| std::fs::read(format!("{dir}/{n}")).unwrap(); - let (ref8, dimensions) = zune_decode(&read("testorig.jpg")); - for (name, sixteen) in [("testorig12.jpg", true), ("testimgari.jpg", false)] { - let img = decode_default(&read(name)).unwrap(); - assert_eq!((img.width, img.height), dimensions, "{name}"); - assert_eq!(matches!(img.samples, Samples::U16(_)), sixteen, "{name}"); - assert!(!img.damaged, "{name}"); - let px: Vec = match &img.samples { - Samples::U16(v) => v.iter().map(|&v| v as f32 / 257.0).collect(), - Samples::U8(v) => v.iter().map(|&v| v as f32).collect(), - }; - let mad = px.iter().zip(&ref8).map(|(a, &b)| (a - b as f32).abs()).sum::() / px.len() as f32; - eprintln!("{name}: mean abs diff vs testorig.jpg {mad}"); - assert!(mad < 3.0, "{name}: {mad}"); - } -} - // --- A test-only arithmetic encoder (T.81 D.1, F.1.4 and G.1.3), mirroring the decoder --- struct Enc { diff --git a/crates/jpeg/tests/conformance.rs b/crates/jpeg/tests/conformance.rs new file mode 100644 index 00000000..2e81620f --- /dev/null +++ b/crates/jpeg/tests/conformance.rs @@ -0,0 +1,98 @@ +//! Decodes of the libjpeg-turbo test images (tests/data, see SOURCE.md there). + +use craft_jpeg::{Decoded, Limits, Samples, decode}; + +fn fixture(name: &str) -> Vec { + let path = format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) +} + +fn decode_fixture(name: &str) -> Decoded { + let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}"); + assert!(!img.damaged, "{name}"); + img +} + +/// Mean and maximum absolute difference. +fn diff(a: impl Iterator, b: impl Iterator) -> (f64, f64) { + let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize); + for (a, b) in a.zip(b) { + let d = (a - b).abs(); + sum += d; + max = max.max(d); + n += 1; + } + (sum / n.max(1) as f64, max) +} + +/// testimgari-huffman.jpg is testimgari.jpg transcoded by jpegtran: the same coefficients, only +/// the entropy coding differs, so the samples must be identical. +#[test] +fn arithmetic_coded_image_matches_its_huffman_transcode() { + let huffman = decode_fixture("testimgari-huffman.jpg"); + let arithmetic = decode_fixture("testimgari.jpg"); + assert!(matches!(huffman.samples, Samples::U8(_))); + assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg"); +} + +/// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is +/// float against integer IDCT and color conversion. +#[test] +fn baseline_image_matches_zune_jpeg() { + let bytes = fixture("testorig.jpg"); + let mut zune = zune_jpeg::JpegDecoder::new(zune_jpeg::zune_core::bytestream::ZCursor::new(&bytes[..])); + let reference = zune.decode().unwrap_or_else(|e| panic!("zune-jpeg: {e:?}")); + let img = decode_fixture("testorig.jpg"); + let Samples::U8(px) = &img.samples else { panic!("testorig.jpg: expected 8-bit samples") }; + assert_eq!(px.len(), reference.len()); + let (mean, max) = diff(px.iter().map(|&v| f64::from(v)), reference.iter().map(|&v| f64::from(v))); + eprintln!("testorig.jpg vs zune-jpeg: mean {mean:.3}, max {max}"); + assert!(mean < MAX_MEAN_8 && max <= MAX_ABS_8, "mean {mean}, max {max}"); +} + +// Observed: mean 0.52, max 3. +const MAX_MEAN_8: f64 = 0.75; +const MAX_ABS_8: f64 = 4.0; + +/// Parse a binary PPM with 16-bit big-endian samples. +fn ppm16(bytes: &[u8]) -> (u32, u32, u32, Vec) { + let mut fields = Vec::new(); + let mut at = 0; + while fields.len() < 4 { + while bytes.get(at).is_some_and(u8::is_ascii_whitespace) { + at += 1; + } + let start = at; + while bytes.get(at).is_some_and(|b| !b.is_ascii_whitespace()) { + at += 1; + } + fields.push(std::str::from_utf8(&bytes[start..at]).unwrap_or_default().to_owned()); + } + assert_eq!(fields[0], "P6"); + let num = |s: &str| s.parse::().unwrap_or_else(|e| panic!("PPM header {s}: {e}")); + let (w, h, maxval) = (num(&fields[1]), num(&fields[2]), num(&fields[3])); + let data = &bytes[at + 1..]; + let px: Vec = data.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + assert_eq!(px.len(), (w * h * 3) as usize); + (w, h, maxval, px) +} + +/// testorig12.jpg against libjpeg-turbo's decode (testorig12.ppm, 12-bit RGB; SOURCE.md says how +/// it was made). libjpeg-turbo uses an integer IDCT and integer color conversion, craft-jpeg +/// floats; the comparison is on the 12-bit scale. +#[test] +fn twelve_bit_image_matches_libjpeg_turbo() { + let (w, h, maxval, reference) = ppm16(&fixture("testorig12.ppm")); + assert_eq!((w, h, maxval), (227, 149, 4095)); + let img = decode_fixture("testorig12.jpg"); + let Samples::U16(px) = &img.samples else { panic!("testorig12.jpg: expected 16-bit samples") }; + assert_eq!(px.len(), reference.len()); + let (mean, max) = diff(px.iter().map(|&v| f64::from(v) * 4095.0 / 65535.0), reference.iter().map(|&v| f64::from(v))); + eprintln!("testorig12.jpg vs libjpeg-turbo (12-bit units): mean {mean:.3}, max {max:.2}"); + assert!(mean < MAX_MEAN_12 && max <= MAX_ABS_12, "mean {mean}, max {max}"); +} + +// Observed: mean 0.50, max 2.46 (of 4095). +const MAX_MEAN_12: f64 = 0.75; +const MAX_ABS_12: f64 = 4.0; diff --git a/crates/jpeg/tests/data/README.ijg b/crates/jpeg/tests/data/README.ijg new file mode 100644 index 00000000..8f376826 --- /dev/null +++ b/crates/jpeg/tests/data/README.ijg @@ -0,0 +1,258 @@ +libjpeg-turbo note: This file has been modified by The libjpeg-turbo Project +to include only information relevant to libjpeg-turbo, to wordsmith certain +sections, and to remove impolitic language that existed in the libjpeg v8 +README. It is included only for reference. Please see README.md for +information specific to libjpeg-turbo. + + +The Independent JPEG Group's JPEG software +========================================== + +This distribution contains a release of the Independent JPEG Group's free JPEG +software. You are welcome to redistribute this software and to use it for any +purpose, subject to the conditions under LEGAL ISSUES, below. + +This software is the work of Tom Lane, Guido Vollbeding, Philip Gladstone, +Bill Allombert, Jim Boucher, Lee Crocker, Bob Friesenhahn, Ben Jackson, +Julian Minguillon, Luis Ortiz, George Phillips, Davide Rossi, Ge' Weijers, +and other members of the Independent JPEG Group. + +IJG is not affiliated with the ISO/IEC JTC1/SC29/WG1 standards committee +(also known as JPEG, together with ITU-T SG16). + + +DOCUMENTATION ROADMAP +===================== + +This file contains the following sections: + +OVERVIEW General description of JPEG and the IJG software. +LEGAL ISSUES Copyright, lack of warranty, terms of distribution. +REFERENCES Where to learn more about JPEG. +ARCHIVE LOCATIONS Where to find newer versions of this software. +FILE FORMAT WARS Software *not* to get. +TO DO Plans for future IJG releases. + +Other documentation files in the distribution are: + +User documentation: + usage.txt Usage instructions for cjpeg, djpeg, jpegtran, + rdjpgcom, and wrjpgcom. + *.1 Unix-style man pages for programs (same info as usage.txt). + wizard.txt Advanced usage instructions for JPEG wizards only. + change.log Version-to-version change highlights. +Programmer and internal documentation: + libjpeg.txt How to use the JPEG library in your own programs. + example.c Sample code for calling the JPEG library. + structure.txt Overview of the JPEG library's internal structure. + coderules.txt Coding style rules --- please read if you contribute code. + +Please read at least usage.txt. Some information can also be found in the JPEG +FAQ (Frequently Asked Questions) article. See ARCHIVE LOCATIONS below to find +out where to obtain the FAQ article. + +If you want to understand how the JPEG code works, we suggest reading one or +more of the REFERENCES, then looking at the documentation files (in roughly +the order listed) before diving into the code. + + +OVERVIEW +======== + +This package contains C software to implement JPEG image encoding, decoding, +and transcoding. JPEG (pronounced "jay-peg") is a standardized compression +method for full-color and grayscale images. JPEG's strong suit is compressing +photographic images or other types of images that have smooth color and +brightness transitions between neighboring pixels. Images with sharp lines or +other abrupt features may not compress well with JPEG, and a higher JPEG +quality may have to be used to avoid visible compression artifacts with such +images. + +JPEG is normally lossy, meaning that the output pixels are not necessarily +identical to the input pixels. However, on photographic content and other +"smooth" images, very good compression ratios can be obtained with no visible +compression artifacts, and extremely high compression ratios are possible if +you are willing to sacrifice image quality (by reducing the "quality" setting +in the compressor.) + +This software implements JPEG baseline, extended-sequential, progressive, and +lossless compression processes. Provision is made for supporting all variants +of these processes, although some uncommon parameter settings aren't +implemented yet. We have made no provision for supporting the hierarchical +processes defined in the standard. + +We provide a set of library routines for reading and writing JPEG image files, +plus two sample applications "cjpeg" and "djpeg", which use the library to +perform conversion between JPEG and some other popular image file formats. +The library is intended to be reused in other applications. + +In order to support file conversion and viewing software, we have included +considerable functionality beyond the bare JPEG coding/decoding capability; +for example, the color quantization modules are not strictly part of JPEG +decoding, but they are essential for output to colormapped file formats or +colormapped displays. These extra functions can be compiled out of the +library if not required for a particular application. + +We have also included "jpegtran", a utility for lossless transcoding between +different JPEG processes, and "rdjpgcom" and "wrjpgcom", two simple +applications for inserting and extracting textual comments in JFIF files. + +The emphasis in designing this software has been on achieving portability and +flexibility, while also making it fast enough to be useful. In particular, +the software is not intended to be read as a tutorial on JPEG. (See the +REFERENCES section for introductory material.) Rather, it is intended to +be reliable, portable, industrial-strength code. We do not claim to have +achieved that goal in every aspect of the software, but we strive for it. + +We welcome the use of this software as a component of commercial products. +No royalty is required, but we do ask for an acknowledgement in product +documentation, as described under LEGAL ISSUES. + + +LEGAL ISSUES +============ + +In plain English: + +1. We don't promise that this software works. (But if you find any bugs, + please let us know!) +2. You can use this software for whatever you want. You don't have to pay us. +3. You may not pretend that you wrote this software. If you use it in a + program, you must acknowledge somewhere in your documentation that + you've used the IJG code. + +In legalese: + +The authors make NO WARRANTY or representation, either express or implied, +with respect to this software, its quality, accuracy, merchantability, or +fitness for a particular purpose. This software is provided "AS IS", and you, +its user, assume the entire risk as to its quality and accuracy. + +This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding. +All Rights Reserved except as specified below. + +Permission is hereby granted to use, copy, modify, and distribute this +software (or portions thereof) for any purpose, without fee, subject to these +conditions: +(1) If any part of the source code for this software is distributed, then this +README file must be included, with this copyright and no-warranty notice +unaltered; and any additions, deletions, or changes to the original files +must be clearly indicated in accompanying documentation. +(2) If only executable code is distributed, then the accompanying +documentation must state that "this software is based in part on the work of +the Independent JPEG Group". +(3) Permission for use of this software is granted only if the user accepts +full responsibility for any undesirable consequences; the authors accept +NO LIABILITY for damages of any kind. + +These conditions apply to any software derived from or based on the IJG code, +not just to the unmodified library. If you use our work, you ought to +acknowledge us. + +Permission is NOT granted for the use of any IJG author's name or company name +in advertising or publicity relating to this software or products derived from +it. This software may be referred to only as "the Independent JPEG Group's +software". + +We specifically permit and encourage the use of this software as the basis of +commercial products, provided that all warranty or liability claims are +assumed by the product vendor. + + +REFERENCES +========== + +We recommend reading one or more of these references before trying to +understand the innards of the JPEG software. + +The best short technical introduction to the JPEG compression algorithm is + Wallace, Gregory K. "The JPEG Still Picture Compression Standard", + Communications of the ACM, April 1991 (vol. 34 no. 4), pp. 30-44. +(Adjacent articles in that issue discuss MPEG motion picture compression, +applications of JPEG, and related topics.) If you don't have the CACM issue +handy, a PDF file containing a revised version of Wallace's article is +available at http://www.ijg.org/files/Wallace.JPEG.pdf. The file (actually +a preprint for an article that appeared in IEEE Trans. Consumer Electronics) +omits the sample images that appeared in CACM, but it includes corrections +and some added material. Note: the Wallace article is copyright ACM and IEEE, +and it may not be used for commercial purposes. + +A somewhat less technical, more leisurely introduction to JPEG can be found in +"The Data Compression Book" by Mark Nelson and Jean-loup Gailly, published by +M&T Books (New York), 2nd ed. 1996, ISBN 1-55851-434-1. This book provides +good explanations and example C code for a multitude of compression methods +including JPEG. It is an excellent source if you are comfortable reading C +code but don't know much about data compression in general. The book's JPEG +sample code is far from industrial-strength, but when you are ready to look +at a full implementation, you've got one here... + +The best currently available description of JPEG is the textbook "JPEG Still +Image Data Compression Standard" by William B. Pennebaker and Joan L. +Mitchell, published by Van Nostrand Reinhold, 1993, ISBN 0-442-01272-1. +Price US$59.95, 638 pp. The book includes the complete text of the ISO JPEG +standards (DIS 10918-1 and draft DIS 10918-2). + +The original JPEG standard is divided into two parts, Part 1 being the actual +specification, while Part 2 covers compliance testing methods. Part 1 is +titled "Digital Compression and Coding of Continuous-tone Still Images, +Part 1: Requirements and guidelines" and has document numbers ISO/IEC IS +10918-1, ITU-T T.81. Part 2 is titled "Digital Compression and Coding of +Continuous-tone Still Images, Part 2: Compliance testing" and has document +numbers ISO/IEC IS 10918-2, ITU-T T.83. + +The JPEG standard does not specify all details of an interchangeable file +format. For the omitted details, we follow the "JFIF" conventions, revision +1.02. JFIF version 1 has been adopted as ISO/IEC 10918-5 (05/2013) and +Recommendation ITU-T T.871 (05/2011): Information technology - Digital +compression and coding of continuous-tone still images: JPEG File Interchange +Format (JFIF). It is available as a free download in PDF file format from +https://www.iso.org/standard/54989.html and http://www.itu.int/rec/T-REC-T.871. +A PDF file of the older JFIF 1.02 specification is available at +http://www.w3.org/Graphics/JPEG/jfif3.pdf. + +The TIFF 6.0 file format specification can be obtained from +http://mirrors.ctan.org/graphics/tiff/TIFF6.ps.gz. The JPEG incorporation +scheme found in the TIFF 6.0 spec of 3-June-92 has a number of serious +problems. IJG does not recommend use of the TIFF 6.0 design (TIFF Compression +tag 6). Instead, we recommend the JPEG design proposed by TIFF Technical Note +#2 (Compression tag 7). Copies of this Note can be obtained from +http://www.ijg.org/files/. It is expected that the next revision +of the TIFF spec will replace the 6.0 JPEG design with the Note's design. +Although IJG's own code does not support TIFF/JPEG, the free libtiff library +uses our library to implement TIFF/JPEG per the Note. + + +ARCHIVE LOCATIONS +================= + +The "official" archive site for this software is www.ijg.org. +The most recent released version can always be found there in +directory "files". + +The JPEG FAQ (Frequently Asked Questions) article is a source of some +general information about JPEG. It is available at +http://www.faqs.org/faqs/jpeg-faq. + + +FILE FORMAT COMPATIBILITY +========================= + +This software implements ITU T.81 | ISO/IEC 10918 with some extensions from +ITU T.871 | ISO/IEC 10918-5 (JPEG File Interchange Format-- see REFERENCES). +Informally, the term "JPEG image" or "JPEG file" most often refers to JFIF or +a subset thereof, but there are other formats containing the name "JPEG" that +are incompatible with the original JPEG standard or with JFIF (for instance, +JPEG 2000 and JPEG XR). This software therefore does not support these +formats. Indeed, one of the original reasons for developing this free software +was to help force convergence on a common, interoperable format standard for +JPEG files. + +JFIF is a minimal or "low end" representation. TIFF/JPEG (TIFF revision 6.0 as +modified by TIFF Technical Note #2) can be used for "high end" applications +that need to record a lot of additional data about an image. + + +TO DO +===== + +Please send bug reports, offers of help, etc. to jpeg-info@jpegclub.org. diff --git a/crates/jpeg/tests/data/SHA256SUMS b/crates/jpeg/tests/data/SHA256SUMS new file mode 100644 index 00000000..551a5dba --- /dev/null +++ b/crates/jpeg/tests/data/SHA256SUMS @@ -0,0 +1,5 @@ +4672c7f08864cd0a8c73a4fa4b66ca32b635d38464551c1ecf06564ae8c89b38 testimgari.jpg +491679b8057739b3c8e5bacd1e918efb1691d271cbbd69820ff8d480dcb90963 testimgari-huffman.jpg +acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg +34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg +62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm diff --git a/crates/jpeg/tests/data/SOURCE.md b/crates/jpeg/tests/data/SOURCE.md new file mode 100644 index 00000000..3876fe87 --- /dev/null +++ b/crates/jpeg/tests/data/SOURCE.md @@ -0,0 +1,46 @@ +# Test images + +The JPEG files come from libjpeg-turbo 3.0.0, `testimages/` +(https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License +(README.ijg in this directory). The other two files are derived from them as described below. +`SHA256SUMS` lists every file. + +| File | What it is | +|---|---| +| `testorig.jpg` | 227×149 YCbCr, 2×2 luma sampling, baseline Huffman (SOF0) | +| `testimgari.jpg` | the same image, arithmetic coded (SOF9), one interleaved scan | +| `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) | +| `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) | +| `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 | + +`testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients: +libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the +coefficients of `testimgari.jpg`, so the two must decode to identical samples. + +## How the derived files were made + +`testimgari-huffman.jpg`, with jpegtran from libjpeg-turbo 3.1.0 (Alpine 3.22, +`libjpeg-turbo-utils-3.1.0-r0`): + +```sh +jpegtran -copy none testimgari.jpg > testimgari-huffman.jpg +``` + +`testorig12.ppm`, with libjpeg-turbo 3.2.0 through imagecodecs 2026.10.10 (default settings: +integer IDCT `JDCT_ISLOW`, fancy upsampling), run as +`uv run --with imagecodecs --with numpy python make.py testorig12.jpg testorig12.ppm`: + +```python +import sys, imagecodecs, numpy as np +src, dst = sys.argv[1], sys.argv[2] +a = imagecodecs.jpeg8_decode(open(src, "rb").read()) +h, w, c = a.shape +assert c == 3 and a.dtype == np.uint16 and a.max() <= 4095 +with open(dst, "wb") as f: + f.write(b"P6\n%d %d\n4095\n" % (w, h)) + f.write(a.astype(">u2").tobytes()) +``` + +`djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file. + +"This software is based in part on the work of the Independent JPEG Group." diff --git a/crates/jpeg/tests/data/testimgari-huffman.jpg b/crates/jpeg/tests/data/testimgari-huffman.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2501c6159865e84615235a62899150282cab6104 GIT binary patch literal 5756 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 S25o&-SUN00f`hCtCjSG6MZ^UF literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/testimgari.jpg b/crates/jpeg/tests/data/testimgari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..896648741b9b512b2f1f8e2ef0471a88ee0e6ca5 GIT binary patch literal 5126 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 literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/testorig12.jpg b/crates/jpeg/tests/data/testorig12.jpg new file mode 100644 index 0000000000000000000000000000000000000000..861aff98e2a78131bf546b82fd38b00a4f741dd2 GIT binary patch literal 12394 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 Iua1WP4;iQWivR!s literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/testorig12.ppm b/crates/jpeg/tests/data/testorig12.ppm new file mode 100644 index 0000000000000000000000000000000000000000..df60fe91f893c87404f2e34eb0af6ef2cb216a5e GIT binary patch literal 202954 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%0AM^@74m$WV{3iZ1Z@#z8 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 F{(mtNRFwb# literal 0 HcmV?d00001 From 6fdd2efbba5090c26c31eb30990eb262ea1c2afb Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:52:51 -0400 Subject: [PATCH 140/160] docking: showing a panel with no known place opens it in the first dock group or at an edge (#286) * docking: open an unanchored panel in the first dock group or at an edge * docking: an unanchored open prefers the end-side dock; an edge frame is a quarter wide * docking: drop the redundant edge resize; an edge frame is already a quarter wide * docking: an unanchored open passes over minimized groups and iconic columns --- crates/ui/src/docking/command.rs | 3 +- crates/ui/src/docking/command_tests.rs | 24 +++++++++++++++ crates/ui/src/docking/dock_tests.rs | 42 ++++++++++++++++++++++++++ crates/ui/src/docking/model.rs | 41 +++++++++++++++++++++++++ crates/ui/src/docking/workspace.rs | 5 +-- docs/ui-docking.md | 8 ++++- 6 files changed, 119 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/docking/command.rs b/crates/ui/src/docking/command.rs index 36b84bb8..99825f1d 100644 --- a/crates/ui/src/docking/command.rs +++ b/crates/ui/src/docking/command.rs @@ -501,7 +501,8 @@ impl DockCommand

{ /// Apply the command to `workspace`, all or nothing, and return the panels it closed so /// the application can release their state. Showing a hidden panel restores its last - /// location; Lock sets [`Workspace::locked`]. Application-owned commands + /// location, and a panel never laid out opens as [`Action::Open`] without an anchor does + /// ([`Workspace::set_visible`]); Lock sets [`Workspace::locked`]. Application-owned commands /// ([`Self::is_app_owned`]) return [`DockError::NotAllowed`] and change nothing. /// `space` is where `window.panel.float` floats, as for [`Self::to_actions`]. pub fn apply( diff --git a/crates/ui/src/docking/command_tests.rs b/crates/ui/src/docking/command_tests.rs index 913cb35d..78d406b1 100644 --- a/crates/ui/src/docking/command_tests.rs +++ b/crates/ui/src/docking/command_tests.rs @@ -420,3 +420,27 @@ fn dock_to_the_side_a_panel_already_leads_the_last_column_of() { // The whole column already is the last one: nothing to do. assert_eq!(actions(json!({"command": "window.panel.dock", "panel": "layers", "column": true, "side": "Start"})), vec![]); } + +#[test] +fn showing_a_panel_never_laid_out_opens_it_in_the_first_dock_group_or_at_an_edge() { + // "brushes" is in neither the layout nor `hidden`. + for params in [ + json!({"command": "window.panel.activate", "panel": "brushes"}), + json!({"command": "window.panel.dock", "panel": "brushes"}), + json!({"command": "ui.dock", "operation": "open", "panel": "brushes"}), + ] { + let (shown, _) = applied(params.clone()); + assert!( + matches!(shown.layout.place(&s("brushes")), Some(dock_model::Place::Dock { column: 0, group: 0, .. })), + "{params}: {:?}", + shown.layout + ); + assert_eq!(shown.layout.panel_state(&s("brushes")), PanelState::Front, "{params}"); + } + // No dock, and the only frame is a protected canvas: a new frame at the edge, not a tab. + let mut workspace = Workspace::new(Layout { root: Some(Node::Tabs { panels: vec![s("canvas")], active: 0 }), floating: Vec::new() }); + let command = parse(json!({"command": "window.panel.activate", "panel": "brushes"})).unwrap(); + command.apply(&mut workspace, false, &space(), permissions).unwrap(); + assert!(matches!(workspace.layout.place(&s("brushes")), Some(dock_model::Place::Node(_))), "{:?}", workspace.layout); + assert_ne!(workspace.layout.place(&s("brushes")), workspace.layout.place(&s("canvas"))); +} diff --git a/crates/ui/src/docking/dock_tests.rs b/crates/ui/src/docking/dock_tests.rs index 1881135b..dcfd2b73 100644 --- a/crates/ui/src/docking/dock_tests.rs +++ b/crates/ui/src/docking/dock_tests.rs @@ -616,6 +616,48 @@ fn screen_reader_values_follow_the_column_width_on_either_edge() { } } +#[test] +fn an_unanchored_open_prefers_the_end_dock_and_an_edge_frame_takes_a_quarter() { + let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] }; + let end = docked(Side::End, &[&[&[2]]]).root.unwrap(); + let mut layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }), + floating: Vec::new(), + }; + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert_eq!(columns(&layout, Side::End), vec![vec![vec![2, 9]]]); + assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1]]]); + // A minimized group and an iconic column are passed over: the panel would not show there. + let start = Node::Dock { side: Side::Start, columns: vec![Column::new(vec![group(&[1])], 240.0)] }; + let mut end = docked(Side::End, &[&[&[2]]]).root.unwrap(); + if let Node::Split { second, .. } = &mut end + && let Node::Dock { columns, .. } = second.as_mut() + { + columns[0].groups[0].minimized = true; + } + let mut layout = Layout { + root: Some(Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::FixedFirst(240.0), first: Box::new(start), second: Box::new(end) }), + floating: Vec::new(), + }; + let mut iconic = layout.clone(); + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert_eq!(columns(&layout, Side::Start), vec![vec![vec![1, 9]]]); + if let Some(Node::Split { first, .. }) = &mut iconic.root + && let Node::Dock { columns, .. } = first.as_mut() + { + columns[0].iconic = true; + } + iconic.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + assert!(matches!(iconic.place(&9), Some(dock_model::Place::Node(_))), "{iconic:?}"); + + // No dock: a frame at the right edge, a quarter of the width. + let mut layout = Layout { root: Some(Node::Tabs { panels: vec![0], active: 0 }), floating: Vec::new() }; + layout.apply(Action::Open { panel: 9, anchor: None }).unwrap(); + let Some(Node::Split { axis: SplitAxis::Horizontal, size, second, .. }) = &layout.root else { panic!("{layout:?}") }; + assert_eq!(*size, SplitSize::Ratio(0.75)); + assert_eq!(**second, Node::Tabs { panels: vec![9], active: 0 }); +} + /// Panels 1-3 as a creative app's Color, Properties and Layers: Layers fills the column and asks /// for room for about ten rows. struct Creative; diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index a30e5994..521ccd5d 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -365,6 +365,11 @@ pub enum Action

{ panel: P, }, /// Reveal a registered, currently absent panel. Applications validate their panel registry. + /// `anchor` names the group it joins as the last tab. Without one it becomes the root of an + /// empty layout, else joins the first dock group that accepts tabs and is shown (not + /// minimized, not in an iconic column; the end-side dock's first), else gets a new frame a + /// quarter wide at the right edge of the docked area + /// ([`Placement::Edge`]). Open { panel: P, anchor: Option

, @@ -909,6 +914,13 @@ impl Layout

{ /// application, particularly Open. pub fn apply_with_permissions(&mut self, action: Action

, permissions: impl Fn(&P) -> Permissions) -> Result, DockError> { self.validate()?; + let action = match action { + Action::Open { panel, anchor: None } if self.root.is_some() && !self.contains(&panel) => { + let (anchor, placement) = self.open_place(&permissions).ok_or(DockError::MissingTarget)?; + Action::OpenAt { panel, anchor, placement } + } + action => action, + }; match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { // Opening an absent panel moves nothing, so it works in a locked workspace too. @@ -1241,6 +1253,35 @@ impl Layout

{ removed } + /// Where [`Action::Open`] without an anchor puts an absent panel in a layout with a root: + /// the end of the first shown dock group that accepts tabs, in the end-side dock if one has + /// such a group, else a new frame at the right edge of the docked area. + fn open_place(&self, permissions: impl Fn(&P) -> Permissions) -> Option<(P, Placement

)> { + let docked: Vec<(&P, Place)> = self + .panels() + .into_iter() + .filter_map(|p| Some((p, self.place(p)?))) + .filter(|(_, place)| !matches!(place, Place::Floating { .. })) + .collect(); + let side = |place: &Place| match place { + Place::Dock { path, .. } => match self.root.as_ref().and_then(|root| node_at(root, path)) { + Some(Node::Dock { side, .. }) => Some(*side), + _ => None, + }, + _ => None, + }; + // A minimized group or an iconic column would leave the panel open but not shown. + let shown = |place: &Place| match place { + Place::Dock { group, .. } => self.column(place).is_some_and(|c| !c.iconic && c.groups.get(*group).is_some_and(|g| !g.minimized)), + _ => false, + }; + let groups = docked.iter().filter(|(p, place)| side(place).is_some() && shown(place) && permissions(p).accepts_tabs); + if let Some((panel, _)) = groups.clone().find(|(_, place)| side(place) == Some(Side::End)).or_else(|| groups.clone().next()) { + return Some(((*panel).clone(), Placement::Tab { before: None })); + } + docked.first().map(|(panel, _)| ((*panel).clone(), Placement::Edge(Zone::Right))) + } + fn insert(&mut self, entry: StackEntry

, anchor: &P, placement: Placement

) -> Result<(), DockError> { let place = self.place(anchor).ok_or(DockError::MissingTarget)?; let tree = place.tree_path().map(|(window, path)| (window, path.to_vec())); diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index 244c5f0d..f5ab2885 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -120,7 +120,8 @@ impl Workspace

{ } /// Show or hide one panel. Showing restores its recorded location, following the chain of - /// hidden anchors when its own anchor is hidden too; otherwise it opens beside `fallback`. + /// hidden anchors when its own anchor is hidden too; otherwise it opens as + /// [`Action::Open`] with `fallback` as the anchor, following `permissions`. /// A shown panel becomes the active tab of its group. Hiding follows `permissions` and /// [`Self::locked`], as [`Self::apply`] does, so a protected canvas stays shown. pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { @@ -137,7 +138,7 @@ impl Workspace

{ let mut layout = self.layout.clone(); if self.restore_into(&mut layout, &panel).is_err() { layout = self.layout.clone(); - layout.apply(Action::Open { panel: panel.clone(), anchor: fallback })?; + layout.apply_with_permissions(Action::Open { panel: panel.clone(), anchor: fallback }, &permissions)?; } layout.apply(Action::Activate { panel: panel.clone() })?; self.layout = layout; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 2a6d48db..df9a61b2 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -72,6 +72,10 @@ layout.apply(Action::Move { - `Move`, `MovePanel`, `Float` and `Close` act on one panel. `MoveGroup`, `MoveColumn` and `MoveWindow` move a group, a dock column or a floating window, named by any panel in it. +- `Open` reveals an absent panel as the last tab of its `anchor`'s group. Without an anchor it + becomes the root of an empty layout, else joins the first dock group that accepts tabs and is + shown, not minimized or in an iconic column (the end-side dock's first), else gets a new frame a quarter wide at the right edge of the docked + area. `OpenAt` takes any placement. - A `Target` is `At { anchor, placement }`, `Float { rect }` or `NewDock { side }`. - A `Placement` is a tab position, a split zone, a new group above or below (with an optional preferred `height`), a new column, or a new frame on an outer edge. @@ -109,7 +113,7 @@ an optional saved arrangement for Reset, and `locked`. | Method | Does | |---|---| | `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused; `SetNative`, which records the OS windows, still applies. | -| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else beside `fallback`. | +| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. | | `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | | `validate` | Check a loaded workspace before use. | @@ -316,6 +320,8 @@ let closed = command.apply(&mut app.workspace, style.right_to_left, &space, |pan a new dock; when it already is that dock's last column, the command does nothing. - `window.panel.dock` with `left`, `right`, `top` or `bottom` and no `anchor` or `side` makes a new frame on that outer edge (`Placement::Edge`). A hidden panel is shown first. +- Showing a panel (`activate`, `dock` without a destination, `ui.dock` `open`) restores a hidden + panel's place; a panel never laid out opens as `Open` without an anchor does. - `window.panel.float` without a rect floats what it moves at the size a drag floats it at (see Floating size). It goes inside the dock area, 24 points from its top-left corner, so it stays clear of the menu bar. From 3be6c3d09dbb6ee6a4965e96d5818810c01dd1bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:57:23 +0000 Subject: [PATCH 141/160] jpeg: bound decoder memory and work Check coefficients, planes and output against max_alloc together. Stop an arithmetic scan once it has consumed more than 8 zero bytes past its data or misses a restart marker, and cap the decoded blocks at 64 passes over the frame instead of capping the scan count. --- crates/jpeg/src/lib.rs | 109 +++++++++++++++++++++++++++------------ crates/jpeg/src/tests.rs | 15 ++++-- 2 files changed, 88 insertions(+), 36 deletions(-) diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 88edd253..44c7673b 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -37,7 +37,7 @@ //! which also reads the APP14 `Adobe` segment and passes its transform byte to [`decode`]. //! //! The decoder never panics on input: every read from the stream is bounds-checked, the work -//! is bounded by the frame size times the scan count (capped at `MAX_SCANS`), and +//! is bounded by 64 passes over the frame's blocks (`SCAN_PASSES`), and //! allocations are checked against the [`Limits`] first. //! Damaged entropy-coded data stops that scan; what was decoded is kept and the caller is told //! through [`Decoded::damaged`]. @@ -386,17 +386,19 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result } let hmax = raw.iter().map(|c| c.1).max().unwrap_or(1); let vmax = raw.iter().map(|c| c.2).max().unwrap_or(1); - limits.check(width, height, nf as u64 * if precision > 8 { 2 } else { 1 })?; + let bytes_per_pixel = nf as u64 * if precision > 8 { 2 } else { 1 }; + limits.check(width, height, bytes_per_pixel)?; let mcux = width.div_ceil(8 * hmax); let mcuy = height.div_ceil(8 * vmax); - // Coefficients (4 bytes) and the reconstructed plane (4 bytes) per padded sample. - let mut internal: u64 = 0; + // `render` holds the coefficients (4 bytes) and the reconstructed plane (4 bytes) of every + // padded sample while it writes the output, so all three count against `max_alloc` together. + let mut total = (width as u64 * height as u64).saturating_mul(bytes_per_pixel); for &(_, h, v, _) in &raw { let samples = (mcux * h * 8) as u64 * (mcuy * v * 8) as u64; - internal = internal.saturating_add(samples.saturating_mul(8)); + total = total.saturating_add(samples.saturating_mul(8)); } - if internal > limits.max_alloc { - return Err(Error::LimitExceeded(format!("{internal} bytes of decoder buffers exceed max_alloc {}", limits.max_alloc))); + if total > limits.max_alloc { + return Err(Error::LimitExceeded(format!("{total} bytes of decoder buffers and output exceed max_alloc {}", limits.max_alloc))); } let mut comps = Vec::with_capacity(nf); for (id, h, v, tq) in raw { @@ -426,21 +428,22 @@ fn scan_end(b: &[u8], start: usize) -> usize { b.len() } -/// Past the next restart marker at or after `pos` (or at the next other marker, or the end). -fn skip_restart(d: &[u8], mut pos: usize) -> usize { +/// Past the next restart marker at or after `pos`, and `true`; or at the next other marker +/// (or the end) and `false`. +fn skip_restart(d: &[u8], mut pos: usize) -> (usize, bool) { while let Some(&v) = d.get(pos) { if v != 0xFF { pos += 1; continue; } match d.get(pos + 1) { - Some(0xD0..=0xD7) => return pos + 2, + Some(0xD0..=0xD7) => return (pos + 2, true), Some(0x00) => pos += 2, Some(0xFF) => pos += 1, - _ => return pos, + _ => return (pos, false), } } - pos + (pos, false) } /// Next data byte for the decoders: `FF 00` is a data `FF`; at a marker (or the end) zeros @@ -519,7 +522,7 @@ impl<'a> Bits<'a> { } fn restart(&mut self) { - self.pos = skip_restart(self.d, self.pos); + self.pos = skip_restart(self.d, self.pos).0; self.acc = 0; self.n = 0; self.pad = 0; @@ -581,16 +584,41 @@ struct Arith<'a> { c: u32, a: u32, ct: u32, + /// Zero bytes fed in since the start or the last restart because the data had ended. + pad: u32, + /// A restart was due but its marker was missing. + lost: bool, } -impl Arith<'_> { +impl<'a> Arith<'a> { + /// The most zero bytes a scan or restart interval may consume past the end of its data. + /// The decoder reads two bytes ahead (INITDEC), and encoders drop trailing zero bytes + /// (libjpeg's `finish_pass` does, as D.1.8 allows), so a valid stream consumes a few. + /// Past that the data was cut short, and the decoder would only decode the zeros. + const MAX_PAD: u32 = 8; + + fn new(d: &'a [u8]) -> Self { + let mut ar = Arith { d, pos: 0, c: 0, a: 0, ct: 0, pad: 0, lost: false }; + ar.init(); + ar + } + fn byte_in(&mut self) { - let v = next_byte(self.d, &mut self.pos).unwrap_or(0); + let v = next_byte(self.d, &mut self.pos).unwrap_or_else(|| { + self.pad = self.pad.saturating_add(1); + 0 + }); self.c = self.c.wrapping_add(u32::from(v) << 8); } + /// The scan's data ran out, or a restart marker is missing. + fn exhausted(&self) -> bool { + self.lost || self.pad > Self::MAX_PAD + } + /// INITDEC (D.2.7). fn init(&mut self) { + self.pad = 0; self.a = 0x10000; self.c = 0; self.byte_in(); @@ -650,7 +678,9 @@ impl Arith<'_> { } fn restart(&mut self) { - self.pos = skip_restart(self.d, self.pos); + let (pos, found) = skip_restart(self.d, self.pos); + self.pos = pos; + self.lost |= !found; self.init(); } } @@ -905,6 +935,9 @@ impl ScanDecoder for ArithScan<'_> { ArithMode::AcFirst => self.ac_first(ta, blk)?, ArithMode::AcRefine => self.ac_refine(ta, blk)?, } + if self.ar.exhausted() { + return Err(err("entropy-coded data ends before the scan does")); + } Ok(()) } } @@ -918,16 +951,25 @@ impl ArithScan<'_> { } } +/// The scan's MCU grid (A.2): MCUs per line, MCU lines, and the blocks the scan decodes. +fn scan_grid(frame: &Frame, scan: &Scan) -> Result<(usize, usize, usize), Error> { + if let [(ci, _, _)] = scan.comps[..] { + let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; + let (mcux, mcuy) = (c.cw.div_ceil(8), c.ch.div_ceil(8)); + return Ok((mcux, mcuy, mcux.saturating_mul(mcuy))); + } + let mut per_mcu = 0usize; + for &(ci, _, _) in &scan.comps { + let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; + per_mcu += c.h * c.v; + } + Ok((frame.mcux, frame.mcuy, frame.mcux.saturating_mul(frame.mcuy).saturating_mul(per_mcu))) +} + /// Walk the scan's MCUs in order (A.2), handing each block to `dec`; counts decoded MCUs. fn walk_scan(frame: &mut Frame, scan: &Scan, restart: usize, dec: &mut dyn ScanDecoder, done: &mut usize) -> Result<(), Error> { let single = scan.comps.len() == 1; - let (mcux, mcuy) = if single { - let &(ci, _, _) = scan.comps.first().ok_or_else(|| err("empty scan"))?; - let c = frame.comps.get(ci).ok_or_else(|| err("bad scan component"))?; - (c.cw.div_ceil(8), c.ch.div_ceil(8)) - } else { - (frame.mcux, frame.mcuy) - }; + let (mcux, mcuy, _) = scan_grid(frame, scan)?; let total = mcux.saturating_mul(mcuy); for m in 0..total { if restart > 0 && m > 0 && m % restart == 0 { @@ -990,7 +1032,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: (true, _, _) => ArithMode::AcRefine, }; let mut dec = ArithScan { - ar: Arith { d, pos: 0, c: 0, a: 0, ct: 0 }, + ar: Arith::new(d), mode, ss: scan.ss, se: scan.se, @@ -1004,7 +1046,6 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: pred: [0; 4], ctx: [0; 4], }; - dec.ar.init(); walk_scan(frame, scan, tables.restart, &mut dec, done) } else { let mut sel = Vec::with_capacity(scan.comps.len()); @@ -1018,11 +1059,12 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: } } -/// The most scans decoded from one stream; later ones are dropped and the image is reported -/// damaged. Every scan walks its whole MCU grid, and an arithmetic-coded scan does so even with -/// no data (the decoder is fed zeros, D.2.6), so without a cap a small file repeating empty -/// scan headers multiplies the work by the frame size. Real files have a few dozen at most. -const MAX_SCANS: usize = 500; +/// The blocks decoded from one stream are capped at this many times the frame's blocks; the +/// scan that would pass the cap and every later one are dropped, and the image is reported +/// damaged. Every scan walks its whole MCU grid, so without a cap a small file repeating scan +/// headers multiplies the work by the frame size. 64 passes allow a progressive file one scan +/// per coefficient for every component; real files make about 10. +const SCAN_PASSES: usize = 64; /// Parse the stream and decode every scan into coefficients. fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool), Error> { @@ -1034,6 +1076,7 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool let mut damaged = false; let mut mcus = 0usize; let mut scans = 0usize; + let mut blocks = 0usize; let mut i = 2; while let Some(&v) = b.get(i) { if v != 0xFF { @@ -1079,11 +1122,13 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool } 0xDA => { let f = frame.as_mut().ok_or_else(|| err("scan before the frame header"))?; - if scans >= MAX_SCANS { + let scan = parse_scan(seg, f)?; + let budget = f.comps.iter().map(|c| c.coefs.len()).sum::().saturating_mul(SCAN_PASSES); + blocks = blocks.saturating_add(scan_grid(f, &scan)?.2); + if blocks > budget { damaged = true; break; } - let scan = parse_scan(seg, f)?; let end = scan_end(b, i); let data = b.get(i..end).unwrap_or_default(); scans += 1; diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 3929e643..44c10a6b 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -530,12 +530,12 @@ fn malformed_streams_return_errors_not_panics() { assert!(decode_default(&streams[0][..40]).is_err()); } -/// Scans past `MAX_SCANS` aren't decoded (each one walks the whole MCU grid, with or without -/// data): the image keeps what the earlier scans decoded and is reported damaged. +/// Scans past the block budget (`SCAN_PASSES` passes over a one-block frame) aren't decoded: +/// the image keeps what the earlier scans decoded and is reported damaged. #[test] -fn scans_past_the_limit_are_not_decoded() { +fn scans_past_the_block_budget_are_not_decoded() { let blocks = coefficients(1, 3, 500); - for (count, damaged) in [(MAX_SCANS, false), (MAX_SCANS + 1, true)] { + for (count, damaged) in [(SCAN_PASSES, false), (SCAN_PASSES + 1, true)] { let stream = arith_stream(8, 1, 1, &blocks, &vec![(0, 0, 0, 0); count], 0); let (frame, _, d) = decode_coefficients(&stream, &Limits::default()).unwrap(); assert_eq!(d, damaged, "{count} scans"); @@ -549,6 +549,13 @@ fn limits_are_checked_before_allocating() { segment(&mut s, 0xC9, &[8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x44, 0, 2, 0x11, 0, 3, 0x11, 0]); let small = Limits { max_alloc: 1 << 20, ..Limits::default() }; assert!(matches!(decode(&s, &small, None), Err(Error::LimitExceeded(_)))); + // 64×64 gray: 32 KiB of coefficients and plane plus 4 KiB of output, each under the limit + // alone but not together. + let stream = arith_stream(8, 8, 8, &coefficients(64, 1, 100), &[(0, 63, 0, 0)], 0); + for (max_alloc, ok) in [(36 << 10, true), ((36 << 10) - 1, false), (32 << 10, false)] { + let limits = Limits { max_alloc, ..Limits::default() }; + assert_eq!(decode(&stream, &limits, None).is_ok(), ok, "max_alloc {max_alloc}"); + } } #[test] From 3acc132ff03fa04b36f2b635369716fcbcd499de Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:00:51 +0000 Subject: [PATCH 142/160] jpeg: mutation test, fuzz target and CI workflow tests/mutation.rs runs a seeded mutation pass over the test images and small SOF9, SOF10 and 12-bit seeds, checking that decode never panics and stays within small limits. fuzz/ is a cargo-fuzz crate (own workspace) with a decode target and those seeds. jpeg.yml runs fmt, clippy, tests and a wasm32 check on Linux. The README gains a testing section. --- .github/workflows/jpeg.yml | 37 ++++++ crates/jpeg/README.md | 32 +++++ crates/jpeg/fuzz/.gitignore | 4 + crates/jpeg/fuzz/Cargo.toml | 23 ++++ crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg | Bin 0 -> 138 bytes .../fuzz/corpus/decode/sof10-progressive.jpg | Bin 0 -> 394 bytes crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg | Bin 0 -> 206 bytes .../jpeg/fuzz/corpus/decode/sof9-restart.jpg | Bin 0 -> 430 bytes .../fuzz/corpus/decode/testimgari-huffman.jpg | Bin 0 -> 5756 bytes crates/jpeg/fuzz/corpus/decode/testimgari.jpg | Bin 0 -> 5126 bytes crates/jpeg/fuzz/corpus/decode/testorig.jpg | Bin 0 -> 5770 bytes crates/jpeg/fuzz/corpus/decode/testorig12.jpg | Bin 0 -> 12394 bytes crates/jpeg/fuzz/fuzz_targets/decode.rs | 23 ++++ crates/jpeg/tests/mutation.rs | 125 ++++++++++++++++++ 14 files changed, 244 insertions(+) create mode 100644 .github/workflows/jpeg.yml create mode 100644 crates/jpeg/fuzz/.gitignore create mode 100644 crates/jpeg/fuzz/Cargo.toml create mode 100644 crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/sof10-progressive.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testimgari-huffman.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testimgari.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig12.jpg create mode 100644 crates/jpeg/fuzz/fuzz_targets/decode.rs create mode 100644 crates/jpeg/tests/mutation.rs diff --git a/.github/workflows/jpeg.yml b/.github/workflows/jpeg.yml new file mode 100644 index 00000000..d10c2598 --- /dev/null +++ b/.github/workflows/jpeg.yml @@ -0,0 +1,37 @@ +name: Shared JPEG + +on: + push: + branches: [main] + paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"] + pull_request: + paths: ["crates/jpeg/**", "Cargo.toml", "Cargo.lock", ".github/workflows/jpeg.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: shared-jpeg-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + timeout-minutes: 10 + name: JPEG lint, tests, WASM + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-jpeg + - run: cargo fmt -p craft-jpeg --check + - run: cargo clippy -p craft-jpeg --all-targets --locked -- -D warnings + - run: cargo test -p craft-jpeg --locked + - run: cargo check -p craft-jpeg --locked --target wasm32-unknown-unknown diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 661be5aa..44eaee7c 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -95,6 +95,38 @@ SOF11 (lossless) falls in that range and returns `Unsupported`. XMP, Photoshop resources, resolution and the APP14 `Adobe` segment are the app's to read. Color management and encoding also stay in the apps. +## Testing + +`cargo test -p craft-jpeg` runs: + +- unit tests in `src/tests.rs`: arithmetic-coded streams from a test-only encoder must decode to + the coefficients that went in, and Huffman streams are compared against `zune-jpeg`. +- `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how + the derived files were made: `tests/data/SOURCE.md`): + - `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode. + - `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit + decode, within the float-against-integer IDCT difference (mean under 0.75, max 4). +- `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header + overwrites, deleted and repeated spans) over the test images and the fuzz seeds. Decoding must + not panic and must stay within small `Limits`. + +`.github/workflows/jpeg.yml` runs fmt, clippy, the tests and a `wasm32-unknown-unknown` check on +Linux. + +### Fuzzing + +`fuzz/` is a cargo-fuzz crate outside the workspace, with one target, `decode`. Its seeds in +`fuzz/corpus/decode` are the test images plus small SOF9 (with restarts), 12-bit SOF9, SOF10 +and 12-bit SOF1 streams. With nightly and `cargo install cargo-fuzz`: + +```sh +cd crates/jpeg/fuzz +cargo +nightly fuzz run decode +``` + +Running it adds the inputs it finds to `fuzz/corpus/decode`; commit only seeds that cover +something new. + ## Credits The decoder was written by Michael Higgins (Higgs32584) for PhotoCraft. diff --git a/crates/jpeg/fuzz/.gitignore b/crates/jpeg/fuzz/.gitignore new file mode 100644 index 00000000..d02230c0 --- /dev/null +++ b/crates/jpeg/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +artifacts +coverage +Cargo.lock diff --git a/crates/jpeg/fuzz/Cargo.toml b/crates/jpeg/fuzz/Cargo.toml new file mode 100644 index 00000000..c4881e66 --- /dev/null +++ b/crates/jpeg/fuzz/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "craft-jpeg-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +craft-jpeg = { path = ".." } + +# Its own workspace: cargo-fuzz needs nightly and sanitizer flags, so this crate stays out of the +# craft-libs workspace and its lockfile. +[workspace] + +[[bin]] +name = "decode" +path = "fuzz_targets/decode.rs" +test = false +doc = false +bench = false diff --git a/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof1-12bit.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7c0e6860918b8dfacc83e92890446d7b324e755f GIT binary patch literal 138 zcmex==J%f(-wUFsLywGN1xp0dx_D|F=Nu85ryt${GLP F1OPx2452jZ5UintrRQqGqBzNJJ!7N3(-kQmRyCr76 z0vhMpkX^R;pY)rg*xK8!`*lrcUbL$Cc`GHYPy5V^-kf_Me^;`dd^zdsWMSTz9q)F{ zNbC5*(s2Ri07h2D%n3kC?pFX!3WA9;FbFgN1sQ;1KpNsrMpl6)pjawU;9@LHkj-8| u@#Nx%HUSI_>_ACh%f0>vlTS+M%K#PSzP=pK5p}0)GIQ?skbeySZvp_H>5dEl literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-12bit.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dd0531825b1f532328bf04ec4676e82d840254d4 GIT binary patch literal 206 zcmex=o+4GBDUP{Ac*v64oim_13x4m8(4A{x+L^thbSD|%7u9&Sw$)b-!zk<`ELmPkcTPsao@c$+N DZS+FX literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg b/crates/jpeg/fuzz/corpus/decode/sof9-restart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c165ab4678b17a758fbbb2c49749ba4895083983 GIT binary patch literal 430 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 S25o&-SUN00f`hCtCjSG6MZ^UF literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/testimgari.jpg b/crates/jpeg/fuzz/corpus/decode/testimgari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..896648741b9b512b2f1f8e2ef0471a88ee0e6ca5 GIT binary patch literal 5126 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 literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12.jpg new file mode 100644 index 0000000000000000000000000000000000000000..861aff98e2a78131bf546b82fd38b00a4f741dd2 GIT binary patch literal 12394 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 Iua1WP4;iQWivR!s literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/fuzz_targets/decode.rs b/crates/jpeg/fuzz/fuzz_targets/decode.rs new file mode 100644 index 00000000..b03dbfe9 --- /dev/null +++ b/crates/jpeg/fuzz/fuzz_targets/decode.rs @@ -0,0 +1,23 @@ +#![no_main] + +use craft_jpeg::{Limits, Samples, decode, frame}; +use libfuzzer_sys::fuzz_target; + +const LIMITS: Limits = Limits { max_pixels: 1 << 20, max_alloc: 64 << 20 }; + +fuzz_target!(|data: &[u8]| { + let _ = frame(data); + // The input length picks the Adobe transform, so every color path is reached without + // changing the stream. + let adobe = [None, Some(0), Some(1), Some(2)][data.len() % 4]; + if let Ok(img) = decode(data, &LIMITS, adobe) { + let pixels = u64::from(img.width) * u64::from(img.height); + let (len, width) = match &img.samples { + Samples::U8(v) => (v.len() as u64, 1), + Samples::U16(v) => (v.len() as u64, 2), + }; + assert!(pixels <= LIMITS.max_pixels); + assert!(len * width <= LIMITS.max_alloc); + assert_eq!(len, pixels * u64::from(img.components)); + } +}); diff --git a/crates/jpeg/tests/mutation.rs b/crates/jpeg/tests/mutation.rs new file mode 100644 index 00000000..a23a706b --- /dev/null +++ b/crates/jpeg/tests/mutation.rs @@ -0,0 +1,125 @@ +//! Deterministic mutation run over the test images and fuzz seeds: decoding corrupted input never panics and +//! stays within the caller's limits. + +use craft_jpeg::{Limits, Samples, decode, frame}; + +/// Inputs and mutation counts. The 227×149 test images take tens of milliseconds each to decode +/// in a debug build; the small fuzz seeds (SOF9 with restarts, 12-bit SOF9, SOF10, 12-bit SOF1) +/// take microseconds and get most of the cases. +const INPUTS: [(&str, usize); 8] = [ + ("tests/data/testorig.jpg", 12), + ("tests/data/testimgari.jpg", 12), + ("tests/data/testimgari-huffman.jpg", 12), + ("tests/data/testorig12.jpg", 12), + ("fuzz/corpus/decode/sof9-restart.jpg", 75), + ("fuzz/corpus/decode/sof9-12bit.jpg", 75), + ("fuzz/corpus/decode/sof10-progressive.jpg", 75), + ("fuzz/corpus/decode/sof1-12bit.jpg", 75), +]; + +/// Room for the 227×149 fixtures, too little for a mutated frame header that grows them much. +const LIMITS: Limits = Limits { max_pixels: 1 << 16, max_alloc: 1 << 20 }; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + // xorshift64* + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + + fn byte(&mut self) -> u8 { + self.next() as u8 + } +} + +/// Offset of the first SOFn segment's body, if any. +fn sof_body(b: &[u8]) -> Option { + (2..b.len().saturating_sub(1)) + .find(|&i| b[i] == 0xFF && matches!(b[i + 1], 0xC0..=0xCF) && !matches!(b[i + 1], 0xC4 | 0xC8 | 0xCC)) + .map(|i| i + 4) +} + +fn mutate(rng: &mut Rng, original: &[u8]) -> Vec { + let mut b = original.to_vec(); + match rng.below(6) { + 0 => b.truncate(rng.below(original.len())), + 1 => { + for _ in 0..1 + rng.below(8) { + let at = rng.below(b.len()); + b[at] ^= 1 << rng.below(8); + } + } + 2 => { + for _ in 0..1 + rng.below(4) { + let at = rng.below(b.len()); + b[at] = rng.byte(); + } + } + 3 => { + // Frame header fields: precision, height, width, component count and the + // per-component sampling and table selectors. + if let Some(body) = sof_body(&b) { + for _ in 0..1 + rng.below(3) { + let at = body + rng.below(15); + if let Some(v) = b.get_mut(at) { + *v = rng.byte(); + } + } + } + } + 4 => { + let at = rng.below(b.len()); + let len = rng.below(64).min(b.len() - at); + b.drain(at..at + len); + } + _ => { + // Repeat a span, e.g. a marker segment or part of the entropy-coded data. + let at = rng.below(b.len()); + let len = rng.below(256).min(b.len() - at); + let span = b[at..at + len].to_vec(); + let to = rng.below(b.len()); + b.splice(to..to, span); + } + } + b +} + +#[test] +fn mutated_streams_never_panic_and_stay_within_limits() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let (mut ok, mut errors) = (0, 0); + for (name, cases) in INPUTS { + let path = format!("{}/{name}", env!("CARGO_MANIFEST_DIR")); + let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + assert!(decode(&original, &LIMITS, None).is_ok(), "{name} must decode within the test limits"); + for case in 0..cases { + let bytes = mutate(&mut rng, &original); + let _ = frame(&bytes); + match decode(&bytes, &LIMITS, None) { + Ok(img) => { + let pixels = u64::from(img.width) * u64::from(img.height); + let (len, width) = match &img.samples { + Samples::U8(v) => (v.len() as u64, 1), + Samples::U16(v) => (v.len() as u64, 2), + }; + assert!(pixels <= LIMITS.max_pixels, "{name} case {case}: {pixels} pixels"); + assert!(len * width <= LIMITS.max_alloc, "{name} case {case}: {} bytes", len * width); + assert_eq!(len, pixels * u64::from(img.components), "{name} case {case}"); + ok += 1; + } + Err(_) => errors += 1, + } + } + } + eprintln!("{ok} decoded, {errors} rejected"); + // A mutation run that only produces errors would no longer reach the entropy decoders. + assert!(ok > 50 && errors > 50, "{ok} decoded, {errors} rejected"); +} From b636dad56e15ffc5df5a3ee8d7892257bedff140 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:03:28 +0000 Subject: [PATCH 143/160] jpeg: read the reference PPM with as_chunks --- crates/jpeg/tests/conformance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/jpeg/tests/conformance.rs b/crates/jpeg/tests/conformance.rs index 2e81620f..3e449263 100644 --- a/crates/jpeg/tests/conformance.rs +++ b/crates/jpeg/tests/conformance.rs @@ -73,7 +73,7 @@ fn ppm16(bytes: &[u8]) -> (u32, u32, u32, Vec) { let num = |s: &str| s.parse::().unwrap_or_else(|e| panic!("PPM header {s}: {e}")); let (w, h, maxval) = (num(&fields[1]), num(&fields[2]), num(&fields[3])); let data = &bytes[at + 1..]; - let px: Vec = data.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + let px: Vec = data.as_chunks::<2>().0.iter().map(|&c| u16::from_be_bytes(c)).collect(); assert_eq!(px.len(), (w * h * 3) as usize); (w, h, maxval, px) } From cb28b0c19476aa58fa20dd4ea4512216a8b21b74 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:04:15 +0000 Subject: [PATCH 144/160] jpeg: use the quantization table in force at a component's first scan The test encoder takes several components, scans of any of them, segments between scans and per-table conditioning, and drops trailing zero bytes as libjpeg does. --- crates/jpeg/src/lib.rs | 30 +++- crates/jpeg/src/tests.rs | 286 ++++++++++++++++++++++++++++++--------- 2 files changed, 249 insertions(+), 67 deletions(-) diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 44c7673b..2a4cdcc7 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -260,6 +260,10 @@ struct Component { bh: usize, /// Quantized coefficients, one block per entry, in zigzag order. coefs: Vec<[i32; 64]>, + /// Table `tq` as it was at the component's first scan. B.2.2 lets a DQT redefine the slot + /// once every scan of the component is done; libjpeg latches it the same way + /// (`latch_quant_tables`). + quant: Option<[u16; 64]>, } struct Frame { @@ -404,7 +408,18 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result for (id, h, v, tq) in raw { let (bw, bh) = (mcux * h, mcuy * v); let blocks = bw.checked_mul(bh).ok_or_else(|| Error::LimitExceeded("too many blocks".into()))?; - comps.push(Component { id, h, v, tq, cw: (width * h).div_ceil(hmax), ch: (height * v).div_ceil(vmax), bw, bh, coefs: vec![[0; 64]; blocks] }); + comps.push(Component { + id, + h, + v, + tq, + cw: (width * h).div_ceil(hmax), + ch: (height * v).div_ceil(vmax), + bw, + bh, + coefs: vec![[0; 64]; blocks], + quant: None, + }); } Ok(Frame { arithmetic, progressive, precision, width, height, hmax, vmax, mcux, mcuy, comps }) } @@ -1023,6 +1038,13 @@ fn parse_scan(seg: &[u8], frame: &Frame) -> Result { /// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`. fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), Error> { + for &(ci, _, _) in &scan.comps { + let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?; + if comp.quant.is_none() { + let q = tables.quant.get(comp.tq).copied().flatten().ok_or_else(|| err("scan uses an undefined quantization table"))?; + comp.quant = Some(q); + } + } if frame.arithmetic { let mode = match (frame.progressive, scan.ss, scan.ah) { (false, _, _) => ArithMode::Sequential, @@ -1217,8 +1239,10 @@ fn render(frame: Frame, tables: &Tables, adobe_transform: Option) -> Result< let (w, h) = (frame.width, frame.height); let mut planes = Vec::with_capacity(frame.comps.len()); for comp in &frame.comps { - let q = tables.quant.get(comp.tq).and_then(Option::as_ref).ok_or_else(|| err("component uses an undefined quantization table"))?; - let plane = reconstruct(comp, q, &basis, max); + // A component no scan reached has only zero coefficients; any defined table will do. + let q = comp.quant.or_else(|| tables.quant.get(comp.tq).copied().flatten()); + let q = q.ok_or_else(|| err("component uses an undefined quantization table"))?; + let plane = reconstruct(comp, &q, &basis, max); let xs = upsample_map(w, comp.cw, comp.h, frame.hmax); let ys = upsample_map(h, comp.ch, comp.v, frame.vmax); planes.push((plane, comp.bw * 8, xs, ys)); diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 44c10a6b..7f63cbfe 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -122,31 +122,49 @@ impl Enc { for _ in 0..self.stacked { self.emit(0xFF); } + // Trailing zero bytes are dropped, as libjpeg's encoder does; the decoder supplies + // zeros at the marker. A zero right after `FF` is stuffing and stays. + while self.out.last() == Some(&0) && self.out.len().checked_sub(2).map(|i| self.out[i]) != Some(0xFF) { + self.out.pop(); + } self.out } } +/// Arithmetic conditioning of one entropy table: DC (L, U) and AC Kx (F.1.4.4). +#[derive(Clone, Copy, PartialEq, Eq)] +struct Cond { + l: u8, + u: u8, + kx: u8, +} + +/// The conditioning in force without a DAC segment (B.2.4.3). +const DEFAULT_COND: Cond = Cond { l: 0, u: 1, kx: 5 }; + +/// Statistics per entropy table, DC prediction and context per scan component. struct EncScan { - dc: [u8; 64], - ac: [u8; 256], - ctx: usize, - pred: i32, + dc: [[u8; 64]; 4], + ac: [[u8; 256]; 4], + ctx: [usize; 4], + pred: [i32; 4], + cond: [Cond; 4], } impl EncScan { - fn new() -> Self { - EncScan { dc: [0; 64], ac: [0; 256], ctx: 0, pred: 0 } + fn new(cond: [Cond; 4]) -> Self { + EncScan { dc: [[0; 64]; 4], ac: [[0; 256]; 4], ctx: [0; 4], pred: [0; 4], cond } } - /// Encode_DC_DIFF with the default conditioning (L = 0, U = 1). - fn dc(&mut self, e: &mut Enc, value: i32) { - let diff = value - self.pred; - self.pred = value; - let st = &mut self.dc; - let s0 = self.ctx; + /// Encode_DC_DIFF of scan component `sc` with DC table `t`. + fn dc(&mut self, e: &mut Enc, sc: usize, t: usize, value: i32) { + let diff = value - self.pred[sc]; + self.pred[sc] = value; + let st = &mut self.dc[t]; + let s0 = self.ctx[sc]; if diff == 0 { e.code(&mut st[s0], 0); - self.ctx = 0; + self.ctx[sc] = 0; return; } e.code(&mut st[s0], 1); @@ -154,11 +172,12 @@ impl EncScan { e.code(&mut st[s0 + 1], sign); let sp = s0 + 2 + usize::from(sign); let sz = diff.unsigned_abs() - 1; + let mut m = 0u32; if sz == 0 { e.code(&mut st[sp], 0); } else { e.code(&mut st[sp], 1); - let mut m = 1u32; + m = 1; let mut s = 20; while m << 1 <= sz { e.code(&mut st[s], 1); @@ -172,13 +191,23 @@ impl EncScan { bit >>= 1; } } - let mag = diff.unsigned_abs(); - self.ctx = if mag <= 2 { 4 + 4 * usize::from(sign) } else { 12 + 4 * usize::from(sign) }; + // Conditioning category as libjpeg's jcarith.c computes it, from the magnitude + // category `m` rather than the difference. + let Cond { l, u, .. } = self.cond[t]; + self.ctx[sc] = if m < (1 << l) >> 1 { + 0 + } else if m > (1 << u) >> 1 { + 12 + 4 * usize::from(sign) + } else { + 4 + 4 * usize::from(sign) + }; } - /// Encode_AC_coefficients over `ss..=se` of the coefficients scaled down by `al` (Kx = 5). - fn ac(&mut self, e: &mut Enc, blk: &[i32; 64], ss: usize, se: usize, al: u8) { - let st = &mut self.ac; + /// Encode_AC_coefficients with AC table `t` over `ss..=se` of the coefficients scaled down + /// by `al`. + fn ac(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) { + let kx = usize::from(self.cond[t].kx); + let st = &mut self.ac[t]; let v = |k: usize| if blk[k] < 0 { -((-blk[k]) >> al) } else { blk[k] >> al }; let last = (ss..=se).rev().find(|&k| v(k) != 0); let mut k = ss; @@ -205,7 +234,7 @@ impl EncScan { } else { e.code(&mut st[sp], 1); let mut m = 2u32; - let mut s = if k <= 5 { 189 } else { 217 }; + let mut s = if k <= kx { 189 } else { 217 }; while m << 1 <= sz { e.code(&mut st[s], 1); m <<= 1; @@ -223,9 +252,9 @@ impl EncScan { } } - /// Successive approximation of AC coefficients: bit `al` of each (G.1.3.3). - fn ac_refine(&mut self, e: &mut Enc, blk: &[i32; 64], ss: usize, se: usize, al: u8) { - let st = &mut self.ac; + /// Successive approximation of AC coefficients with table `t`: bit `al` of each (G.1.3.3). + fn ac_refine(&mut self, e: &mut Enc, t: usize, blk: &[i32; 64], ss: usize, se: usize, al: u8) { + let st = &mut self.ac[t]; let prior = |k: usize| blk[k].unsigned_abs() >> (al + 1) != 0; let now = |k: usize| blk[k].unsigned_abs() >> al != 0; let eob_prev = (ss..=se).rev().find(|&k| prior(k)).map_or(ss, |k| k + 1); @@ -293,51 +322,149 @@ fn segment(out: &mut Vec, marker: u8, body: &[u8]) { out.extend_from_slice(body); } +/// A component of a test frame: id, sampling factors, quantization table slot, and the +/// coefficients of its block grid padded to whole MCUs, row-major. +struct TestComp { + id: u8, + h: usize, + v: usize, + tq: u8, + blocks: Vec<[i32; 64]>, +} + +/// A scan of a test frame: frame component indexes, `(ss, se, ah, al)`, and segments +/// written just before its header. +struct TestScan { + comps: Vec, + band: (usize, usize, u8, u8), + before: Vec, +} + +/// An arithmetic-coded frame (SOF9, or SOF10 when `progressive`). Component `ci` uses entropy +/// tables `min(ci, 1)`, as libjpeg's defaults do; a DAC segment is written for the tables +/// whose `cond` isn't the default, and quantization slot 0 holds all ones until a scan's +/// `before` replaces it. +struct TestFrame { + precision: u8, + progressive: bool, + width: usize, + height: usize, + comps: Vec, + cond: [Cond; 4], + ri: usize, + scans: Vec, +} + +impl TestFrame { + fn encode(&self) -> Vec { + let mut s = vec![0xFF, 0xD8]; + let mut dqt = vec![0u8]; + dqt.extend([1u8; 64]); + segment(&mut s, 0xDB, &dqt); + let mut sof = vec![self.precision]; + sof.extend((self.height as u16).to_be_bytes()); + sof.extend((self.width as u16).to_be_bytes()); + sof.push(self.comps.len() as u8); + for c in &self.comps { + sof.extend([c.id, (c.h << 4 | c.v) as u8, c.tq]); + } + segment(&mut s, if self.progressive { 0xCA } else { 0xC9 }, &sof); + let mut dac = Vec::new(); + for (t, c) in self.cond.iter().enumerate().filter(|(_, c)| **c != DEFAULT_COND) { + dac.extend([t as u8, c.l | c.u << 4, 0x10 | t as u8, c.kx]); + } + if !dac.is_empty() { + segment(&mut s, 0xCC, &dac); + } + if self.ri > 0 { + segment(&mut s, 0xDD, &(self.ri as u16).to_be_bytes()); + } + let hmax = self.comps.iter().map(|c| c.h).max().unwrap(); + let vmax = self.comps.iter().map(|c| c.v).max().unwrap(); + let mcux = self.width.div_ceil(8 * hmax); + let mcuy = self.height.div_ceil(8 * vmax); + for scan in &self.scans { + s.extend(&scan.before); + let (ss, se, ah, al) = scan.band; + let mut sos = vec![scan.comps.len() as u8]; + for &ci in &scan.comps { + let t = ci.min(1) as u8; + sos.extend([self.comps[ci].id, t << 4 | t]); + } + sos.extend([ss as u8, se as u8, ah << 4 | al]); + segment(&mut s, 0xDA, &sos); + // Blocks in coding order (A.2): `(scan component, block)` for each MCU. + let mut mcus: Vec> = Vec::new(); + if let [ci] = scan.comps[..] { + let c = &self.comps[ci]; + let bw = mcux * c.h; + let (cw, ch) = ((self.width * c.h).div_ceil(hmax), (self.height * c.v).div_ceil(vmax)); + for by in 0..ch.div_ceil(8) { + for bx in 0..cw.div_ceil(8) { + mcus.push(vec![(0, &c.blocks[by * bw + bx])]); + } + } + } else { + for my in 0..mcuy { + for mx in 0..mcux { + let mut mcu = Vec::new(); + for (sc, &ci) in scan.comps.iter().enumerate() { + let c = &self.comps[ci]; + for by in my * c.v..(my + 1) * c.v { + for bx in mx * c.h..(mx + 1) * c.h { + mcu.push((sc, &c.blocks[by * mcux * c.h + bx])); + } + } + } + mcus.push(mcu); + } + } + } + let mut e = Enc::new(); + let mut st = EncScan::new(self.cond); + let mut rst = 0u8; + for (n, mcu) in mcus.iter().enumerate() { + if self.ri > 0 && n > 0 && n % self.ri == 0 { + s.extend(std::mem::replace(&mut e, Enc::new()).finish()); + s.extend([0xFF, 0xD0 + rst]); + rst = (rst + 1) & 7; + st = EncScan::new(self.cond); + } + for &(sc, blk) in mcu { + let t = scan.comps[sc].min(1); + match (self.progressive, ss, ah) { + (false, _, _) => { + st.dc(&mut e, sc, t, blk[0]); + st.ac(&mut e, t, blk, 1, 63, 0); + } + (true, 0, 0) => st.dc(&mut e, sc, t, blk[0] >> al), + (true, 0, _) => e.code_fixed(((blk[0] >> al) & 1) as u8), + (true, _, 0) => st.ac(&mut e, t, blk, ss, se, al), + (true, _, _) => st.ac_refine(&mut e, t, blk, ss, se, al), + } + } + } + s.extend(e.finish()); + } + s.extend([0xFF, 0xD9]); + s + } +} + /// A gray SOF9 stream (SOF10 when there are several scans) holding `blocks` (`bw` × `bh`), /// with the scans `(ss, se, ah, al)` and restart interval `ri` (in MCUs). fn arith_stream(precision: u8, bw: usize, bh: usize, blocks: &[[i32; 64]], scans: &[(usize, usize, u8, u8)], ri: usize) -> Vec { - let progressive = scans.len() > 1; - let mut s = vec![0xFF, 0xD8]; - let mut dqt = vec![0u8]; - dqt.extend([1u8; 64]); - segment(&mut s, 0xDB, &dqt); - let (w, h) = ((bw * 8) as u16, (bh * 8) as u16); - let mut sof = vec![precision]; - sof.extend(h.to_be_bytes()); - sof.extend(w.to_be_bytes()); - sof.extend([1, 1, 0x11, 0]); - segment(&mut s, if progressive { 0xCA } else { 0xC9 }, &sof); - if ri > 0 { - segment(&mut s, 0xDD, &(ri as u16).to_be_bytes()); + TestFrame { + precision, + progressive: scans.len() > 1, + width: bw * 8, + height: bh * 8, + comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.to_vec() }], + cond: [DEFAULT_COND; 4], + ri, + scans: scans.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(), } - for &(ss, se, ah, al) in scans { - segment(&mut s, 0xDA, &[1, 1, 0x00, ss as u8, se as u8, (ah << 4) | al]); - let mut e = Enc::new(); - let mut st = EncScan::new(); - let mut rst = 0u8; - for (n, blk) in blocks.iter().enumerate() { - if ri > 0 && n > 0 && n % ri == 0 { - s.extend(std::mem::replace(&mut e, Enc::new()).finish()); - s.extend([0xFF, 0xD0 + rst]); - rst = (rst + 1) & 7; - st = EncScan::new(); - } - match (ss, ah) { - (0, 0) => { - st.dc(&mut e, blk[0] >> al); - if !progressive { - st.ac(&mut e, blk, 1, 63, 0); - } - } - (0, _) => e.code_fixed(((blk[0] >> al) & 1) as u8), - (_, 0) => st.ac(&mut e, blk, ss, se, al), - _ => st.ac_refine(&mut e, blk, ss, se, al), - } - } - s.extend(e.finish()); - } - s.extend([0xFF, 0xD9]); - s + .encode() } fn decoded_blocks(stream: &[u8]) -> Vec<[i32; 64]> { @@ -365,6 +492,37 @@ fn arithmetic_progressive_round_trips_coefficients() { } } +/// Each component dequantizes with the table in its slot at its first scan, not the one the +/// slot holds when the stream ends. +#[test] +fn quantization_tables_are_latched_at_the_first_scan() { + // DC-only blocks: sample = DC × q₀ / 8 + 128. + let mut blk = [0i32; 64]; + blk[0] = 40; + let dqt = |q0: u8| { + let mut body = vec![0u8, q0]; + body.extend([1u8; 63]); + let mut seg = Vec::new(); + segment(&mut seg, 0xDB, &body); + seg + }; + let stream = TestFrame { + precision: 8, + progressive: false, + width: 16, + height: 16, + comps: b"RGB".iter().map(|&id| TestComp { id, h: 1, v: 1, tq: 0, blocks: vec![blk; 4] }).collect(), + cond: [DEFAULT_COND; 4], + ri: 0, + scans: [2, 4, 8].iter().enumerate().map(|(ci, &q0)| TestScan { comps: vec![ci], band: (0, 63, 0, 0), before: dqt(q0) }).collect(), + } + .encode(); + let img = decode_default(&stream).unwrap(); + assert!(!img.damaged); + let Samples::U8(px) = &img.samples else { panic!("16-bit samples") }; + assert!(px.chunks(3).all(|p| p == [138, 148, 168]), "{:?}", &px[..3]); +} + #[test] fn arithmetic_twelve_bit_flat_block_is_sixteen_bit() { // DC only: sample = DC / 8 + 2048 with a quantizer of 1; 1000 → 2173. From 3ab100f46e26ca6c7f42607ecec04a3cea34562b Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:05:06 +0000 Subject: [PATCH 145/160] jpeg: test that truncated arithmetic data is reported damaged --- crates/jpeg/src/tests.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 7f63cbfe..05d46fde 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -620,6 +620,31 @@ fn huffman_scan_out_of_data_is_not_clean() { assert!(decode(&cut, &Limits::default(), None).unwrap().damaged); } +/// Arithmetic data cut short before a valid EOI is reported damaged, and the blocks before the +/// cut are kept: past the end the decoder is fed zeros, and a restart marker goes missing. +#[test] +fn arithmetic_scan_out_of_data_is_damaged() { + let blocks = coefficients(64, 11, 500); + for ri in [0, 2] { + let full = arith_stream(8, 8, 8, &blocks, &[(0, 63, 0, 0)], ri); + assert_eq!(decoded_blocks(&full), blocks, "ri={ri}"); + let data = last_scan_data(&full); + let end = full.len() - 2; + for cut in [data + (end - data) / 2, data] { + let mut s = full[..cut].to_vec(); + if s.last() == Some(&0xFF) { + s.pop(); + } + s.extend([0xFF, 0xD9]); + let (frame, _, damaged) = decode_coefficients(&s, &Limits::default()).unwrap(); + assert!(damaged, "ri={ri} cut at {}", cut - data); + if cut > data { + assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri}"); + } + } + } +} + /// A baseline file from `jpeg-encoder`, relabeled SOF1, decodes like zune decodes the original. #[test] fn extended_huffman_matches_zune_on_baseline_data() { From 059d1519a6c90ecb0064ffe39020311014ce760d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:06:07 +0000 Subject: [PATCH 146/160] jpeg: test DAC conditioning and interleaved subsampled arithmetic scans --- crates/jpeg/src/tests.rs | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 05d46fde..20cbbba6 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -492,6 +492,74 @@ fn arithmetic_progressive_round_trips_coefficients() { } } +/// Every component's coefficients, and whether the stream was damaged. +fn decoded_components(stream: &[u8]) -> (Vec>, bool) { + let (frame, _, damaged) = decode_coefficients(stream, &Limits::default()).unwrap(); + (frame.comps.into_iter().map(|c| c.coefs).collect(), damaged) +} + +/// Non-default DC conditioning (L, U) and AC Kx from a DAC segment round-trip, and the decoder +/// applies them: the same data read with the default conditioning comes out different. +#[test] +fn arithmetic_conditioning_from_dac_round_trips() { + let blocks = coefficients(12, 21, 1000); + for (l, u, kx) in [(2, 4, 2), (1, 1, 20), (0, 0, 1), (3, 7, 63), (5, 15, 40)] { + let cond = Cond { l, u, kx }; + for progressive in [false, true] { + let bands: &[(usize, usize, u8, u8)] = if progressive { &[(0, 0, 0, 0), (1, 63, 0, 0)] } else { &[(0, 63, 0, 0)] }; + let stream = TestFrame { + precision: 8, + progressive, + width: 32, + height: 24, + comps: vec![TestComp { id: 1, h: 1, v: 1, tq: 0, blocks: blocks.clone() }], + cond: [cond, DEFAULT_COND, DEFAULT_COND, DEFAULT_COND], + ri: 0, + scans: bands.iter().map(|&band| TestScan { comps: vec![0], band, before: Vec::new() }).collect(), + } + .encode(); + assert_eq!(decoded_blocks(&stream), blocks, "L={l} U={u} Kx={kx} progressive={progressive}"); + let dac = stream.windows(2).position(|p| p == [0xFF, 0xCC]).unwrap(); + let len = usize::from(u16::from_be_bytes([stream[dac + 2], stream[dac + 3]])); + let mut without = stream.clone(); + without.drain(dac..dac + 2 + len); + let differs = decode_coefficients(&without, &Limits::default()).map_or(true, |(f, _, _)| f.comps[0].coefs != blocks); + assert!(differs, "L={l} U={u} Kx={kx} progressive={progressive}: DAC ignored"); + } + } +} + +/// Interleaved scans of a subsampled frame (2×2 luma, two 1×1 chroma components sharing +/// entropy table 1 with non-default conditioning), sequential with and without restarts, and +/// progressive. +#[test] +fn arithmetic_interleaved_subsampled_round_trips() { + let comps = |seed: u32| { + vec![ + TestComp { id: 1, h: 2, v: 2, tq: 0, blocks: coefficients(8, seed, 800) }, + TestComp { id: 2, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 1, 300) }, + TestComp { id: 3, h: 1, v: 1, tq: 0, blocks: coefficients(2, seed + 2, 300) }, + ] + }; + let cond = [DEFAULT_COND, Cond { l: 1, u: 3, kx: 8 }, DEFAULT_COND, DEFAULT_COND]; + let scan = |comps: &[usize], band| TestScan { comps: comps.to_vec(), band, before: Vec::new() }; + let progressive = vec![ + scan(&[0, 1, 2], (0, 0, 0, 1)), + scan(&[0], (1, 63, 0, 0)), + scan(&[2], (1, 63, 0, 0)), + scan(&[1], (1, 63, 0, 0)), + scan(&[0, 1, 2], (0, 0, 1, 0)), + ]; + for (seed, ri, scans) in [(1, 0, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (2, 1, vec![scan(&[0, 1, 2], (0, 63, 0, 0))]), (3, 0, progressive)] { + let frame = TestFrame { precision: 8, progressive: scans.len() > 1, width: 32, height: 16, comps: comps(seed), cond, ri, scans }; + let (got, damaged) = decoded_components(&frame.encode()); + assert!(!damaged, "seed {seed}"); + for (c, want) in got.iter().zip(&frame.comps) { + assert_eq!(c, &want.blocks, "seed {seed} component {}", want.id); + } + } +} + /// Each component dequantizes with the table in its slot at its first scan, not the one the /// slot holds when the stream ends. #[test] From 36db71c5c30dd9e7b0d7f0838746aabb16d7ec6a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:13:00 -0400 Subject: [PATCH 147/160] docking: floating windows stay in the app window on Wayland (#292) * docking: floating windows stay in the app window on Wayland The compositor reports no window positions, so native windows could not dock again by drag or reopen where they were. The dock area detects Wayland (WAYLAND_DISPLAY or WAYLAND_SOCKET set and a main window that never reported its outer rect) and draws floats in-window, keeping their native rects. DockArea::native_windows(bool) overrides the choice. * docking: a missing DISPLAY also counts as a possible Wayland backend * Revert "docking: a missing DISPLAY also counts as a possible Wayland backend" This reverts commit a804afb1d3402b0acef7826522e37da87b5f41e5. * docking: the Wayland session is a pure function of the environment; without a Wayland variable only a real window counts --- crates/ui/src/docking/floating_tests.rs | 7 +-- crates/ui/src/docking/native_tests.rs | 67 ++++++++++++++++++++++++- crates/ui/src/docking/render/mod.rs | 5 +- crates/ui/src/docking/render/native.rs | 67 +++++++++++++++++++++++-- crates/ui/src/docking/surface.rs | 13 +++++ crates/ui/src/docking/testing.rs | 15 ++++-- docs/ui-docking.md | 14 ++++-- 7 files changed, 174 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/docking/floating_tests.rs b/crates/ui/src/docking/floating_tests.rs index 1806c429..8d5c7ec5 100644 --- a/crates/ui/src/docking/floating_tests.rs +++ b/crates/ui/src/docking/floating_tests.rs @@ -149,7 +149,8 @@ impl Driver { Hide::All => HidePanels::All, Hide::Keep => HidePanels::Except(&keep), }; - output = DockArea::new(Id::new(AREA)).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content); + output = + DockArea::new(Id::new(AREA)).native_windows(true).maximized(maximized).hide_panels(hide).show_content(ui, layout, &style, content); }); let mut full = full; full.textures_delta.clear(); @@ -421,7 +422,7 @@ fn close_other_panels_keeps_only_the_chosen_tab() { |ui, (content, actions, events): &mut State| { let mut style = DockStyle::from_ui(ui); frame_style(&mut style); - let out = DockArea::new(Id::new(AREA)).show_content(ui, &frames(), &style, content); + let out = DockArea::new(Id::new(AREA)).native_windows(true).show_content(ui, &frames(), &style, content); actions.extend(out.actions); events.extend(out.events); }, @@ -684,7 +685,7 @@ fn undock_panel_group_floats_one_group_of_a_floating_window() { |ui, (content, layout, actions): &mut State| { let mut style = DockStyle::from_ui(ui); frame_style(&mut style); - let out = DockArea::new(Id::new(AREA)).show_content(ui, layout, &style, content); + let out = DockArea::new(Id::new(AREA)).native_windows(true).show_content(ui, layout, &style, content); actions.extend(out.actions); }, (Content::default(), stacked(), Vec::new()), diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 9a8caae3..03e71876 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -3,7 +3,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCommand, epaint::ClippedShape, pos2, vec2}; -use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}; +use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, wayland}; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, Workspace}; use crate::layout::{SplitAxis, SplitSize}; @@ -1303,3 +1303,68 @@ fn a_layout_built_each_frame_without_ids_is_warned_about() { } assert!(!warned(&d)); } + +/// [`layout`] with window `[5, 6]` saved as a native window at `NATIVE`. +fn saved_native() -> Layout { + let mut layout = layout(true); + layout.floating[0].native = Some(NATIVE); + layout +} + +const NATIVE: [f32; 4] = [700.0, 400.0, 240.0, 300.0]; + +#[test] +fn with_native_windows_off_a_float_stays_in_the_app_window_and_keeps_its_native_rect() { + let mut d = DockDriver::new(saved_native(), Content::default()); + d.native_windows = false; + let mut d = d.with_native_host(ORIGIN); + assert!(d.host().windows.is_empty() && d.host().commands.is_empty()); + assert!(d.hit(&Hit::Title(5)).is_some(), "drawn in the main window"); + assert_eq!(window(&d, 5).native, Some(NATIVE)); + + // Its tab docks again by drag, in the main window. + let tab = d.hit(&Hit::Tab(5)).unwrap().center(); + let out = d.drag(tab, pos2(600.0, 300.0)); + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 5, anchor: 3 | 4, .. }]), "{:?}", out.actions); + assert!(in_root(&d, 5)); + assert_eq!(window(&d, 6).native, Some(NATIVE)); + assert!(d.host().windows.is_empty() && d.host().commands.is_empty()); + + // The same workspace opens as a native window where native windows are on. + let mut d = driver(saved_native()); + d.settle(); + assert_eq!(os_window(&d, 5).map(|(rect, _)| rect), Some(Rect::from_min_size(pos2(700.0, 400.0), vec2(240.0, 300.0)))); +} + +#[test] +fn wayland_is_a_main_window_that_never_reported_a_position() { + use super::render::Session; + let mut d = driver(layout(false)); + d.host().no_position = true; + d.step(vec![]); + assert!(wayland(&d.ctx, Session::Wayland)); + assert!(wayland(&d.ctx, Session::Unknown), "a real window without a Wayland variable"); + assert!(!wayland(&d.ctx, Session::Other), "X11"); + // X11 (forced under a Wayland session) reports it; minimized, it reports none again. + d.host().no_position = false; + d.step(vec![]); + assert!(!wayland(&d.ctx, Session::Wayland)); + d.host().no_position = true; + d.step(vec![]); + assert!(!wayland(&d.ctx, Session::Wayland)); + // A context with no real window (a headless test) and no display variables at all. + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert!(!wayland(&ctx, Session::Unknown)); +} + +#[test] +fn the_session_follows_winits_environment_rule() { + use super::render::{Session, session}; + let with = |vars: &'static [&'static str]| move |name: &str| vars.contains(&name); + assert_eq!(session(true, with(&["WAYLAND_DISPLAY", "DISPLAY"])), Session::Wayland); + assert_eq!(session(true, with(&["WAYLAND_SOCKET"])), Session::Wayland); + assert_eq!(session(true, with(&["DISPLAY"])), Session::Other); + assert_eq!(session(true, with(&[])), Session::Unknown); + assert_eq!(session(false, with(&["WAYLAND_DISPLAY"])), Session::Other); +} diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 1d0078b0..3459656a 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -17,7 +17,7 @@ mod native; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands}, + native::{APPKIT_CORNER, MACOS_CLOSE, Session, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, }; /// The viewport of the native window `window` of the dock area `area`. @@ -76,6 +76,9 @@ pub(super) struct Renderer<'a, P> { /// [`crate::docking::DockArea::monitors`]. #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] pub(super) monitors: Vec, + /// [`crate::docking::DockArea::native_windows`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) native_windows: Option, /// The other OS windows a drag may cross into. #[cfg(feature = "native-windows")] pub(super) cross: cross::Cross

, diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index c4c3b628..993c3ead 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -2,8 +2,9 @@ //! viewports, every floating window whose panels allow it is drawn with //! `show_viewport_immediate`, always on top of other windows and hidden while none of the //! application's windows has focus. Where egui embeds viewports (wasm, tests) windows stay -//! in-window at their in-window rect. Tabs, groups and whole windows drag between OS windows -//! (see `cross.rs`). +//! in-window at their in-window rect, and so do they on Wayland, unless the app turns native +//! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows +//! drag between OS windows (see `cross.rs`). use std::{fmt::Debug, hash::Hash}; @@ -199,7 +200,7 @@ impl

Default for Report

{ impl Renderer<'_, P> { /// Show the native windows and return the windows to draw in-window. pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { - let native = !ctx.embed_viewports(); + let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session())); let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); let visibility = if native { self.float_visibility(ctx) } else { (true, false) }; self.cross.on = native; @@ -664,6 +665,66 @@ impl Renderer<'_, P> { #[cfg(debug_assertions)] const REBUILT_FRAMES: usize = 3; +/// What the environment says of winit's backend. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::docking) enum Session { + /// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set: winit picks Wayland unless the app forces + /// X11 in code (`EventLoopBuilderExtX11::with_x11`); winit reads no variable that does. + Wayland, + /// None of those nor `DISPLAY` is set on a Unix desktop: a Wayland app started with only + /// `WAYLAND_SOCKET`, which winit unsets as it connects, or no display at all (tests, CI). + Unknown, + /// X11 or another backend. + Other, +} + +/// The [`Session`] of a Unix desktop (`unix`) whose environment variable `name` is set and +/// not empty when `set(name)`. +pub(in crate::docking) fn session(unix: bool, set: impl Fn(&str) -> bool) -> Session { + if !unix { + Session::Other + } else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") { + Session::Wayland + } else if set("DISPLAY") { + Session::Other + } else { + Session::Unknown + } +} + +/// This process's [`Session`], read once. +fn env_session() -> Session { + static SESSION: std::sync::OnceLock = std::sync::OnceLock::new(); + *SESSION.get_or_init(|| { + session(cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")), |name| { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) + }) + }) +} + +/// Whether the main window runs on Wayland, given the environment's `session`. egui does not +/// name the backend, but on Wayland the main window never reports its outer rect, while under +/// X11 it does from the first frame on (eframe reads it as the window opens). A minimized X11 +/// window reports none either, so once one has, the backend is not Wayland. Without a Wayland +/// variable, only a real OS window (one that reports its native pixels per point) counts. +pub(in crate::docking) fn wayland(ctx: &Context, session: Session) -> bool { + let root = |test: fn(&ViewportInfo) -> bool| ctx.input(|i| i.raw.viewports.get(&ViewportId::ROOT).is_some_and(test)); + match session { + Session::Other => return false, + Session::Unknown if !root(|v| v.native_pixels_per_point.is_some()) => return false, + Session::Wayland | Session::Unknown => {} + } + let key = Id::new("craft-ui-dock-window-positions"); + if ctx.data(|d| d.get_temp::(key).unwrap_or(false)) { + return false; + } + let reported = root(|v| v.outer_rect.is_some()); + if reported { + ctx.data_mut(|d| d.insert_temp(key, true)); + } + !reported +} + /// Whether every window of `current` is new to `previous`, and they hold the same panels: /// windows, with their panels, as `(id, panels)`. #[cfg(debug_assertions)] diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 00aac1ff..dc24a187 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -746,6 +746,7 @@ pub struct DockArea<'a, P = ()> { auto_collapse: bool, float_bounds: Option, monitors: Vec, + native_windows: Option, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { @@ -760,6 +761,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { auto_collapse: true, float_bounds: None, monitors: Vec::new(), + native_windows: None, } } @@ -772,6 +774,16 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Whether floating windows open in their own OS windows (feature `native-windows`), where + /// the backend has viewports. Off, they stay in the app window, and their native rects are + /// kept for a later run with it on. By default it is on, except on Wayland: the compositor + /// reports no window positions, so a native window could not dock again by drag or open + /// where it was. + pub fn native_windows(mut self, on: bool) -> Self { + self.native_windows = Some(on); + self + } + /// The egui viewport of the native window of the floating window with [`super::Floating::id`] /// `window`, in the dock area `id` (feature `native-windows`). #[cfg(feature = "native-windows")] @@ -933,6 +945,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { dividers: Vec::new(), tree: None, monitors: self.monitors, + native_windows: self.native_windows, #[cfg(feature = "native-windows")] cross: Default::default(), }; diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 9804ba8b..b6e1b107 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -48,6 +48,8 @@ pub struct DockDriver { pub monitors: Vec, /// Passed to [`DockArea::locked`]. pub locked: bool, + /// Passed to [`DockArea::native_windows`]: on by default, whatever the test's desktop. + pub native_windows: bool, /// The stand-in for OS windows, in use after [`Self::with_native_host`]. #[cfg(feature = "native-windows")] host: Option>>, @@ -77,6 +79,7 @@ impl> Do float_bounds: None, monitors: Vec::new(), locked: false, + native_windows: true, #[cfg(feature = "native-windows")] host: None, #[cfg(feature = "native-windows")] @@ -125,15 +128,20 @@ impl> Do } let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); let monitors = self.monitors.clone(); - let (style_fn, maximized, raise, auto, bounds, locked) = - (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds, self.locked); + let (style_fn, maximized, raise, auto, bounds, locked, native) = + (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds, self.locked, self.native_windows); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if let Some(adjust) = style_fn { adjust(&mut style); } ui.scope_builder(UiBuilder::new().max_rect(area), |ui| { - let mut dock = DockArea::new(id).maximized(maximized.clone()).auto_collapse_iconic(auto).monitors(monitors.clone()).locked(locked); + let mut dock = DockArea::new(id) + .maximized(maximized.clone()) + .auto_collapse_iconic(auto) + .monitors(monitors.clone()) + .locked(locked) + .native_windows(native); if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } @@ -418,6 +426,7 @@ impl NativeHost { .then(|| if id == egui::ViewportId::ROOT { rect } else { Rect::from_min_max(rect.min - self.frame, rect.max) }), focused: Some(self.focus == Some(id)), monitor_size: self.monitor_size, + native_pixels_per_point: Some(1.0), ..Default::default() }; let main = Rect::from_min_size(self.origin, self.screen.size()); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index df9a61b2..fde3e312 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -355,6 +355,13 @@ viewport's `inner_rect.min` plus its rect). A window with a panel whose that is not floatable, stays in-window. Floating windows get no taskbar or Alt-Tab entry (`with_taskbar(false)`). +On Wayland floating windows stay in-window: the compositor reports no window positions, so a +native window could not dock again by drag or reopen where it was. Their `native` rects are +kept, and the same layout opens native windows on X11, macOS and Windows. The dock area takes +Wayland to be the backend when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (winit's rule) and +the main window has never reported its `outer_rect`; an app that forces X11 in code gets native +windows from the first frame. `DockArea::native_windows(bool)` overrides the choice either way. + - **Identity.** `Floating::id` names the OS window, so a window keeps it when its panels change, including when its first panel moves out. `Floating::new` and loading draw fresh ids, and `Floating::with_id` sets one; a layout @@ -465,9 +472,10 @@ that is not floatable, stays in-window. Floating windows get no taskbar or Alt-T the desktop, or with Ctrl/Cmd held, it stays where it was moved, and the layout takes its place once, at the release. Escape puts it back. An in-window floating window's title bar still splits only groups of other floating windows. - - *Wayland.* Where `outer_rect` is `None`, desktop positions are unknown, so drops across - windows cannot be resolved. A drag works inside its own window, and a release outside it - floats a new window that the compositor places. Moving a window hands the move to the + - *Wayland.* With `DockArea::native_windows(true)` on Wayland, or wherever `outer_rect` is + `None`, desktop positions are unknown, so drops across windows cannot be resolved. A drag + works inside its own window, and a release outside it floats a new window that the + compositor places. Moving a window hands the move to the compositor, which never docks it. On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the From c98ee15a7fa4ab60899206e73a920eacda1f0a04 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:13:12 -0400 Subject: [PATCH 148/160] docking: tabs shrink before they overflow (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docking: tabs shrink before they overflow Tabs in a narrow strip shrink, widest first, down to DockStyle::min_tab_width (54 pt at the default font), cutting their labels with "…" and naming them in a tooltip. Only tabs that do not fit at that width go to the overflow menu. * docking: the drag bar narrows once the strip is narrower than the header plus the bar --- crates/ui/README.md | 3 +- crates/ui/src/docking/group_tests.rs | 56 ++++++++++++++++++++++++++- crates/ui/src/docking/render/group.rs | 9 +++-- crates/ui/src/docking/surface.rs | 14 +++++-- crates/ui/src/docking/tests.rs | 51 ++++++++++++------------ crates/ui/src/docking/trial_tests.rs | 5 +-- docs/ui-docking.md | 21 ++++++---- 7 files changed, 113 insertions(+), 46 deletions(-) diff --git a/crates/ui/README.md b/crates/ui/README.md index 593a628d..9b11f3e2 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -107,12 +107,13 @@ indices and widths, overflow indices, and the actual reserved overflow width. It tabs before hiding them, keeps input order and retains the selected tab. Tiny strips may reduce its width to zero. Negative/non-finite dimensions become zero and large dimensions are bounded. The work is linear in tab count with at most 32 fitting passes; the caller caps its own tab count. +Docking tab strips use this policy, with `DockStyle::min_tab_width` as the minimum. `tabs::fit_whole(natural_widths, selected, available, overflow_width)` keeps every shown tab at its natural width and sends the rest to overflow. Tabs are shown in order up to the first that does not fit; the selected tab is always shown in its logical place, taking the place of the last tabs that fit when it lies past them. Only a selected tab wider than the strip minus the overflow button is -narrowed. Docking tab strips use this policy. +narrowed. `tabs::Tab` registers a tab at a supplied rectangle and invokes the application's painter: diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index f6027aed..2e0c5efa 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -263,12 +263,66 @@ fn drag_bar_survives_a_strip_full_of_tabs() { assert!(bar.width() >= 40.0, "{bar:?}"); assert_eq!(bar.right(), 300.0 - 24.0, "the bar ends at the header"); assert_eq!(d.content.strip_width.get(), 300.0, "the header is sized for the whole strip"); - assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs overflow before the bar narrows"); + assert!(d.ctx.read_response(Id::new(AREA).with(("overflow", &1u32))).is_some(), "tabs shrink, then overflow, before the bar narrows"); let out = d.drag(bar.center(), pos2(500.0, 300.0)); assert!(matches!(moved_group(&out.actions), Some(Target::Float { .. })), "{:?}", out.actions); assert_eq!(d.layout.floating[0].groups()[0].panels.len(), 8); } +/// Panels with ordinary names. +struct Named; + +impl DockContent for Named { + fn body(&mut self, ui: &mut egui::Ui, _: &u32) { + ui.label("Body"); + } + fn logical_label(&self, panel: &u32) -> String { + if *panel == 1 { "Properties" } else { "Layers" }.into() + } +} + +#[test] +fn narrow_frames_shrink_their_tabs_before_the_rest_overflow() { + let texts = |d: &testing::DockDriver| { + let mut out = Vec::new(); + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(text) => out.push(text.clone()), + _ => {} + } + } + d.shapes().iter().for_each(|clipped| walk(&clipped.shape, &mut out)); + out + }; + let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: Vec::new() }; + let area = |width: f32| Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 300.0)); + + // Both tabs show, cut to fit, with their labels inside them. + let d = testing::DockDriver::new(layout.clone(), Named).with_area(area(168.0)); + assert!(d.hit(&Hit::Overflow(1)).is_none(), "no overflow while the tabs fit at their minimum width"); + let min = DockStyle::default().min_tab_width; + for (panel, name) in [(1, "Properties"), (2, "Layers")] { + let tab = d.hit(&Hit::Tab(panel)).unwrap_or_else(|| panic!("{name} is hidden")); + assert!(tab.width() >= min - 0.5, "{name}: {tab:?}"); + let label = texts(&d).into_iter().find(|t| t.galley.text() == name && tab.contains(t.pos)).unwrap_or_else(|| panic!("no {name} label")); + assert!(tab.contains_rect(label.visual_bounding_rect()), "{name}: {:?} outside {tab:?}", label.visual_bounding_rect()); + } + let properties = texts(&d).into_iter().find(|t| t.galley.text() == "Properties").unwrap(); + assert!(properties.galley.elided, "the wider tab shrinks to make room"); + + // Too narrow for both at their minimum width: the active tab stays, the other is listed. + let mut d = testing::DockDriver::new(layout, Named).with_area(area(120.0)); + assert!(d.hit(&Hit::Tab(1)).is_some() && d.hit(&Hit::Tab(2)).is_none()); + let overflow = d.hit(&Hit::Overflow(1)).expect("an overflow button"); + d.click(overflow.center()); + d.settle(); + let listed = texts(&d).into_iter().find(|t| t.galley.text() == "Layers").expect("the menu lists Layers"); + assert!(listed.pos.y >= overflow.bottom(), "{listed:?}"); + let out = d.click(listed.visual_bounding_rect().center()); + assert!(out.actions.contains(&Action::Activate { panel: 2 }), "{:?}", out.actions); +} + #[test] fn top_border_drags_the_group_and_the_menu_button_only_clicks() { let content = TestContent { header: 30.0, ..Default::default() }; diff --git a/crates/ui/src/docking/render/group.rs b/crates/ui/src/docking/render/group.rs index 9b214ee0..be19494a 100644 --- a/crates/ui/src/docking/render/group.rs +++ b/crates/ui/src/docking/render/group.rs @@ -80,7 +80,7 @@ impl Renderer<'_, P> { self.output.focused = Some(selected.clone()); } } - // The drag bar keeps its reserved width: tabs overflow before it narrows. + // The drag bar keeps its reserved width: tabs shrink, then overflow, before it narrows. let bar_width = self.style.drag_bar_width.clamp(0.0, (strip.width() - header_width).max(0.0)); let tabs_width = (strip.width() - header_width - bar_width).max(0.0); let widths: Vec = panels @@ -94,9 +94,10 @@ impl Renderer<'_, P> { + TAB_PADDING }) .collect(); - // Tabs keep their full names; the rest go to the overflow menu. Only an active tab wider - // than the strip is cut, with its name in a tooltip. - let fit = tabs::fit_whole(&widths, active, tabs_width, OVERFLOW_WIDTH); + // The widest tabs shrink first, to a common width no narrower than `min_tab_width`; only + // when every tab at that width cannot fit do the rest go to the overflow menu. A tab + // narrower than its name cuts it with "…" and names it in a tooltip. + let fit = tabs::fit(&widths, active, tabs_width, self.style.min_tab_width, OVERFLOW_WIDTH); let owner = TabGroup { panels, selected, minimized, kind, rect }; let mut x = strip.left(); let mut shown = Vec::new(); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index dc24a187..6acd40dd 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -38,9 +38,14 @@ pub struct DockStyle { pub float_group_label: String, /// Group menu item that moves the whole group into another group (submenu of targets). pub move_group_to_label: String, - /// Minimum width of the drag bar left of the group header. Tabs overflow before the bar - /// narrows. + /// Minimum width of the drag bar left of the group header. Tabs shrink, then overflow, + /// before the bar narrows; a strip narrower than the header plus this width narrows it. pub drag_bar_width: f32, + /// Narrowest a tab gets before the tabs that do not fit go to the overflow menu: about + /// three characters and the "…" at [`Self::font`]'s size, plus the tab's 24 pt padding + /// (54 pt at the default 12 pt font). Tabs whose names are shorter keep their natural + /// width. Set it again after changing the font. + pub min_tab_width: f32, /// Painted under every floating window, so it stays opaque when the panel colors are not. pub floating_background: Color32, pub floating_stroke: Stroke, @@ -135,6 +140,7 @@ impl DockStyle { } pub fn from_visuals(visuals: &egui::Visuals) -> Self { + let font = FontId::proportional(12.0); Self { tab_height: 26.0, gap: 4.0, @@ -146,7 +152,8 @@ impl DockStyle { inactive_text: visuals.weak_text_color(), border: visuals.widgets.noninteractive.bg_stroke, accent: visuals.selection.stroke.color, - font: FontId::proportional(12.0), + min_tab_width: font.size * 2.5 + TAB_PADDING, + font, float_label: "Float Panel".into(), close_label: "Close Panel".into(), move_panel_to_label: "Move Panel to Group".into(), @@ -208,6 +215,7 @@ impl DockStyle { self.border.width, self.font.size, self.drag_bar_width, + self.min_tab_width, self.floating_stroke.width, self.title_height, self.edge_band, diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index d9abfaeb..c4494b79 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1401,16 +1401,9 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() { } #[test] -fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { +fn a_strip_too_narrow_for_its_tabs_at_their_minimum_width_lists_the_others_in_the_overflow_menu() { use egui::accesskit::Role; use egui_kittest::{Harness, kittest::Queryable}; - fn elided(shape: &egui::Shape) -> bool { - match shape { - egui::Shape::Text(text) => text.galley.elided, - egui::Shape::Vec(shapes) => shapes.iter().any(elided), - _ => false, - } - } let area = Id::new("whole-tabs"); let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] }; let mut h = Harness::builder().with_size(vec2(260.0, 240.0)).build_ui(move |ui| { @@ -1430,7 +1423,10 @@ fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { let mut expected: Vec = (1..shown.len() as u32).collect(); expected.push(6); assert_eq!(shown, expected); - assert!(h.output().shapes.iter().all(|shape| !elided(&shape.shape)), "every shown tab keeps its full name"); + // As many tabs show as fit at the minimum width beside the drag bar and the "…" button. + let style = DockStyle::default(); + let room = 260.0 - style.drag_bar_width - 22.0; + assert!(shown.len() as f32 * style.min_tab_width <= room && (shown.len() + 1) as f32 * style.min_tab_width > room, "{shown:?}"); h.get_by_role_and_label(Role::Button, "Panels").click(); h.run_steps(2); let listed: Vec = (1..=6).filter(|p| h.query_by_role_and_label(Role::Button, &format!("Panel {p}")).is_some()).collect(); @@ -1439,13 +1435,13 @@ fn a_narrow_strip_shows_whole_tabs_and_lists_the_others_in_the_overflow_menu() { } #[test] -fn only_an_active_tab_wider_than_the_strip_is_truncated_and_named_in_a_tooltip() { +fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() { let ctx = Context::default(); ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0); let layout = Layout { root: Some(tabs(&[1, 2, 3], 0)), floating: vec![] }; let label = |p: &u32| format!("Eigenschaften und Anpassungen {p}"); let run = |events: Vec| { - let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(150.0, 200.0))), events, ..Default::default() }; + let input = RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(240.0, 200.0))), events, ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { DockArea::new(Id::new("truncate")).show( ui, @@ -1463,23 +1459,24 @@ fn only_an_active_tab_wider_than_the_strip_is_truncated_and_named_in_a_tooltip() }; run(vec![]); let shapes = run(vec![]); - let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).map(|response| response.rect); - assert!(tab(2).is_none() && tab(3).is_none(), "the other tabs overflow instead of shrinking"); - let active = tab(1).unwrap(); - // The clip rect would hide an overflowing label; what is laid out must itself fit. - let painted: Vec<&egui::epaint::TextShape> = shapes - .iter() - .filter_map(|shape| match &shape.shape { - egui::Shape::Text(text) if active.contains(text.visual_bounding_rect().center()) => Some(text), - _ => None, - }) - .collect(); - assert_eq!(painted.len(), 1, "{painted:?}"); - assert!(painted[0].galley.elided); - assert!(active.contains_rect(painted[0].visual_bounding_rect()), "label {:?} overflows {active:?}", painted[0].visual_bounding_rect()); - run(vec![Event::PointerMoved(active.center())]); + let tab = |p: u32| ctx.read_response(Id::new("truncate").with(("tab", &p))).expect("every tab shrinks to fit").rect; + for p in 1..=3 { + let tab = tab(p); + // The clip rect would hide an overflowing label; what is laid out must itself fit. + let painted: Vec<&egui::epaint::TextShape> = shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if tab.contains(text.visual_bounding_rect().center()) => Some(text), + _ => None, + }) + .collect(); + assert_eq!(painted.len(), 1, "tab {p}: {painted:?}"); + assert!(painted[0].galley.elided, "tab {p}"); + assert!(tab.contains_rect(painted[0].visual_bounding_rect()), "tab {p}: label {:?} overflows {tab:?}", painted[0].visual_bounding_rect()); + } + run(vec![Event::PointerMoved(tab(2).center())]); let shapes = run(vec![]); - let full = label(&1); + let full = label(&2); // An elided galley still reports its whole text, so the cut tab label alone would match. assert!( shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == full && !text.galley.elided)), diff --git a/crates/ui/src/docking/trial_tests.rs b/crates/ui/src/docking/trial_tests.rs index aa3ec6ce..0b825da9 100644 --- a/crates/ui/src/docking/trial_tests.rs +++ b/crates/ui/src/docking/trial_tests.rs @@ -79,7 +79,7 @@ impl DockContent for GalleyLabels { } #[test] -fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellipsis_inside_it() { +fn galley_labels_in_shrunk_tabs_end_with_an_ellipsis_inside_the_tab_active_or_not() { let panels = [1, 2, 3, 4, 5, 6]; let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() }; for width in [320.0, 90.0] { @@ -111,8 +111,7 @@ fn galley_labels_stay_whole_and_only_an_over_wide_active_tab_ends_with_an_ellips let Some((clip, text)) = texts.iter().find(|(_, t)| t.galley.text().ends_with(&format!(" {panel}")) && tab.contains(t.pos)) else { panic!("no label in tab {panel}") }; - let cut = width < 100.0; - assert_eq!(last_char(&text.galley), if cut { Some('…') } else { char::from_digit(panel, 10) }, "{width}: tab {panel}"); + assert_eq!(last_char(&text.galley), Some('…'), "{width}: tab {panel}"); let drawn = Rect::from_min_size(text.pos, text.galley.size()); assert!(drawn.right() <= tab.right() + 0.5 && drawn.left() >= tab.left(), "tab {panel}: {drawn:?} in {tab:?}"); assert!(tab.contains_rect(clip.intersect(drawn)), "tab {panel}"); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index fde3e312..3034a125 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -174,13 +174,20 @@ docking and `craft_ui::panes` both draw) for apps that draw their own windows. ## Tab strips -Every shown tab carries its panel's full name. Tabs that do not fit go to the "…" button at the -end of the strip, whose menu lists the hidden panels; choosing one makes it active. The active tab -is always shown, in its logical place: when it lies past the tabs that fit, it takes the place of -the last of them. Only an active tab wider than the room beside the "…" button, or a lone tab -wider than the strip, is cut with "…", and its tooltip names it in full. The drag bar always keeps -its reserved width: tabs go to the overflow menu before it narrows, and only a strip narrower than -the bar itself shrinks it. A tab dropped on the "…" button goes to the end of the group. +Tabs start at their natural width, wide enough for the panel's name. When the strip is too +narrow, the widest tabs shrink first, to a common width; tabs whose names fit that width keep +their natural width. A tab narrower than its name cuts the name with "…" and shows it in full as a +tooltip. Shrunk tabs keep their hit areas, drag and keyboard behaviour, and accessibility labels +with the full names. + +No tab shrinks below `DockStyle::min_tab_width`: by default about three characters and the "…" +plus the tab's padding, 54 pt at the 12 pt font. Only when the tabs at that width do not all fit +do the rest go to the "…" button at the end of the strip, whose menu lists the hidden panels; +choosing one makes it active. The active tab is always shown, in its logical place, and the +others take the remaining room in their order. An active tab with less room than its minimum +width, or a lone tab wider than the strip, is cut the same way. The drag bar always keeps its +reserved width: tabs shrink, then overflow, before it narrows, and only a strip narrower than the +group header plus the bar shrinks it. A tab dropped on the "…" button goes to the end of the group. ## Floating size From c75bf618f0c482e0f82610fa91fdb51c05068f7b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:13:37 -0400 Subject: [PATCH 149/160] docking: viewer panels float as normal windows (#296) * docking: viewer panels float as normal windows DockContent::window_kind marks panels as palettes (the default) or normal windows. A native floating window holding any Normal panel gets the normal window level and stays shown while the app is in the background; drags treat it as stacked with the main window by focus. * docs: a normal window behind the main window takes drags outside it --- crates/ui/src/docking/desktop.rs | 46 +++++++++------- crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/native_tests.rs | 39 +++++++++++++- crates/ui/src/docking/render/cross.rs | 5 +- crates/ui/src/docking/render/native.rs | 75 ++++++++++++++++---------- crates/ui/src/docking/surface.rs | 22 ++++++++ crates/ui/src/docking/testing.rs | 14 ++++- docs/ui-docking.md | 21 +++++--- 8 files changed, 168 insertions(+), 58 deletions(-) diff --git a/crates/ui/src/docking/desktop.rs b/crates/ui/src/docking/desktop.rs index 2047a641..7fb47211 100644 --- a/crates/ui/src/docking/desktop.rs +++ b/crates/ui/src/docking/desktop.rs @@ -16,19 +16,21 @@ pub(super) enum Over { } /// The window under `point`: the first of `windows` (floating native windows by id and desktop -/// rect, front first) that holds it, then `main` (the main window's content). The window -/// `dragged` follows the pointer by its bar and is never its own target. -pub(super) fn window_at(point: Pos2, windows: &[(u64, Rect)], main: Option, dragged: Option) -> Over { +/// rect, front first) that holds it, where the first `front` of them lie in front of `main` +/// (the main window's content) and the rest behind it. The window `dragged` follows the pointer +/// by its bar and is never its own target. +pub(super) fn window_at(point: Pos2, windows: &[(u64, Rect)], front: usize, main: Option, dragged: Option) -> Over { if !point.is_finite() { return Over::Desktop; } - if let Some((id, rect)) = windows.iter().find(|(id, rect)| Some(*id) != dragged && rect.contains(point)) { - return Over::Window(*id, point - rect.min.to_vec2()); - } - match main { - Some(main) if main.contains(point) => Over::Main(point - main.min.to_vec2()), - _ => Over::Desktop, - } + let (before, behind) = windows.split_at(front.min(windows.len())); + let hit = |windows: &[(u64, Rect)]| { + windows.iter().find(|(id, rect)| Some(*id) != dragged && rect.contains(point)).map(|(id, rect)| Over::Window(*id, point - rect.min.to_vec2())) + }; + hit(before) + .or_else(|| main.filter(|main| main.contains(point)).map(|main| Over::Main(point - main.min.to_vec2()))) + .or_else(|| hit(behind)) + .unwrap_or(Over::Desktop) } #[cfg(test)] @@ -46,24 +48,28 @@ mod tests { let main = Some(rect(100.0, 50.0, 800.0, 600.0)); // Window 2 lies over window 1, and both over the main window. let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))]; - assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, None), Over::Window(2, pos2(50.0, 50.0))); - assert_eq!(window_at(pos2(260.0, 90.0), &windows, main, None), Over::Window(1, pos2(10.0, 10.0))); - assert_eq!(window_at(pos2(120.0, 60.0), &windows, main, None), Over::Main(pos2(20.0, 10.0))); - assert_eq!(window_at(pos2(20.0, 20.0), &windows, main, None), Over::Desktop); + assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, None), Over::Window(2, pos2(50.0, 50.0))); + assert_eq!(window_at(pos2(260.0, 90.0), &windows, 2, main, None), Over::Window(1, pos2(10.0, 10.0))); + assert_eq!(window_at(pos2(120.0, 60.0), &windows, 2, main, None), Over::Main(pos2(20.0, 10.0))); + assert_eq!(window_at(pos2(20.0, 20.0), &windows, 2, main, None), Over::Desktop); // A floating window outside the main window. let windows = [(3, rect(1000.0, 0.0, 100.0, 100.0))]; - assert_eq!(window_at(pos2(1050.0, 40.0), &windows, main, None), Over::Window(3, pos2(50.0, 40.0))); + assert_eq!(window_at(pos2(1050.0, 40.0), &windows, 2, main, None), Over::Window(3, pos2(50.0, 40.0))); // Unknown main window, a point that is not one. - assert_eq!(window_at(pos2(120.0, 60.0), &[], None, None), Over::Desktop); - assert_eq!(window_at(pos2(f32::NAN, 60.0), &windows, main, None), Over::Desktop); + assert_eq!(window_at(pos2(120.0, 60.0), &[], 0, None, None), Over::Desktop); + assert_eq!(window_at(pos2(f32::NAN, 60.0), &windows, 2, main, None), Over::Desktop); + // A normal window behind the main window is reached only past the main window's edge. + let windows = [(4, rect(50.0, 40.0, 200.0, 200.0))]; + assert_eq!(window_at(pos2(120.0, 60.0), &windows, 0, main, None), Over::Main(pos2(20.0, 10.0))); + assert_eq!(window_at(pos2(60.0, 45.0), &windows, 0, main, None), Over::Window(4, pos2(10.0, 5.0))); } #[test] fn the_dragged_window_is_looked_through() { let main = Some(rect(0.0, 0.0, 800.0, 600.0)); let windows = [(2, rect(300.0, 100.0, 200.0, 200.0)), (1, rect(250.0, 80.0, 200.0, 200.0))]; - assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, Some(2)), Over::Window(1, pos2(100.0, 70.0))); - assert_eq!(window_at(pos2(350.0, 150.0), &windows, main, Some(1)), Over::Window(2, pos2(50.0, 50.0))); - assert_eq!(window_at(pos2(480.0, 280.0), &windows, main, Some(2)), Over::Main(pos2(480.0, 280.0))); + assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(2)), Over::Window(1, pos2(100.0, 70.0))); + assert_eq!(window_at(pos2(350.0, 150.0), &windows, 2, main, Some(1)), Over::Window(2, pos2(50.0, 50.0))); + assert_eq!(window_at(pos2(480.0, 280.0), &windows, 2, main, Some(2)), Over::Main(pos2(480.0, 280.0))); } } diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index 79457375..fd24506f 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -31,8 +31,8 @@ pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ - DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, begin_drag, - cancel_drag, float_space, is_dragging, + DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, WindowKind, + begin_drag, cancel_drag, float_space, is_dragging, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 03e71876..351aeab9 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -5,7 +5,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, ViewportCo use super::render::{APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, wayland}; use super::testing::DockDriver; -use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, Workspace}; +use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, WindowKind, Workspace}; use crate::layout::{SplitAxis, SplitSize}; /// Desktop position of the main window's content. @@ -17,6 +17,8 @@ struct Content { fixed: Vec, /// Panels that stay in-window. in_window: Vec, + /// Viewer panels: their windows are normal windows. + normal: Vec, /// The panel with a text field, what it holds, and whether it takes focus next frame. text: Option, typed: String, @@ -50,6 +52,10 @@ impl DockContent for Content { fn allow_native(&self, panel: &u32) -> bool { !self.in_window.contains(panel) } + + fn window_kind(&self, panel: &u32) -> WindowKind { + if self.normal.contains(panel) { WindowKind::Normal } else { WindowKind::Palette } + } } fn tabs(panels: &[u32]) -> Node { @@ -348,6 +354,37 @@ fn floating_windows_hide_while_the_app_is_inactive() { assert!(d.host().windows[&viewport].visible); } +#[test] +fn viewer_windows_stay_shown_at_normal_level() { + let mut layout = layout(false); + layout.floating = vec![ + Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]), + Floating::single(vec![6], 0, [560.0, 80.0, 240.0, 300.0]), + // A palette in front of a viewer's tab: the viewer makes the window normal. + Floating::single(vec![7, 8], 0, [820.0, 80.0, 240.0, 300.0]), + ]; + let mut d = driver(layout); + d.content.normal = vec![5, 8]; + d.step(vec![]); + let [viewer, palette, mixed] = [5, 6, 7].map(|p| d.viewport(window(&d, p).id)); + let level = |d: &DockDriver, v| d.host().windows[&v].level; + assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); + assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); + assert_eq!(level(&d, mixed), Some(egui::WindowLevel::Normal)); + // Another application: only the palette hides, and the others get no visibility command. + d.host().focus = None; + for _ in 0..20 { + d.step(vec![]); + } + let shown = |d: &DockDriver| [viewer, palette, mixed].map(|v| d.host().windows[&v].visible); + assert_eq!(shown(&d), [true, false, true]); + let told = |d: &DockDriver, v| d.host().commands.iter().any(|(w, c)| *w == v && matches!(c, ViewportCommand::Visible(_))); + assert!(!told(&d, viewer) && !told(&d, mixed)); + d.host().focus = Some(egui::ViewportId::ROOT); + d.step(vec![]); + assert_eq!(shown(&d), [true, true, true]); +} + #[test] fn unused_keys_in_a_native_window_reach_the_app() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index bd7b767d..fca134e7 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -85,6 +85,8 @@ pub(in crate::docking) struct Cross

{ pub main: Option, /// Shown native windows' desktop rects, front first. pub windows: Vec<(u64, Rect)>, + /// How many of `windows` lie in front of the main window. + pub front: usize, /// Targets of the native windows drawn this frame. pub surfaces: Vec>, /// The drag's pointer, as its owner wrote it this frame. @@ -106,6 +108,7 @@ impl

Default for Cross

{ focus_lost: false, main: None, windows: Vec::new(), + front: 0, surfaces: Vec::new(), pointer: None, release: None, @@ -152,7 +155,7 @@ impl Renderer<'_, P> { /// known, and a point outside it is on the desktop. `current` is a window drawing now. fn destination(&self, pointer: &DragPointer, current: Option<&Surface

>) -> Option { if let (Some(point), Some(main)) = (pointer.desktop(), self.cross.main) { - return Some(match window_at(point, &self.cross.windows, Some(main), pointer.window) { + return Some(match window_at(point, &self.cross.windows, self.cross.front, Some(main), pointer.window) { Over::Main(local) => Dest::Main(local), Over::Window(id, local) => Dest::Window(id, local), Over::Desktop => Dest::Desktop(Some(point)), diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 993c3ead..94a5069f 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -18,7 +18,7 @@ use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; use crate::docking::placement::{kept_reachable, monitor_of, restore, sanitized}; -use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node}; +use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, WindowKind}; /// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; @@ -143,6 +143,8 @@ struct Place { pending: Option, /// The window system put the frame, not the content, where it opened: corrected once. corrected: bool, + /// Shown, as it opened or was last told. + shown: bool, } /// Whether two rects differ by more than rounding. @@ -202,7 +204,7 @@ impl Renderer<'_, P> { pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session())); let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); - let visibility = if native { self.float_visibility(ctx) } else { (true, false) }; + let visible = !native || self.float_visibility(ctx); self.cross.on = native; let mut shown = Vec::new(); let mut in_window = Vec::new(); @@ -233,7 +235,7 @@ impl Renderer<'_, P> { if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(key)); } - self.map_desktop(ctx, &natives, visibility.0); + self.map_desktop(ctx, &natives, visible); // The main window owns drags of the panels it draws. let id = self.id; let dragged = ctx.data(|d| { @@ -245,7 +247,7 @@ impl Renderer<'_, P> { } } for (window, rect, viewport) in natives { - if let Some(key) = self.native_window(ctx, window, rect, viewport, visibility) { + if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { shown.push(key); } } @@ -281,37 +283,47 @@ impl Renderer<'_, P> { ctx.data_mut(|d| d.insert_temp(key, (runs, current))); } - /// The main window and the shown native windows on the desktop, front first: the window - /// focused most recently first. A window whose position is unknown is left out. + /// The main window and the shown native windows on the desktop, front first: palettes + /// above the rest, each the window focused most recently first. A window whose position is + /// unknown is left out. `visible`: palettes show. fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool) { let key = self.id.with("native-order"); - let mut order = ctx.data(|d| d.get_temp::>(key)).unwrap_or_default(); - order.retain(|id| natives.iter().any(|(w, ..)| w.id == *id)); + // Focus order, front first; `None` is the main window. + let mut order = ctx.data(|d| d.get_temp::>>(key)).unwrap_or_default(); + order.retain(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id))); + if ctx.input(|i| i.viewport().focused == Some(true)) || !order.contains(&None) { + let front = order.contains(&None); + order.retain(Option::is_some); + if front { order.insert(0, None) } else { order.push(None) } + } let mut windows = Vec::new(); for (window, rect, viewport) in natives { let info = ctx.input(|i| i.raw.viewports.get(viewport).cloned()); // A new window opens in front. - if info.as_ref().is_some_and(|i| i.focused == Some(true)) || !order.contains(&window.id) { - order.retain(|id| *id != window.id); - order.insert(0, window.id); + if info.as_ref().is_some_and(|i| i.focused == Some(true)) || !order.contains(&Some(window.id)) { + order.retain(|id| *id != Some(window.id)); + order.insert(0, Some(window.id)); } let [x, y, w, h] = *rect; let placed = match info { None => Some(Rect::from_min_size(pos2(x, y), vec2(w, h))), Some(info) => placed(&info).map(|[x, y, w, h]| Rect::from_min_size(pos2(x, y), vec2(w, h))), }; - if let Some(placed) = placed.filter(|r| r.is_finite() && visible) { - windows.push((window.id, placed)); + let normal = self.normal_window(window); + if let Some(placed) = placed.filter(|r| r.is_finite() && (visible || normal)) { + windows.push((window.id, placed, normal)); } } - windows.sort_by_key(|(id, _)| order.iter().position(|o| o == id)); + let rank = |id: Option| order.iter().position(|o| *o == id); + windows.sort_by_key(|(id, _, normal)| (*normal, rank(Some(*id)))); + self.cross.front = windows.iter().filter(|(id, _, normal)| !normal || rank(Some(*id)) < rank(None)).count(); + self.cross.windows = windows.into_iter().map(|(id, rect, _)| (id, rect)).collect(); ctx.data_mut(|d| d.insert_temp(key, order)); - self.cross.windows = windows; self.cross.main = ctx.input(|i| i.viewport().outer_rect.and(i.viewport().inner_rect)).filter(|r| r.is_finite()); } - /// Whether floating windows show this frame, and whether that changed since the last. - fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { + /// Whether palettes show this frame. + fn float_visibility(&mut self, ctx: &Context) -> bool { let (focused, now) = ctx.input(|i| { let infos = || i.raw.viewports.values(); // A backend that reports no focus at all keeps the windows. @@ -320,19 +332,26 @@ impl Renderer<'_, P> { }); self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); let key = self.id.with("native-visibility"); - let (last, was) = ctx.data(|d| d.get_temp::<(f64, bool)>(key)).unwrap_or((now, true)); - let last = if focused { now } else { last }; + let last = if focused { now } else { ctx.data(|d| d.get_temp::(key)).unwrap_or(now) }; let visible = floats_shown(focused, now, last); if visible && !focused { ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); } - ctx.data_mut(|d| d.insert_temp(key, (last, visible))); - (visible, visible != was) + ctx.data_mut(|d| d.insert_temp(key, last)); + visible } - /// One floating window in its own viewport. Returns the key of its [`Place`]. - fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visibility: (bool, bool)) -> Option { - let (visible, toggled) = visibility; + /// A window holding a viewer is a normal window, so it neither vanishes nor sits over other + /// applications; the rest are palettes. + fn normal_window(&self, window: &Floating

) -> bool { + window.panels().any(|p| self.content.window_kind(p) == WindowKind::Normal) + } + + /// One floating window in its own viewport. `visible`: palettes show. Returns the key of + /// its [`Place`]. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visible: bool) -> Option { + let normal = self.normal_window(window); + let visible = visible || normal; let first = window.panels().next()?.clone(); let key = self.id.with(("native-place", viewport)); let now = ctx.input(|i| i.time); @@ -366,7 +385,7 @@ impl Renderer<'_, P> { }; seen.insert(window.id); ctx.data_mut(|d| d.insert_temp(seen_key, seen)); - Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false } + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true } } }; let spawn = place.spawn; @@ -376,7 +395,7 @@ impl Renderer<'_, P> { .with_position([spawn[0], spawn[1]]) .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) - .with_window_level(WindowLevel::AlwaysOnTop) + .with_window_level(if normal { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) .with_taskbar(false); // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and // buttons hidden: AppKit resizes a movable window from its edges and corners with its @@ -390,8 +409,10 @@ impl Renderer<'_, P> { .with_resizable(window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); - if toggled || (info.is_none() && !visible) { + // Only on a change: showing a shown window raises it and, on macOS, takes focus. + if place.shown != visible { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); + place.shown = visible; } if visible && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 6acd40dd..37028586 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -509,6 +509,20 @@ impl DockHeights { } } +/// How a floating window in its own OS window behaves (feature `native-windows`), see +/// [`DockContent::window_kind`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum WindowKind { + /// A tool palette: it stays above other windows and hides while none of the app's windows + /// has focus. + #[default] + Palette, + /// A window of its own, such as a viewer on a second display: normal window level, and it + /// stays shown while the app is in the background. + Normal, +} + #[derive(Clone)] pub(super) struct Drag

{ pub(super) panel: P, @@ -637,6 +651,11 @@ pub trait DockContent

{ fn allow_native(&self, _panel: &P) -> bool { true } + /// How the panel's native floating window behaves (feature `native-windows`). A window + /// holding any [`WindowKind::Normal`] panel is a normal window; the rest are palettes. + fn window_kind(&self, _panel: &P) -> WindowKind { + WindowKind::Palette + } /// Host-owned drop resolution for targets the renderer cannot know about. Returning /// feedback consumes the gesture, including no-op targets; the renderer then emits no move. fn resolve_drag(&mut self, _ctx: &Context, _style: &DockStyle, _request: &DropRequest<'_, P>) -> Option { @@ -722,6 +741,9 @@ impl + ?Sized> DockContent

for Overrides<'_, P, C> { fn allow_native(&self, panel: &P) -> bool { self.inner.allow_native(panel) } + fn window_kind(&self, panel: &P) -> WindowKind { + self.inner.window_kind(panel) + } fn resolve_drag(&mut self, ctx: &Context, style: &DockStyle, request: &DropRequest<'_, P>) -> Option { self.inner.resolve_drag(ctx, style, request) } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index b6e1b107..c8873dd0 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -388,6 +388,8 @@ pub struct HostWindow { /// `InnerSize`. pub rect: Rect, pub visible: bool, + /// The window level its builder asked for on its last frame. + pub level: Option, /// Frames it drew. pub frames: usize, /// Events for its next frame, in its own coordinates. @@ -443,11 +445,21 @@ impl NativeHost { let builder = &viewport.builder; let rect = Rect::from_min_size(builder.position.unwrap_or_default() + host.frame, builder.inner_size.unwrap_or(vec2(300.0, 200.0))); let visible = builder.visible.unwrap_or(true); - host.windows.entry(id).or_insert_with(|| HostWindow { rect, visible, frames: 0, events: Vec::new(), close: false, shapes: Vec::new() }); + let level = builder.window_level; + host.windows.entry(id).or_insert_with(|| HostWindow { + rect, + visible, + level, + frames: 0, + events: Vec::new(), + close: false, + shapes: Vec::new(), + }); let mut viewports = host.infos(); let (time, focused, modifiers) = (host.time, host.focus == Some(id), host.modifiers); let Some(window) = host.windows.get_mut(&id) else { return }; window.frames += 1; + window.level = level; if std::mem::take(&mut window.close) && let Some(info) = viewports.get_mut(&id) { diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 3034a125..dfcb5be7 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -374,10 +374,18 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho `Floating::with_id` sets one; a layout loads with unique ids; a window that merges into another takes the receiver's id, and a whole window that moves (floated again, or its only group dragged) keeps its own. -- **Level.** The windows stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the - document does not bury them. While none of the app's windows has focus they hide - (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide waits - 250 ms, so focus moving between the app's own windows does not flicker. +- **Kind and level.** `DockContent::window_kind` sorts panels into palettes + (`WindowKind::Palette`, the default) and viewers (`WindowKind::Normal`), such as a video + monitor on a second display. A window holding any `Normal` panel is a normal window, whichever + of its tabs shows; the rest are palettes. + - Palettes stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the document + does not bury them. While none of the app's windows has focus they hide + (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide + waits 250 ms, so focus moving between the app's own windows does not flicker. + - Normal windows have `WindowLevel::Normal` and stay shown while the app is in the + background. They stack with the main window by focus, below every palette; a drag over a + normal window that lies behind the main window targets the main window where the two + overlap, and the normal window outside the main window. - **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On Wayland, where a window cannot be placed, the bar hands the move to the compositor. On @@ -451,8 +459,9 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho - *Hit-testing.* The owner adds its desktop origin to the pointer: a native window's reported `inner_rect.min`, or while it moves by its bar, the place it was last put less the pointer's offset from the pressed point. The window under that - desktop point is a floating native window first, most recently focused first, then the main - window. A window moving by its bar is never its own target. A point in no window is on the + desktop point is a floating native window in front of the main window first (palettes, then + normal windows, most recently focused first), then the main window, then a normal window + behind it. A window moving by its bar is never its own target. A point in no window is on the desktop. - *Targets and previews.* Each window draws its drop targets in its own coordinates, and the window under the pointer draws the drop preview (zone, dock line, ghost) in its own frame. From 35bd2f389136a7413faabec328794317228389f5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 21:30:42 +0000 Subject: [PATCH 150/160] jpeg: CodeRabbit fixes: the max_alloc and damaged docs describe the combined check and block budget, SOF2's message names its coding --- crates/jpeg/README.md | 9 +++++---- crates/jpeg/src/lib.rs | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 44eaee7c..c45f290e 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -55,14 +55,15 @@ if img.damaged { /* warn: part of the image is missing */ } inverted convention (maximum = no ink) and are inverted back. With Adobe transform 2 (YCCK), C, M and Y are converted from YCbCr, which yields ink amounts directly, and K is inverted. - `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had - decoded, or the stream had more than 500 scans. The rest of that scan is left out; those - blocks keep what earlier scans decoded. A stream with no decodable image data is an error. + decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of + that scan, and every scan past the budget, is left out; those blocks keep what earlier scans + decoded. A stream with no decodable image data is an error. ## Limits `Limits { max_pixels, max_alloc }` is checked against the frame header before anything is -allocated: `width × height` against `max_pixels`, and both the output buffer and the decoder's -own coefficient and sample buffers against `max_alloc`. The defaults are 2³⁰ pixels and 8 GiB on +allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's own +coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels and 8 GiB on 64-bit targets, 1 GiB on 32-bit ones (wasm32). Fields are public, so an app sets one and keeps the other default: diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 2a4cdcc7..262e03b3 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -71,8 +71,8 @@ fn unsupported(m: impl Into) -> Error { pub struct Limits { /// Maximum `width * height`. pub max_pixels: u64, - /// Maximum bytes for the decoded output, and separately for the decoder's own - /// coefficient and sample buffers. + /// Maximum bytes for the decoded output and the decoder's own coefficient and sample + /// buffers together. pub max_alloc: u64, } @@ -361,7 +361,7 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result 0xC0 | 0xC1 => (false, false), 0xC9 => (true, false), 0xCA => (true, true), - 0xC2 => return Err(unsupported("progressive JPEG with 12-bit samples is not supported")), + 0xC2 => return Err(unsupported("progressive Huffman-coded JPEG is not supported")), 0xC3 | 0xCB => return Err(unsupported("lossless JPEG is not supported")), _ => return Err(unsupported("hierarchical JPEG is not supported")), }; From 75da958619489ef45b57e1eded85a8beb3f2280e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:52:34 -0400 Subject: [PATCH 151/160] docking: panels closed one by one reopen into one group (#290) * docking: panels closed one by one reopen into one group Showing a hidden panel whose anchor is hidden too follows the chain of hidden anchors to its far end, so a floating location no longer opens a second window. The hidden panels the restore passed then anchor back on the panel before them in the chain, on the recorded side, so reopening them joins the restored panel instead of rebuilding their group or window. A restored tab whose next tab is not in the anchor's group goes last. * docking: a panel beside an edge frame keeps its own location on reopen * docking: a panel reopened through a hidden neighbour keeps its own group state; the chain stops reversing at an edge frame --- crates/ui/src/docking/model.rs | 10 +- crates/ui/src/docking/model_tests.rs | 148 +++++++++++++++++++++++++++ crates/ui/src/docking/workspace.rs | 78 +++++++++++--- docs/ui-docking.md | 2 +- 4 files changed, 220 insertions(+), 18 deletions(-) diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs index 521ccd5d..be2ae3a3 100644 --- a/crates/ui/src/docking/model.rs +++ b/crates/ui/src/docking/model.rs @@ -784,7 +784,8 @@ impl Layout

{ /// Restore an absent panel at a captured location. Missing anchors are reported without /// changes, allowing the app to choose an explicit default; a floating panel whose - /// window is gone floats at its last rect instead. Unrelated tabs retain selection. + /// window is gone floats at its last rect instead. A tab whose next tab is not in the + /// anchor's group goes last. Unrelated tabs retain selection. pub fn restore(&mut self, panel: P, location: &Location

) -> Result<(), DockError> { self.validate()?; if self.contains(&panel) { @@ -856,7 +857,12 @@ impl Layout

{ } // A subtree that changed since (a migrated or reconciled layout) splits the anchor. if !around { - proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement: location.placement.clone() })?; + // The tab that followed may have closed or moved since; the panel then goes last. + let placement = match &location.placement { + Placement::Tab { before: Some(before) } if !proposed.same_group(before, anchor) => Placement::Tab { before: None }, + placement => placement.clone(), + }; + proposed.apply_inner(Action::OpenAt { panel: panel.clone(), anchor: anchor.clone(), placement })?; if let Some(size) = location.size { proposed.trees_mut().any(|root| restore_parent_size(root, &panel, size)); } diff --git a/crates/ui/src/docking/model_tests.rs b/crates/ui/src/docking/model_tests.rs index f208209b..9327fd4d 100644 --- a/crates/ui/src/docking/model_tests.rs +++ b/crates/ui/src/docking/model_tests.rs @@ -205,6 +205,154 @@ fn close_group_and_close_window_record_a_restorable_location_per_member() { assert_eq!(workspace, before); } +#[test] +fn panels_closed_one_by_one_reopen_into_one_group() { + let allow = |_: &u32| Permissions::default(); + let rect = [100.0, 100.0, 300.0, 400.0]; + let cycle = |workspace: &mut Workspace, close: &[u32], reopen: &[u32]| { + for &panel in close { + workspace.set_visible(panel, false, None, allow).unwrap(); + } + for &panel in reopen { + workspace.set_visible(panel, true, None, allow).unwrap_or_else(|e| panic!("{close:?} {reopen:?} {panel}: {e}")); + } + assert!(workspace.hidden.is_empty()); + }; + let orders2 = [[2, 3], [3, 2]]; + for close in orders2 { + for reopen in orders2 { + let mut workspace = Workspace::new(docked()); + cycle(&mut workspace, &close, &reopen); + let columns = dock(&workspace.layout, Side::End); + assert_eq!( + columns.iter().map(|c| panels_of(&c.groups)).collect::>(), + [vec![vec![2, 3], vec![4]], vec![vec![5]]], + "{close:?} {reopen:?}" + ); + } + } + let orders2 = [[10, 20], [20, 10]]; + for close in orders2 { + for reopen in orders2 { + let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20], 0, rect)] }); + cycle(&mut workspace, &close, &reopen); + let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) }; + assert_eq!((window.panels().copied().collect::>(), window.rect), (vec![10, 20], rect), "{close:?} {reopen:?}"); + } + } + // Three tabs: the locations do not always record the order, only the window. + let orders3 = [[10, 20, 30], [10, 30, 20], [20, 10, 30], [20, 30, 10], [30, 10, 20], [30, 20, 10]]; + for close in orders3 { + for reopen in orders3 { + let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: vec![Floating::single(vec![10, 20, 30], 0, rect)] }); + cycle(&mut workspace, &close, &reopen); + let [window] = &workspace.layout.floating[..] else { panic!("{close:?} {reopen:?}: {:?}", workspace.layout.floating) }; + let mut panels: Vec = window.panels().copied().collect(); + panels.sort_unstable(); + assert_eq!(panels, [10, 20, 30], "{close:?} {reopen:?}"); + } + } +} + +#[test] +fn a_panel_reopened_through_a_hidden_neighbour_keeps_its_own_group_state() { + let allow = |_: &u32| Permissions::default(); + let dock_of = |columns: Vec>| Layout { + root: Some(hsplit(SplitSize::FixedSecond(480.0), tabs(&[1], 0), Node::Dock { side: Side::End, columns })), + floating: Vec::new(), + }; + // Groups [2] and [3] stacked with their own heights: 2 comes back at its height, not 3's. + let stacked = dock_of(vec![ + Column::new(vec![Group { height: Some(120.0), ..g(&[2], 0) }, Group { height: Some(300.0), ..g(&[3], 0) }], 240.0), + Column::new(vec![g(&[5], 0)], 240.0), + ]); + let mut workspace = Workspace::new(stacked); + workspace.set_visible(2, false, None, allow).unwrap(); + workspace.set_visible(3, false, None, allow).unwrap(); + workspace.set_visible(2, true, None, allow).unwrap(); + let group = |w: &Workspace, panel: u32| { + dock(&w.layout, Side::End).iter().flat_map(|c| c.groups.iter()).find(|g| g.panels.contains(&panel)).cloned().unwrap() + }; + assert_eq!(group(&workspace, 2).height, Some(120.0), "{:?}", workspace.layout); + workspace.set_visible(3, true, None, allow).unwrap(); + assert_eq!(group(&workspace, 3).height, Some(300.0), "{:?}", workspace.layout); + + // [2] beside an icon-only column holding [3]: 2 comes back in a column of its own width, + // not as an icon. + let iconic = Column { iconic: true, ..Column::new(vec![g(&[3], 0)], 200.0) }; + let mut workspace = Workspace::new(dock_of(vec![Column::new(vec![g(&[2], 0)], 260.0), iconic])); + workspace.set_visible(2, false, None, allow).unwrap(); + workspace.set_visible(3, false, None, allow).unwrap(); + workspace.set_visible(2, true, None, allow).unwrap(); + let column = dock(&workspace.layout, Side::End).iter().find(|c| c.groups.iter().any(|g| g.panels.contains(&2))).cloned().unwrap(); + assert!(!column.iconic, "{:?}", workspace.layout); + assert_eq!(column.width, 260.0); +} + +#[test] +fn panels_closed_one_by_one_reopen_on_their_own_sides() { + let allow = |_: &u32| Permissions::default(); + let orders = [[2, 3], [3, 2]]; + let frames = Layout { + root: Some(hsplit(SplitSize::Ratio(0.3), tabs(&[1], 0), hsplit(SplitSize::Ratio(0.5), tabs(&[2], 0), tabs(&[3], 0)))), + floating: Vec::new(), + }; + let columns = Layout { + root: Some(hsplit( + SplitSize::FixedSecond(480.0), + tabs(&[1], 0), + Node::Dock { side: Side::End, columns: vec![Column::new(vec![g(&[2], 0)], 240.0), Column::new(vec![g(&[3], 0)], 240.0)] }, + )), + floating: Vec::new(), + }; + for close in orders { + for reopen in orders { + let mut workspace = Workspace::new(frames.clone()); + for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) { + workspace.set_visible(panel, visible, None, allow).unwrap(); + } + let order: Vec = workspace.layout.panels().into_iter().copied().collect(); + assert_eq!(order, [1, 2, 3], "{close:?} {reopen:?}: {:?}", workspace.layout); + assert!(order.iter().all(|p| matches!(workspace.layout.place(p), Some(Place::Node(_))))); + assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{close:?} {reopen:?}"); + + let mut workspace = Workspace::new(columns.clone()); + for (panel, visible) in close.iter().map(|p| (*p, false)).chain(reopen.iter().map(|p| (*p, true))) { + workspace.set_visible(panel, visible, None, allow).unwrap(); + } + let groups: Vec<_> = dock(&workspace.layout, Side::End).iter().map(|c| panels_of(&c.groups)).collect(); + assert_eq!(groups, [vec![vec![2]], vec![vec![3]]], "{close:?} {reopen:?}"); + } + } + // A panel that sat at the edge beside a hidden one keeps that one's own location: 3 opens + // right of 1, not as a tab beside 2. + let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }); + let at = |anchor: u32, placement: Placement| Location { + floating: None, + anchor: Some(anchor), + placement, + size: None, + stack: None, + sibling: Vec::new(), + group: None, + }; + workspace.hidden = vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(1, Placement::Split(Zone::Right)))]; + workspace.set_visible(2, true, None, allow).unwrap(); + workspace.set_visible(3, true, None, allow).unwrap(); + assert!(matches!(workspace.layout.place(&3), Some(Place::Node(_)))); + assert_ne!(workspace.layout.place(&2), workspace.layout.place(&3), "{:?}", workspace.layout); + // Past the edge frame the chain keeps its forward anchors: 4 does not anchor on 3 while 3 + // anchors on 4, and both still reopen. + let mut workspace = Workspace::new(Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() }); + workspace.hidden = + vec![(2, at(3, Placement::Edge(Zone::Right))), (3, at(4, Placement::Split(Zone::Right))), (4, at(1, Placement::Split(Zone::Right)))]; + workspace.set_visible(2, true, None, allow).unwrap(); + assert_eq!(workspace.hidden.iter().find(|(p, _)| *p == 4).and_then(|(_, l)| l.anchor), Some(1), "{:?}", workspace.hidden); + workspace.set_visible(3, true, None, allow).unwrap(); + workspace.set_visible(4, true, None, allow).unwrap(); + assert!(workspace.hidden.is_empty() && [1, 2, 3, 4].iter().all(|p| workspace.layout.contains(p)), "{:?}", workspace.layout); +} + #[test] fn restore_into_a_column_group_seam_and_new_column_round_trips() { let column_a = Column::new(vec![g(&[2, 3], 1), Group { minimized: true, height: Some(150.0), ..g(&[4], 0) }, g(&[9], 0)], 180.0); diff --git a/crates/ui/src/docking/workspace.rs b/crates/ui/src/docking/workspace.rs index f5ab2885..183af38d 100644 --- a/crates/ui/src/docking/workspace.rs +++ b/crates/ui/src/docking/workspace.rs @@ -6,7 +6,7 @@ use std::{collections::HashSet, hash::Hash}; -use super::model::{Action, DockError, Layout, Location, MAX_PANELS, Permissions}; +use super::model::{Action, DockError, Layout, Location, MAX_PANELS, Permissions, Placement, Zone}; #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -121,7 +121,9 @@ impl Workspace

{ /// Show or hide one panel. Showing restores its recorded location, following the chain of /// hidden anchors when its own anchor is hidden too; otherwise it opens as - /// [`Action::Open`] with `fallback` as the anchor, following `permissions`. + /// [`Action::Open`] with `fallback` as the anchor, following `permissions`. The hidden + /// panels on that chain reopen beside it later, so a group or window closed one panel at a + /// time comes back as one. /// A shown panel becomes the active tab of its group. Hiding follows `permissions` and /// [`Self::locked`], as [`Self::apply`] does, so a protected canvas stays shown. pub fn set_visible(&mut self, panel: P, visible: bool, fallback: Option

, permissions: impl Fn(&P) -> Permissions) -> Result<(), DockError> { @@ -136,7 +138,7 @@ impl Workspace

{ return self.apply(Action::Activate { panel }, |_| Permissions::default()).map(drop); } let mut layout = self.layout.clone(); - if self.restore_into(&mut layout, &panel).is_err() { + if self.restore_from_hidden(&mut layout, &panel).is_err() { layout = self.layout.clone(); layout.apply_with_permissions(Action::Open { panel: panel.clone(), anchor: fallback }, &permissions)?; } @@ -146,19 +148,42 @@ impl Workspace

{ Ok(()) } - fn restore_into(&self, layout: &mut Layout

, panel: &P) -> Result<(), DockError> { + /// Restore `panel` where the chain of hidden anchors from its location ends, else at the + /// nearest location before that end that restores. The hidden panels it passed then anchor + /// on the panel before them in the chain, on the recorded side, so reopening them joins it + /// instead of rebuilding their group or window a second time. + fn restore_from_hidden(&mut self, layout: &mut Layout

, panel: &P) -> Result<(), DockError> { let find = |p: &P| self.hidden.iter().find(|(q, _)| q == p).map(|(_, location)| location); - let mut location = find(panel).ok_or(DockError::MissingTarget)?; - for _ in 0..=self.hidden.len() { - match layout.restore(panel.clone(), location) { - Err(DockError::MissingTarget) => { - let next = location.anchor.as_ref().filter(|anchor| *anchor != panel).and_then(find); - location = next.ok_or(DockError::MissingTarget)?; - } - result => return result, - } + let mut chain = vec![(panel.clone(), find(panel).ok_or(DockError::MissingTarget)?.clone())]; + while let Some(anchor) = chain.last().and_then(|(_, location)| location.anchor.as_ref()) + && !chain.iter().any(|(p, _)| p == anchor) + && let Some(location) = find(anchor) + { + chain.push((anchor.clone(), location.clone())); } - Err(DockError::MissingTarget) + // A floating location whose anchor is hidden would open a window of its own, even when + // the chain leads to an open one. A neighbour's location brings the panel's own group + // and column state (height, width, icon-only), not the neighbour's. + let own = chain.first().and_then(|(_, location)| location.group.clone()); + let used = (0..chain.len()) + .rev() + .find_map(|i| { + let location = Location { group: own.clone().or_else(|| chain[i].1.group.clone()), ..chain[i].1.clone() }; + match layout.restore(panel.clone(), &location) { + Err(DockError::MissingTarget) => None, + result => Some(result.map(|()| i)), + } + }) + .unwrap_or(Err(DockError::MissingTarget))?; + // Past an outer-edge frame the relation can't be reversed: the rest of the chain keeps + // its forward anchors, so no two hidden panels anchor on each other. + for i in 1..=used { + let (before, location) = &chain[i - 1]; + let Some((_, own)) = self.hidden.iter_mut().find(|(p, _)| p == &chain[i].0) else { continue }; + let Some(location) = reversed(own, location, before) else { break }; + *own = location; + } + Ok(()) } /// Fit a loaded workspace to the panels the application registers now. An invalid @@ -216,7 +241,7 @@ impl Workspace

{ let stuck: Vec

= self.hidden.iter().map(|(p, _)| p).filter(|p| registered(p) && !permissions(p).closable).cloned().collect(); for panel in stuck { let mut layout = self.layout.clone(); - let shown = self.restore_into(&mut layout, &panel).is_ok() + let shown = self.restore_from_hidden(&mut layout, &panel).is_ok() || default.layout.location(&panel).is_ok_and(|location| layout.restore(panel.clone(), &location).is_ok()) || layout.open_at_edge(panel.clone()).is_ok(); if shown { @@ -268,3 +293,26 @@ fn close_locations(layout: &Layout

, closed: &[P]) -> Ve locations.reverse(); locations } + +/// The location of a hidden panel beside `panel`, whose `location` anchors on it: the same +/// relation from the other side. `None` for an outer-edge frame, which only sat beside it. +fn reversed(hidden: &Location

, location: &Location

, panel: &P) -> Option> { + let next = match &hidden.placement { + Placement::Tab { before } => before.clone(), + _ => None, + }; + let (placement, size) = match &location.placement { + // `before` names the anchor only when `panel` came first and the anchor second; the + // anchor's own `before` then still names the tab after it. + Placement::Tab { before } if before == &location.anchor => (Placement::Tab { before: next }, None), + Placement::Tab { .. } | Placement::Split(Zone::Center) => (Placement::Tab { before: Some(panel.clone()) }, None), + Placement::Split(Zone::Left) => (Placement::Split(Zone::Right), location.size), + Placement::Split(Zone::Right) => (Placement::Split(Zone::Left), location.size), + Placement::Split(Zone::Top) => (Placement::Split(Zone::Bottom), location.size), + Placement::Split(Zone::Bottom) => (Placement::Split(Zone::Top), location.size), + Placement::Group { below, .. } => (Placement::Group { below: !below, height: None }, None), + Placement::Column { after } => (Placement::Column { after: !after }, None), + Placement::Edge(_) => return None, + }; + Some(Location { floating: None, anchor: Some(panel.clone()), placement, size, sibling: Vec::new(), ..hidden.clone() }) +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index dfcb5be7..06e9334d 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -113,7 +113,7 @@ an optional saved arrangement for Reset, and `locked`. | Method | Does | |---|---| | `apply`, `apply_all` | Apply actions all or none, with the app's permissions. Closed panels are recorded in `hidden`. While `locked`, moving, floating and closing are refused; `SetNative`, which records the OS windows, still applies. | -| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. | +| `set_visible(panel, visible, fallback, permissions)` | Hide a panel that may close, or show it at its recorded place, else as `Open` beside `fallback`, or where `Open` without an anchor puts it. A group or window closed one panel at a time comes back as one group as its panels are shown again. | | `reconcile(registered, permissions, default)` | Fit a loaded workspace to the panels the app has now. Unknown panels move to `hidden`, so they come back when registered again. A registered panel that cannot close, such as the canvas, is always shown. | | `reset(default)` | Return to the saved arrangement, or to `default` for a built-in workspace. | | `validate` | Check a loaded workspace before use. | From d12a6db7508f7d55026921244bb50d0ed8589cf1 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:53:29 -0400 Subject: [PATCH 152/160] docking: an input hook and dropped files for every native window (#293) * docking: an input hook and dropped files for every native window eframe runs raw_input_hook only for the root viewport, and each native window's dropped files arrive only in that window's input. DockArea::native_input_hook runs an app hook on each native window's RawInput through an egui plugin, before egui reads it, and DockOutput::window_files reports files hovered or dropped on native windows with the window and the panel under the pointer. The test host takes hovered and dropped files per window. * docking: a discarded pass reports no hovered files; share hook state through a mutex * docking: one file report per native window when its pass is discarded --- crates/ui/src/docking/mod.rs | 4 +- crates/ui/src/docking/native_tests.rs | 64 ++++++++++++++++++++++++ crates/ui/src/docking/render/cross.rs | 3 ++ crates/ui/src/docking/render/mod.rs | 7 +++ crates/ui/src/docking/render/native.rs | 67 ++++++++++++++++++++++++-- crates/ui/src/docking/surface.rs | 50 +++++++++++++++++++ crates/ui/src/docking/testing.rs | 28 +++++++++++ docs/ui-docking.md | 23 +++++++-- 8 files changed, 238 insertions(+), 8 deletions(-) diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs index fd24506f..86198840 100644 --- a/crates/ui/src/docking/mod.rs +++ b/crates/ui/src/docking/mod.rs @@ -31,8 +31,8 @@ pub use hits::{Divider, Hit, ResizeEdge}; pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Monitor, Node, Permissions, Placement, StackEntry, Zone}; pub use render::{close_flyout, flyout, open_flyout}; pub use surface::{ - DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, WindowKind, - begin_drag, cancel_drag, float_space, is_dragging, + DockArea, DockContent, DockEvent, DockHeights, DockOutput, DockStyle, DropFeedback, DropRequest, FloatSpace, HidePanels, PanelLimits, + WindowFiles, WindowKind, begin_drag, cancel_drag, float_space, is_dragging, }; pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, tab_insertion_marker, zone_rect, zone_shape}; pub use workspace::Workspace; diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 351aeab9..348820b1 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -25,6 +25,8 @@ struct Content { focus_text: bool, /// The panel with a button that takes focus. focused_button: Option, + /// Pointer buttons pressed in each panel's body. + pressed: Vec<(u32, PointerButton)>, } impl DockContent for Content { @@ -39,6 +41,10 @@ impl DockContent for Content { if self.focused_button == Some(*panel) { ui.button("OK").request_focus(); } + if ui.rect_contains_pointer(ui.max_rect()) { + let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); + self.pressed.extend(pressed.map(|b| (*panel, b))); + } } fn logical_label(&self, panel: &u32) -> String { @@ -1040,6 +1046,64 @@ fn a_native_window_forwards_no_keys_while_one_of_its_widgets_has_focus() { assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); } +#[test] +fn the_input_hook_rewrites_each_native_windows_input_before_its_widgets_see_it() { + // As an app maps Ctrl+click to a secondary click on macOS. + let mut d = driver(layout(true)).with_native_input_hook(|_, input| { + for event in &mut input.events { + if let Event::PointerButton { button, .. } = event { + *button = PointerButton::Secondary; + } + } + }); + let id = id_of(&d); + d.window_click(id, pos2(60.0, 150.0)); + assert_eq!(d.content.pressed, vec![(5, PointerButton::Secondary)]); + + // The main window's input is the app's own `raw_input_hook`'s. + d.content.pressed.clear(); + d.click(pos2(100.0, 300.0)); + assert_eq!(d.content.pressed, vec![(1, PointerButton::Primary)]); +} + +#[derive(Debug)] +struct File(std::path::PathBuf); + +impl egui::DroppedFile for File { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(Vec::new()) + } +} + +#[test] +fn files_dragged_onto_a_native_window_are_reported_with_its_panel() { + let mut d = driver(layout(true)); + let (id, viewport) = (id_of(&d), d.viewport(id_of(&d))); + let hovered = egui::HoveredFile { path: Some("a.png".into()), mime: String::new() }; + d.host().windows.get_mut(&viewport).unwrap().hovered_files = vec![hovered.clone()]; + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + let [files] = out.window_files.as_slice() else { panic!("{:?}", out.window_files) }; + assert_eq!((files.window, files.viewport, files.panel, files.pos), (id, viewport, 5, Some(pos2(60.0, 150.0)))); + assert_eq!(files.hovered, vec![hovered]); + assert!(files.dropped.is_empty()); + + { + let mut host = d.host(); + let window = host.windows.get_mut(&viewport).unwrap(); + window.hovered_files.clear(); + window.dropped_files = vec![std::sync::Arc::new(File("a.png".into()))]; + } + let out = d.window_step(id, Vec::new()); + let [files] = out.window_files.as_slice() else { panic!("{:?}", out.window_files) }; + assert_eq!(files.panel, 5); + assert_eq!(files.dropped.iter().map(|f| f.path().to_path_buf()).collect::>(), vec![std::path::PathBuf::from("a.png")]); + assert!(d.step(Vec::new()).window_files.is_empty()); +} + #[test] fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { // Escape cancels it, and is no shortcut. diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index fca134e7..fafdb4b2 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -99,6 +99,8 @@ pub(in crate::docking) struct Cross

{ pub moved: Option, /// Resolving a window move that docks whatever `title_bar_docks` says. pub docks: bool, + /// The panel whose body is under the pointer in the native window drawing now. + pub pointed: Option

, } impl

Default for Cross

{ @@ -115,6 +117,7 @@ impl

Default for Cross

{ bar: false, moved: None, docks: false, + pointed: None, } } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 3459656a..2aad65cf 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -20,6 +20,9 @@ pub(in crate::docking) use { native::{APPKIT_CORNER, MACOS_CLOSE, Session, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, }; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use native::{InputHook, set_input_hook}; + /// The viewport of the native window `window` of the dock area `area`. #[cfg(feature = "native-windows")] pub(in crate::docking) fn native_viewport(area: Id, window: u64) -> egui::ViewportId { @@ -364,6 +367,10 @@ impl Renderer<'_, P> { if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.any_pressed()) { self.output.focused = Some(panel.clone()); } + #[cfg(feature = "native-windows")] + if child.rect_contains_pointer(rect) { + self.cross.pointed = Some(panel.clone()); + } } } diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 94a5069f..5d6f217a 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -9,8 +9,8 @@ use std::{fmt::Debug, hash::Hash}; use egui::{ - Context, CursorIcon, Event, Id, Key, Modifiers, Pos2, Rect, ResizeDirection, Sense, Ui, Vec2, ViewportBuilder, ViewportClass, ViewportCommand, - ViewportId, ViewportInfo, WindowLevel, pos2, vec2, + Context, CursorIcon, Event, Id, Key, Modifiers, Pos2, RawInput, Rect, ResizeDirection, Sense, Ui, Vec2, ViewportBuilder, ViewportClass, + ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, vec2, }; use super::Renderer; @@ -18,7 +18,7 @@ use super::cross::{DragPointer, Surface}; use crate::docking::chrome::{ChromeStyle, TitleBar}; pub(in crate::docking) use crate::docking::placement::BAR_HEIGHT; use crate::docking::placement::{kept_reachable, monitor_of, restore, sanitized}; -use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, WindowKind}; +use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, WindowFiles, WindowKind}; /// Smallest native window. const MIN_SIZE: [f32; 2] = [160.0, 100.0]; @@ -418,11 +418,13 @@ impl Renderer<'_, P> { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); + ctx.with_plugin(|input: &mut NativeInput| input.windows.insert(viewport, self.id)); let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, self.style.floating_background); let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); + self.cross.pointed = None; self.floating_body(ui, window, body, full); if debug_close() { debug_outlines(ui, full, &buttons); @@ -450,6 +452,7 @@ impl Renderer<'_, P> { surface.origin = self.window_gestures(ui, window); self.paint_cross(ui, &mut surface); self.forward_events(ui); + self.report_files(ui, window, viewport); ui.input(|i| Report { closed: i.viewport().close_requested(), placed: placed(i.viewport()), @@ -661,6 +664,26 @@ impl Renderer<'_, P> { self.cross.release = Some((drag, pointer)); } + /// Files dragged over or dropped on the window this frame, with the panel under the pointer. + fn report_files(&mut self, ui: &Ui, window: &Floating

, viewport: ViewportId) { + let (hovered, dropped, pos) = ui.input(|i| (i.raw.hovered_files.clone(), i.raw.dropped_files.clone(), i.pointer.latest_pos())); + if hovered.is_empty() && dropped.is_empty() { + return; + } + // The window system may send no pointer moves while files are dragged in. + let Some(panel) = self.cross.pointed.clone().or_else(|| crate::docking::dock_model::first_active(&window.root).cloned()) else { return }; + // A window whose own pass is discarded draws twice: keep one report, with the latest + // hovered files and every dropped one. + if let Some(files) = self.output.window_files.iter_mut().find(|f| f.viewport == viewport) { + files.panel = panel; + files.pos = pos; + files.hovered = hovered; + files.dropped.extend(dropped); + } else { + self.output.window_files.push(WindowFiles { window: window.id, viewport, panel, pos, hovered, dropped }); + } + } + /// Keys, text and clipboard events the window's widgets left over, for the application's /// shortcuts. Nothing while a widget of the window has keyboard focus: its keys are its own. fn forward_events(&mut self, ui: &Ui) { @@ -681,6 +704,44 @@ impl Renderer<'_, P> { } } +/// Input hooks of dock areas for their native windows, run by egui before it reads each +/// window's input. eframe runs `App::raw_input_hook` for the main window only. +#[derive(Default)] +struct NativeInput { + hooks: std::collections::HashMap>, + /// The dock area of each native window shown this frame. + windows: std::collections::HashMap, +} + +pub(in crate::docking) type InputHook = Box; + +impl egui::Plugin for NativeInput { + fn debug_name(&self) -> &'static str { + "craft-ui docking native input" + } + + fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) { + if let Some(hook) = self.windows.get(&input.viewport_id).and_then(|area| self.hooks.get(area)) { + (hook.lock())(ctx, input); + } + } +} + +/// Set or clear the input hook of the dock area `area` for this frame, and forget its native +/// windows until they show again. +pub(in crate::docking) fn set_input_hook(ctx: &Context, area: Id, hook: Option) { + if hook.is_some() && ctx.plugin_opt::().is_none() { + ctx.add_plugin(NativeInput::default()); + } + ctx.with_plugin(|input: &mut NativeInput| { + input.windows.retain(|_, a| *a != area); + match hook { + Some(hook) => input.hooks.insert(area, egui::mutex::Mutex::new(hook)), + None => input.hooks.remove(&area), + } + }); +} + /// Frames running whose native windows are all new, with the last frame's panels, before /// [`Renderer::warn_rebuilt`] warns. #[cfg(debug_assertions)] diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 37028586..cef9fe0d 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -261,6 +261,28 @@ pub struct DockOutput

{ /// forwarded any. egui-winit turns Cmd/Ctrl+C, X and V into `Copy`, `Cut` and `Paste` /// without the key, so these tell them from the keyboard's own Copy, Cut and Paste keys. pub forwarded_modifiers: Option, + /// Files dragged over or dropped on floating windows in their own OS windows this frame, + /// one entry per window (feature `native-windows`). egui hands each OS window only its own + /// input, so the main window's `RawInput` never holds them. + pub window_files: Vec>, +} + +/// Files dragged over or dropped on a native floating window in one frame. +#[derive(Clone, Debug)] +pub struct WindowFiles

{ + /// [`super::Floating::id`] of the window. + pub window: u64, + /// The window's egui viewport. + pub viewport: egui::ViewportId, + /// The panel whose body is under the pointer, else the window's shown panel: the window + /// system may send no pointer moves while files are dragged in. + pub panel: P, + /// The pointer's last position in the window's coordinates, where egui has one. + pub pos: Option, + /// The window's `RawInput::hovered_files`. + pub hovered: Vec, + /// The window's `RawInput::dropped_files`. + pub dropped: Vec, } impl

Default for DockOutput

{ @@ -274,6 +296,7 @@ impl

Default for DockOutput

{ events: Vec::new(), forwarded_events: Vec::new(), forwarded_modifiers: None, + window_files: Vec::new(), } } } @@ -777,6 +800,8 @@ pub struct DockArea<'a, P = ()> { float_bounds: Option, monitors: Vec, native_windows: Option, + #[cfg(feature = "native-windows")] + input_hook: Option, } impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { @@ -792,6 +817,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { float_bounds: None, monitors: Vec::new(), native_windows: None, + #[cfg(feature = "native-windows")] + input_hook: None, } } @@ -821,6 +848,19 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { super::render::native_viewport(id, window) } + /// Rewrite the input of each native window of this area before egui reads it (feature + /// `native-windows`), as `eframe::App::raw_input_hook` does for the main window only. Pass + /// it every frame; a frame without it runs none. The hook runs for every pass of each + /// window's frame, before any widget sees the input, so rewritten pointer events count as + /// sent. `RawInput::viewport_id` names the window (see [`Self::window_viewport`]). State + /// shared with the app's own `raw_input_hook`, which takes `&mut self`, goes in an + /// `Arc>`. + #[cfg(feature = "native-windows")] + pub fn native_input_hook(mut self, hook: impl FnMut(&Context, &mut egui::RawInput) + Send + 'static) -> Self { + self.input_hook = Some(Box::new(hook)); + self + } + /// [D] Auto-Collapse Iconic Panels (default on): a flyout closes on Escape or a press /// outside it. Off, it stays open until its icon or another icon is clicked. pub fn auto_collapse_iconic(mut self, on: bool) -> Self { @@ -936,6 +976,8 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { /// Render with every label, policy and size from `content`. pub fn show_content(self, ui: &mut Ui, layout: &Layout

, style: &DockStyle, content: &mut dyn DockContent

) -> DockOutput

{ + #[cfg(feature = "native-windows")] + super::render::set_input_hook(ui.ctx(), self.id, self.input_hook); let rect = ui.available_rect_before_wrap(); if let Err(error) = layout.validate() { cancel_drag::

(ui.ctx(), self.id); @@ -996,6 +1038,14 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); + // egui runs a discarded pass again with the same hovered files (dropped ones are + // taken), so only the next pass reports them. + if ui.ctx().will_discard() { + renderer.output.window_files.retain_mut(|files| { + files.hovered.clear(); + !files.dropped.is_empty() + }); + } renderer.output } } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index c8873dd0..49fe34d6 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -18,6 +18,8 @@ use super::{DockArea, DockContent, DockOutput, DockStyle, Hit, Layout, Monitor}; const NUDGE: egui::Vec2 = egui::Vec2::new(8.0, 6.0); type StyleFn = Box; +#[cfg(feature = "native-windows")] +type InputHook = std::sync::Arc; /// A [`DockArea`] in a headless egui context, with its layout and content. Each frame advances /// the clock by 1/60 s, so double-clicks register. [`Self::step`] applies what a frame emits, @@ -55,6 +57,9 @@ pub struct DockDriver { host: Option>>, #[cfg(feature = "native-windows")] no_host: std::cell::RefCell, + /// Passed to [`DockArea::native_input_hook`]. + #[cfg(feature = "native-windows")] + input_hook: Option, style: Option, shapes: Vec, time: f64, @@ -84,6 +89,8 @@ impl> Do host: None, #[cfg(feature = "native-windows")] no_host: std::cell::RefCell::new(NativeHost::new(Pos2::ZERO)), + #[cfg(feature = "native-windows")] + input_hook: None, style: None, shapes: Vec::new(), time: 0.0, @@ -128,6 +135,8 @@ impl> Do } let (layout, content, area, id) = (&self.layout, &mut self.content, self.area, self.id); let monitors = self.monitors.clone(); + #[cfg(feature = "native-windows")] + let input_hook = self.input_hook.clone(); let (style_fn, maximized, raise, auto, bounds, locked, native) = (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds, self.locked, self.native_windows); let mut full = self.ctx.run_ui(input, |ui| { @@ -148,6 +157,10 @@ impl> Do if let Some(bounds) = bounds { dock = dock.float_bounds(bounds); } + #[cfg(feature = "native-windows")] + if let Some(hook) = input_hook.clone() { + dock = dock.native_input_hook(move |ctx, input| hook(ctx, input)); + } output = dock.show_content(ui, layout, &style, content); }); }); @@ -176,6 +189,13 @@ impl> Do self } + /// Pass `hook` to [`DockArea::native_input_hook`] every frame (feature `native-windows`). + #[cfg(feature = "native-windows")] + pub fn with_native_input_hook(mut self, hook: impl Fn(&Context, &mut RawInput) + Send + Sync + 'static) -> Self { + self.input_hook = Some(std::sync::Arc::new(hook)); + self + } + /// The stand-in for OS windows: without [`Self::with_native_host`], one with no windows /// that nothing uses. #[cfg(feature = "native-windows")] @@ -396,6 +416,10 @@ pub struct HostWindow { pub events: Vec, /// Ask to close it on its next frame, as the OS close button does. pub close: bool, + /// Files dragged over it, in every frame's input until cleared. + pub hovered_files: Vec, + /// Files dropped on it on its next frame. + pub dropped_files: Vec, /// Shapes of its last frame. pub shapes: Vec, } @@ -453,6 +477,8 @@ impl NativeHost { frames: 0, events: Vec::new(), close: false, + hovered_files: Vec::new(), + dropped_files: Vec::new(), shapes: Vec::new(), }); let mut viewports = host.infos(); @@ -470,6 +496,8 @@ impl NativeHost { viewports, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, window.rect.size())), events: std::iter::once(Event::ModifiersChanged(modifiers)).chain(std::mem::take(&mut window.events)).collect(), + hovered_files: window.hovered_files.clone(), + dropped_files: std::mem::take(&mut window.dropped_files), focused, time: Some(time), ..Default::default() diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 06e9334d..8b54d60b 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -450,6 +450,21 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho `Paste` without the key). The app feeds them to the shortcut handling it runs on the main window. A window where any widget has keyboard focus forwards nothing, and an Escape that cancels a drag is not forwarded. +- **Input hook.** eframe runs `App::raw_input_hook` for the main window only. An app that + rewrites input there (say, Ctrl+click to a secondary click on macOS, or blocking input + during automation) passes the same rewrite to `DockArea::native_input_hook` every frame as a + closure. eframe's hook takes `&mut self`, so state it shares with the closure goes in an + `Arc>`. The dock area runs it on each native window's `RawInput` (`viewport_id` names the window; see + `DockArea::window_viewport`). It runs as an egui `Plugin::input_hook`, before egui reads the + input, so widgets see rewritten pointer and key events as if the window system had sent them. + It runs once per pass, so a frame that egui runs twice calls it a second time with the + events already taken. The hook must be `Send + 'static`, as egui plugins are. +- **Dropped files.** Files dragged over or dropped on a native window come back in + `DockOutput::window_files`, one `WindowFiles` per window that had any this frame: the + window's id and viewport, the panel whose body is under the pointer (else the window's + shown panel, since the window system may send no pointer moves during a file drag), the + pointer's last position in the window, and the window's `hovered_files` and + `dropped_files`. Files on the main window stay in its own `RawInput`. - **Drags.** Tabs, groups and whole windows drag between the app's OS windows. - *Ownership.* The window where the button went down owns the drag: the window system sends it the pointer's moves and the release while the button is held, inside the window or not. So @@ -495,8 +510,8 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho compositor, which never docks it. On the web and wherever egui embeds viewports, nothing changes: floating windows stay in the -page at their in-window rect, `native` and the monitor fields are kept but unused, and -`forwarded_events` stays empty. Without the feature, the fields exist and are never set by the +page at their in-window rect, `native` and the monitor fields are kept but unused, the input +hook never runs, and `forwarded_events` and `window_files` stay empty. Without the feature, the fields exist and are never set by the renderer. ## Labels @@ -543,7 +558,9 @@ OS windows: the main window's content sits at `origin` on the desktop, and each runs its own frame with its own input and desktop rect (`host()`, `window_step`, `window_click`). `NativeHost` can report a monitor size (`monitor_size`), give windows a frame between their outer position and content (`frame`), land `OuterPosition` a frame late (`lag`), -and report no positions (`no_position`, as on Wayland). +and report no positions (`no_position`, as on Wayland). A `HostWindow` takes files dragged +over it (`hovered_files`) or dropped on it (`dropped_files`), and `with_native_input_hook` +passes a hook to `DockArea::native_input_hook` every frame. `crates/ui/examples/docking_native.rs` (`cargo run -p craft-ui --example docking_native --features native-windows`) shows a dock with two native floating windows and prints every From e96bf9552b75d28c1b397c83e301e8cc182a3cf1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:02:49 +0000 Subject: [PATCH 153/160] jpeg: arithmetic scans take zeros at a marker without limit libjpeg's encoder drops trailing zero bytes, so a flat tail of a few thousand blocks decodes from zeros supplied at EOI. Damage is data that runs to the end of the file or a lost restart marker. whitetail-ari.jpg (cjpeg -arithmetic) and its Huffman transcode cover it. --- crates/jpeg/README.md | 7 +++- .../jpeg/fuzz/corpus/decode/whitetail-ari.jpg | Bin 0 -> 3492 bytes crates/jpeg/src/lib.rs | 35 +++++++++--------- crates/jpeg/src/tests.rs | 30 +++++++++++---- crates/jpeg/tests/conformance.rs | 19 +++++++++- crates/jpeg/tests/data/SHA256SUMS | 2 + crates/jpeg/tests/data/SOURCE.md | 22 ++++++++++- crates/jpeg/tests/data/whitetail-ari.jpg | Bin 0 -> 3492 bytes crates/jpeg/tests/data/whitetail-huffman.jpg | Bin 0 -> 7011 bytes 9 files changed, 86 insertions(+), 29 deletions(-) create mode 100644 crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg create mode 100644 crates/jpeg/tests/data/whitetail-ari.jpg create mode 100644 crates/jpeg/tests/data/whitetail-huffman.jpg diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index c45f290e..606ea080 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -57,7 +57,10 @@ if img.damaged { /* warn: part of the image is missing */ } - `damaged` is set when entropy-coded data was cut short or corrupt after at least one MCU had decoded, or its scans would decode more than 64 passes over the frame's blocks. The rest of that scan, and every scan past the budget, is left out; those blocks keep what earlier scans - decoded. A stream with no decodable image data is an error. + decoded. A stream with no decodable image data is an error. Arithmetic-coded data that stops + early at a marker reads as the zero bytes encoders drop at the end of a scan (libjpeg-turbo + treats it the same way), so for arithmetic scans only data that runs to the end of the file + and a missing restart marker count as damage. ## Limits @@ -105,6 +108,8 @@ SOF11 (lossless) falls in that range and returns `Unsupported`. - `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how the derived files were made: `tests/data/SOURCE.md`): - `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode. + - `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros + supplied at EOI) decodes undamaged and equal to its Huffman transcode. - `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit decode, within the float-against-integer IDCT difference (mean under 0.75, max 4). - `tests/mutation.rs`: a seeded mutation run (truncations, bit flips, byte and frame-header diff --git a/crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg b/crates/jpeg/fuzz/corpus/decode/whitetail-ari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e7011fb6e1cea60f901bf5362a46fbff6ba1c37b GIT binary patch literal 3492 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 S{{a60{{XV^@U+Bo>i^l{%!YIT literal 0 HcmV?d00001 diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index 262e03b3..a89614b4 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -595,45 +595,45 @@ const QE: [(u32, u8, u8, bool); 113] = [ /// Qe index in bits 0–6 and the MPS in bit 7. struct Arith<'a> { d: &'a [u8], + /// `d` ends at a marker rather than at the end of the file. + at_marker: bool, pos: usize, c: u32, a: u32, ct: u32, - /// Zero bytes fed in since the start or the last restart because the data had ended. - pad: u32, + /// The decoder needed bytes past the end of the file. + eof: bool, /// A restart was due but its marker was missing. lost: bool, } impl<'a> Arith<'a> { - /// The most zero bytes a scan or restart interval may consume past the end of its data. - /// The decoder reads two bytes ahead (INITDEC), and encoders drop trailing zero bytes - /// (libjpeg's `finish_pass` does, as D.1.8 allows), so a valid stream consumes a few. - /// Past that the data was cut short, and the decoder would only decode the zeros. - const MAX_PAD: u32 = 8; - - fn new(d: &'a [u8]) -> Self { - let mut ar = Arith { d, pos: 0, c: 0, a: 0, ct: 0, pad: 0, lost: false }; + fn new(d: &'a [u8], at_marker: bool) -> Self { + let mut ar = Arith { d, at_marker, pos: 0, c: 0, a: 0, ct: 0, eof: false, lost: false }; ar.init(); ar } + /// At a marker the decoder is fed zeros for as long as it asks (D.2.6): encoders drop + /// trailing zero bytes (libjpeg's `finish_pass` does, as D.1.8 allows), and a flat tail of + /// a few thousand blocks can decode entirely from those zeros. Running out at the end of + /// the file is damage. fn byte_in(&mut self) { let v = next_byte(self.d, &mut self.pos).unwrap_or_else(|| { - self.pad = self.pad.saturating_add(1); + let at_restart = matches!(self.d.get(self.pos + 1), Some(0xD0..=0xD7)); + self.eof |= !at_restart && !self.at_marker; 0 }); self.c = self.c.wrapping_add(u32::from(v) << 8); } - /// The scan's data ran out, or a restart marker is missing. + /// The data ended without a marker, or a restart marker is missing. fn exhausted(&self) -> bool { - self.lost || self.pad > Self::MAX_PAD + self.lost || self.eof } /// INITDEC (D.2.7). fn init(&mut self) { - self.pad = 0; self.a = 0x10000; self.c = 0; self.byte_in(); @@ -1037,7 +1037,8 @@ fn parse_scan(seg: &[u8], frame: &Frame) -> Result { } /// Decode one scan's entropy-coded data `d`, counting the decoded MCUs in `done`. -fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: &mut usize) -> Result<(), Error> { +/// `at_marker` says whether a marker follows `d` (otherwise it runs to the end of the file). +fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], at_marker: bool, done: &mut usize) -> Result<(), Error> { for &(ci, _, _) in &scan.comps { let comp = frame.comps.get_mut(ci).ok_or_else(|| err("bad scan component"))?; if comp.quant.is_none() { @@ -1054,7 +1055,7 @@ fn decode_scan(frame: &mut Frame, tables: &Tables, scan: &Scan, d: &[u8], done: (true, _, _) => ArithMode::AcRefine, }; let mut dec = ArithScan { - ar: Arith::new(d), + ar: Arith::new(d, at_marker), mode, ss: scan.ss, se: scan.se, @@ -1154,7 +1155,7 @@ fn decode_coefficients(b: &[u8], limits: &Limits) -> Result<(Frame, Tables, bool let end = scan_end(b, i); let data = b.get(i..end).unwrap_or_default(); scans += 1; - if let Err(e) = decode_scan(f, &tables, &scan, data, &mut mcus) { + if let Err(e) = decode_scan(f, &tables, &scan, data, end < b.len(), &mut mcus) { if mcus == 0 { return Err(e); } diff --git a/crates/jpeg/src/tests.rs b/crates/jpeg/src/tests.rs index 20cbbba6..3398b0e7 100644 --- a/crates/jpeg/src/tests.rs +++ b/crates/jpeg/src/tests.rs @@ -688,8 +688,9 @@ fn huffman_scan_out_of_data_is_not_clean() { assert!(decode(&cut, &Limits::default(), None).unwrap().damaged); } -/// Arithmetic data cut short before a valid EOI is reported damaged, and the blocks before the -/// cut are kept: past the end the decoder is fed zeros, and a restart marker goes missing. +/// Arithmetic data that runs to the end of the file, or loses a restart marker, is reported +/// damaged, and the blocks before the cut are kept. Data cut short before EOI with no restart +/// interval reads as dropped trailing zeros and can't be detected. #[test] fn arithmetic_scan_out_of_data_is_damaged() { let blocks = coefficients(64, 11, 500); @@ -698,16 +699,29 @@ fn arithmetic_scan_out_of_data_is_damaged() { assert_eq!(decoded_blocks(&full), blocks, "ri={ri}"); let data = last_scan_data(&full); let end = full.len() - 2; - for cut in [data + (end - data) / 2, data] { + for cut in [data + (end - data) / 2, data + 1] { let mut s = full[..cut].to_vec(); if s.last() == Some(&0xFF) { s.pop(); } - s.extend([0xFF, 0xD9]); - let (frame, _, damaged) = decode_coefficients(&s, &Limits::default()).unwrap(); - assert!(damaged, "ri={ri} cut at {}", cut - data); - if cut > data { - assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri}"); + for eoi in [false, true] { + if eoi && ri == 0 { + continue; + } + let mut s = s.clone(); + if eoi { + s.extend([0xFF, 0xD9]); + } + // With one byte of data the first MCU fails, which is an error, not damage. + match decode_coefficients(&s, &Limits::default()) { + Ok((frame, _, damaged)) => { + assert!(damaged, "ri={ri} eoi={eoi} cut at {}", cut - data); + if cut > data + 1 { + assert_eq!(frame.comps[0].coefs[0], blocks[0], "ri={ri} eoi={eoi}"); + } + } + Err(_) => assert_eq!(cut, data + 1, "ri={ri} eoi={eoi}"), + } } } } diff --git a/crates/jpeg/tests/conformance.rs b/crates/jpeg/tests/conformance.rs index 3e449263..e9fab449 100644 --- a/crates/jpeg/tests/conformance.rs +++ b/crates/jpeg/tests/conformance.rs @@ -7,13 +7,18 @@ fn fixture(name: &str) -> Vec { std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")) } -fn decode_fixture(name: &str) -> Decoded { +fn decode_clean(name: &str) -> Decoded { let img = decode(&fixture(name), &Limits::default(), None).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}"); assert!(!img.damaged, "{name}"); img } +fn decode_fixture(name: &str) -> Decoded { + let img = decode_clean(name); + assert_eq!((img.width, img.height, img.components), (227, 149, 3), "{name}"); + img +} + /// Mean and maximum absolute difference. fn diff(a: impl Iterator, b: impl Iterator) -> (f64, f64) { let (mut sum, mut max, mut n) = (0.0, 0.0f64, 0usize); @@ -36,6 +41,16 @@ fn arithmetic_coded_image_matches_its_huffman_transcode() { assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg"); } +/// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower +/// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode +/// whole and match its Huffman transcode. +#[test] +fn arithmetic_white_tail_decodes_from_implied_zeros() { + let arithmetic = decode_clean("whitetail-ari.jpg"); + assert_eq!((arithmetic.width, arithmetic.height, arithmetic.components), (512, 512, 1)); + assert!(decode_clean("whitetail-huffman.jpg").samples == arithmetic.samples, "whitetail-ari.jpg decodes differently from whitetail-huffman.jpg"); +} + /// The 8-bit path against zune-jpeg. Both use centered linear chroma upsampling; the residual is /// float against integer IDCT and color conversion. #[test] diff --git a/crates/jpeg/tests/data/SHA256SUMS b/crates/jpeg/tests/data/SHA256SUMS index 551a5dba..2309d1b5 100644 --- a/crates/jpeg/tests/data/SHA256SUMS +++ b/crates/jpeg/tests/data/SHA256SUMS @@ -3,3 +3,5 @@ acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg 34790770f76db8e60a7765b52ca4edf5f16bc21bcb8c6045ca2efef39a8a013e testorig12.jpg 62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm +0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg +0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg diff --git a/crates/jpeg/tests/data/SOURCE.md b/crates/jpeg/tests/data/SOURCE.md index 3876fe87..8e916998 100644 --- a/crates/jpeg/tests/data/SOURCE.md +++ b/crates/jpeg/tests/data/SOURCE.md @@ -2,7 +2,8 @@ The JPEG files come from libjpeg-turbo 3.0.0, `testimages/` (https://github.com/libjpeg-turbo/libjpeg-turbo/tree/3.0.0/testimages), under the IJG License -(README.ijg in this directory). The other two files are derived from them as described below. +(README.ijg in this directory). The other files are derived from them, or made with +libjpeg-turbo's tools, as described below. `SHA256SUMS` lists every file. | File | What it is | @@ -12,6 +13,8 @@ The JPEG files come from libjpeg-turbo 3.0.0, `testimages/` | `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) | | `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) | | `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 | +| `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below | +| `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) | `testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients: libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the @@ -43,4 +46,21 @@ with open(dst, "wb") as f: `djpeg -dct int testorig12.jpg` from libjpeg-turbo 3.1.0 writes a byte-identical file. +`whitetail-ari.jpg` and `whitetail-huffman.jpg`, with cjpeg and jpegtran from libjpeg-turbo 3.1.3 +(Alpine 3.24, `libjpeg-turbo-utils`). libjpeg's arithmetic encoder drops the trailing zero bytes +of a scan, so the white rows decode almost entirely from zeros supplied at the EOI marker. +`python3 whitetail.py whitetail.pgm` writes the source image: + +```python +import sys +w, h, top = 512, 512, 16 +px = bytes(((x * 3 + y * 5) ^ (x * y)) & 0xFF if y < top else 255 for y in range(h) for x in range(w)) +open(sys.argv[1], "wb").write(b"P5\n%d %d\n255\n" % (w, h) + px) +``` + +```sh +cjpeg -arithmetic -quality 75 whitetail.pgm > whitetail-ari.jpg +jpegtran -copy none whitetail-ari.jpg > whitetail-huffman.jpg +``` + "This software is based in part on the work of the Independent JPEG Group." diff --git a/crates/jpeg/tests/data/whitetail-ari.jpg b/crates/jpeg/tests/data/whitetail-ari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e7011fb6e1cea60f901bf5362a46fbff6ba1c37b GIT binary patch literal 3492 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 S{{a60{{XV^@U+Bo>i^l{%!YIT literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/whitetail-huffman.jpg b/crates/jpeg/tests/data/whitetail-huffman.jpg new file mode 100644 index 0000000000000000000000000000000000000000..888cc1b67ca90813179cd70727a797652eb2648d GIT binary patch literal 7011 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 literal 0 HcmV?d00001 From c91d190a7ef1b662d79844f5129418594f27c55a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:03:34 +0000 Subject: [PATCH 154/160] jpeg: DC-only blocks skip the IDCT; README advises a smaller max_alloc for UI and preview decodes --- crates/jpeg/README.md | 4 ++++ crates/jpeg/src/lib.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 606ea080..63afb834 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -74,6 +74,10 @@ the other default: let limits = craft_jpeg::Limits { max_alloc: 512 << 20, ..Default::default() }; ``` +A file of under a hundred bytes can declare a frame that fills the default limits, and +decoding it still renders every sample. Callers that decode for a UI or for previews should +pass a `max_alloc` well below the default. + ## Routing An app that decodes baseline JPEG with another decoder keeps its own rule for which files come diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index a89614b4..ec38e67a 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -1196,6 +1196,19 @@ fn reconstruct(comp: &Component, q: &[u16; 64], basis: &[[f32; 8]; 8], max: f32) let shift = (max + 1.0) / 2.0; for (n, blk) in comp.coefs.iter().enumerate() { let (bx, by) = (n % comp.bw, n / comp.bw); + if blk[1..].iter().all(|&c| c == 0) { + // Only DC: every sample is T[y][0] · T[x][0] · F[0][0], the same products the full + // transform below sums (its other terms are zero), so the result is identical. + let t = basis[0][0]; + let v = (t * (t * (blk[0] as f32 * f32::from(q[0]))) + shift).clamp(0.0, max); + for y in 0..8 { + let row = (by * 8 + y) * stride + bx * 8; + if let Some(out) = plane.get_mut(row..row + 8) { + out.fill(v); + } + } + continue; + } let mut f = [0f32; 64]; for (k, &c) in blk.iter().enumerate() { f[ZIGZAG[k]] = c as f32 * f32::from(q[k]); From 2628b8ac9ca4c942c4d5cfaf5673295b8c4c880f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:03:57 +0000 Subject: [PATCH 155/160] jpeg: reject decoder buffers above isize::MAX bytes whatever max_alloc allows --- crates/jpeg/src/lib.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index ec38e67a..f75c8463 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -72,7 +72,7 @@ pub struct Limits { /// Maximum `width * height`. pub max_pixels: u64, /// Maximum bytes for the decoded output and the decoder's own coefficient and sample - /// buffers together. + /// buffers together. Totals above `isize::MAX` are rejected whatever this is set to. pub max_alloc: u64, } @@ -401,8 +401,11 @@ fn parse_frame(marker: u8, seg: &[u8], limits: &Limits) -> Result let samples = (mcux * h * 8) as u64 * (mcuy * v * 8) as u64; total = total.saturating_add(samples.saturating_mul(8)); } - if total > limits.max_alloc { - return Err(Error::LimitExceeded(format!("{total} bytes of decoder buffers and output exceed max_alloc {}", limits.max_alloc))); + // No allocation may pass `isize::MAX` bytes, which matters on 32-bit targets when a caller + // raises `max_alloc`. + let cap = limits.max_alloc.min(isize::MAX as u64); + if total > cap { + return Err(Error::LimitExceeded(format!("{total} bytes of decoder buffers and output exceed the limit of {cap}"))); } let mut comps = Vec::with_capacity(nf); for (id, h, v, tq) in raw { From b4e764e44cb63e9fe669d7a7af584f2c14775038 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:04:21 +0000 Subject: [PATCH 156/160] jpeg: conformance fixtures for jpegtran's progressive and 12-bit arithmetic transcodes --- crates/jpeg/README.md | 2 ++ .../testorig-ari-progressive-restart.jpg | Bin 0 -> 5467 bytes .../corpus/decode/testorig-ari-progressive.jpg | Bin 0 -> 5154 bytes .../decode/testorig12-ari-progressive.jpg | Bin 0 -> 11357 bytes .../jpeg/fuzz/corpus/decode/testorig12-ari.jpg | Bin 0 -> 11543 bytes crates/jpeg/tests/conformance.rs | 15 +++++++++++++++ crates/jpeg/tests/data/SHA256SUMS | 4 ++++ crates/jpeg/tests/data/SOURCE.md | 17 ++++++++++++++++- .../data/testorig-ari-progressive-restart.jpg | Bin 0 -> 5467 bytes .../tests/data/testorig-ari-progressive.jpg | Bin 0 -> 5154 bytes .../tests/data/testorig12-ari-progressive.jpg | Bin 0 -> 11357 bytes crates/jpeg/tests/data/testorig12-ari.jpg | Bin 0 -> 11543 bytes 12 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig12-ari-progressive.jpg create mode 100644 crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg create mode 100644 crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg create mode 100644 crates/jpeg/tests/data/testorig-ari-progressive.jpg create mode 100644 crates/jpeg/tests/data/testorig12-ari-progressive.jpg create mode 100644 crates/jpeg/tests/data/testorig12-ari.jpg diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index 63afb834..f403f454 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -112,6 +112,8 @@ SOF11 (lossless) falls in that range and returns `Unsupported`. - `tests/conformance.rs`, on libjpeg-turbo's test images in `tests/data` (provenance and how the derived files were made: `tests/data/SOURCE.md`): - `testimgari.jpg` (SOF9) decodes to exactly the samples of its jpegtran Huffman transcode. + - jpegtran's arithmetic and progressive arithmetic transcodes of `testorig.jpg` (one with + restarts) and `testorig12.jpg` decode to exactly the samples of their sources. - `whitetail-ari.jpg` (SOF9 from cjpeg, a white lower part that decodes from the zeros supplied at EOI) decodes undamaged and equal to its Huffman transcode. - `testorig.jpg` matches `zune-jpeg`, and `testorig12.jpg` matches libjpeg-turbo's 12-bit diff --git a/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive-restart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..10c5081f76196a3e236c6a9065a685f05ce591ca GIT binary patch literal 5467 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 ohgbG5571~^*8l(j literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg b/crates/jpeg/fuzz/corpus/decode/testorig-ari-progressive.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e814c9297796a6976d437e8304bc336ffec8da28 GIT binary patch literal 5154 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 QycnyoZFB4*@Mq)y02OSl)Bpeg literal 0 HcmV?d00001 diff --git a/crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg b/crates/jpeg/fuzz/corpus/decode/testorig12-ari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..66478ad5640414a4b83c81fa613ae72562f7e52e GIT binary patch literal 11543 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 ySH|8k2JYXmmFdJ}RqBM!K!m|?)r#{U4^)%a5Y literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/conformance.rs b/crates/jpeg/tests/conformance.rs index e9fab449..86b9dfb0 100644 --- a/crates/jpeg/tests/conformance.rs +++ b/crates/jpeg/tests/conformance.rs @@ -41,6 +41,21 @@ fn arithmetic_coded_image_matches_its_huffman_transcode() { assert!(huffman.samples == arithmetic.samples, "testimgari.jpg decodes differently from testimgari-huffman.jpg"); } +/// The arithmetic and progressive arithmetic transcodes jpegtran made of testorig.jpg and +/// testorig12.jpg hold their coefficients, so they decode to exactly the same samples. +#[test] +fn arithmetic_transcodes_match_their_huffman_sources() { + for (source, transcodes) in [ + ("testorig.jpg", &["testorig-ari-progressive.jpg", "testorig-ari-progressive-restart.jpg"]), + ("testorig12.jpg", &["testorig12-ari.jpg", "testorig12-ari-progressive.jpg"]), + ] { + let reference = decode_fixture(source); + for name in transcodes { + assert!(decode_fixture(name).samples == reference.samples, "{name} decodes differently from {source}"); + } + } +} + /// libjpeg's arithmetic encoder drops the trailing zero bytes of a scan, so the white lower /// part of whitetail-ari.jpg decodes from zeros supplied at the EOI marker. It must decode /// whole and match its Huffman transcode. diff --git a/crates/jpeg/tests/data/SHA256SUMS b/crates/jpeg/tests/data/SHA256SUMS index 2309d1b5..19c71c54 100644 --- a/crates/jpeg/tests/data/SHA256SUMS +++ b/crates/jpeg/tests/data/SHA256SUMS @@ -5,3 +5,7 @@ acc6ec555d41d15b368320edaa3b20958ee6fa97cb6e4a18d1213d5ae8bec73b testorig.jpg 62d182ed042e243544d3d753c440ada1a6992464717311fe6efd546632f44dc4 testorig12.ppm 0031b82a06300b5b5d19ef5553cc39d3fafafa4fbc474199e6cfbc7818312a87 whitetail-ari.jpg 0f6e17a44be7a005d6610e66f91fa119388dc6b8438e95cb8816b6db58f8c365 whitetail-huffman.jpg +7e7edd9d9825c2eca1e6ff6dfafeaddec39e2464fc5d6d1ce4a378a59ec3f716 testorig-ari-progressive.jpg +cb0a2c60e6a89c68ef8cf48854dc09c77c26ffb27ac62e17d343d98f1424cc77 testorig-ari-progressive-restart.jpg +c4942c4b704d20e27c4cd3e613c6ef975dd7cfe53a756e2c209fa3ad033d8b51 testorig12-ari.jpg +18df0394115d9f8751939fb9f45a0d330df2ca849e9f8b07d0af76c09b90fcdb testorig12-ari-progressive.jpg diff --git a/crates/jpeg/tests/data/SOURCE.md b/crates/jpeg/tests/data/SOURCE.md index 8e916998..bc15d764 100644 --- a/crates/jpeg/tests/data/SOURCE.md +++ b/crates/jpeg/tests/data/SOURCE.md @@ -13,12 +13,17 @@ libjpeg-turbo's tools, as described below. | `testimgari-huffman.jpg` | `testimgari.jpg` losslessly transcoded to baseline Huffman (SOF0) | | `testorig12.jpg` | 227×149 YCbCr, 12-bit extended sequential Huffman (SOF1) | | `testorig12.ppm` | libjpeg-turbo's decode of `testorig12.jpg`: 12-bit RGB, binary PPM, maxval 4095 | +| `testorig-ari-progressive.jpg` | `testorig.jpg` transcoded to progressive arithmetic (SOF10) | +| `testorig-ari-progressive-restart.jpg` | the same with a restart interval of 3 MCU rows | +| `testorig12-ari.jpg` | `testorig12.jpg` transcoded to 12-bit arithmetic (SOF9) | +| `testorig12-ari-progressive.jpg` | `testorig12.jpg` transcoded to 12-bit progressive arithmetic (SOF10) | | `whitetail-ari.jpg` | 512×512 gray, arithmetic coded (SOF9), noise in the top 16 rows, white below | | `whitetail-huffman.jpg` | `whitetail-ari.jpg` losslessly transcoded to baseline Huffman (SOF0) | `testimgari.jpg` and `testorig.jpg` share their quantization tables but not their coefficients: libjpeg-turbo decodes them to different pixels. `testimgari-huffman.jpg` holds exactly the -coefficients of `testimgari.jpg`, so the two must decode to identical samples. +coefficients of `testimgari.jpg`, so the two must decode to identical samples. The same holds +for each jpegtran transcode below and its source. ## How the derived files were made @@ -29,6 +34,16 @@ coefficients of `testimgari.jpg`, so the two must decode to identical samples. jpegtran -copy none testimgari.jpg > testimgari-huffman.jpg ``` +The arithmetic transcodes of `testorig.jpg` and `testorig12.jpg`, with jpegtran from +libjpeg-turbo 3.1.3 (Alpine 3.24, `libjpeg-turbo-utils`): + +```sh +jpegtran -copy none -arithmetic -progressive testorig.jpg > testorig-ari-progressive.jpg +jpegtran -copy none -arithmetic -progressive -restart 3 testorig.jpg > testorig-ari-progressive-restart.jpg +jpegtran -copy none -arithmetic testorig12.jpg > testorig12-ari.jpg +jpegtran -copy none -arithmetic -progressive testorig12.jpg > testorig12-ari-progressive.jpg +``` + `testorig12.ppm`, with libjpeg-turbo 3.2.0 through imagecodecs 2026.10.10 (default settings: integer IDCT `JDCT_ISLOW`, fancy upsampling), run as `uv run --with imagecodecs --with numpy python make.py testorig12.jpg testorig12.ppm`: diff --git a/crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg b/crates/jpeg/tests/data/testorig-ari-progressive-restart.jpg new file mode 100644 index 0000000000000000000000000000000000000000..10c5081f76196a3e236c6a9065a685f05ce591ca GIT binary patch literal 5467 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 ohgbG5571~^*8l(j literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/testorig-ari-progressive.jpg b/crates/jpeg/tests/data/testorig-ari-progressive.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e814c9297796a6976d437e8304bc336ffec8da28 GIT binary patch literal 5154 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 QycnyoZFB4*@Mq)y02OSl)Bpeg literal 0 HcmV?d00001 diff --git a/crates/jpeg/tests/data/testorig12-ari.jpg b/crates/jpeg/tests/data/testorig12-ari.jpg new file mode 100644 index 0000000000000000000000000000000000000000..66478ad5640414a4b83c81fa613ae72562f7e52e GIT binary patch literal 11543 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 ySH|8k2JYXmmFdJ}RqBM!K!m|?)r#{U4^)%a5Y literal 0 HcmV?d00001 From 955831a3a260e398a8e060c4336fe1277d82a2fa Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:05:06 +0000 Subject: [PATCH 157/160] jpeg: Limits is non_exhaustive; examples set fields on Limits::default() --- crates/jpeg/README.md | 13 +++++++------ crates/jpeg/fuzz/fuzz_targets/decode.rs | 14 ++++++++++---- crates/jpeg/src/lib.rs | 6 ++++++ crates/jpeg/tests/mutation.rs | 16 +++++++++++----- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/crates/jpeg/README.md b/crates/jpeg/README.md index f403f454..e88c5cd9 100644 --- a/crates/jpeg/README.md +++ b/crates/jpeg/README.md @@ -64,14 +64,15 @@ if img.damaged { /* warn: part of the image is missing */ } ## Limits -`Limits { max_pixels, max_alloc }` is checked against the frame header before anything is -allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's own -coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels and 8 GiB on -64-bit targets, 1 GiB on 32-bit ones (wasm32). Fields are public, so an app sets one and keeps -the other default: +`Limits` (fields `max_pixels`, `max_alloc`) is checked against the frame header before anything +is allocated: `width × height` against `max_pixels`, and the output buffer plus the decoder's +own coefficient and sample buffers, together, against `max_alloc`. The defaults are 2³⁰ pixels +and 8 GiB on 64-bit targets, 1 GiB on 32-bit ones (wasm32). The struct is `#[non_exhaustive]`: +start from the defaults and set the fields you change: ```rust -let limits = craft_jpeg::Limits { max_alloc: 512 << 20, ..Default::default() }; +let mut limits = craft_jpeg::Limits::default(); +limits.max_alloc = 512 << 20; ``` A file of under a hundred bytes can declare a frame that fills the default limits, and diff --git a/crates/jpeg/fuzz/fuzz_targets/decode.rs b/crates/jpeg/fuzz/fuzz_targets/decode.rs index b03dbfe9..ed96fafe 100644 --- a/crates/jpeg/fuzz/fuzz_targets/decode.rs +++ b/crates/jpeg/fuzz/fuzz_targets/decode.rs @@ -3,21 +3,27 @@ use craft_jpeg::{Limits, Samples, decode, frame}; use libfuzzer_sys::fuzz_target; -const LIMITS: Limits = Limits { max_pixels: 1 << 20, max_alloc: 64 << 20 }; +fn fuzz_limits() -> Limits { + let mut limits = Limits::default(); + limits.max_pixels = 1 << 20; + limits.max_alloc = 64 << 20; + limits +} fuzz_target!(|data: &[u8]| { + let limits = fuzz_limits(); let _ = frame(data); // The input length picks the Adobe transform, so every color path is reached without // changing the stream. let adobe = [None, Some(0), Some(1), Some(2)][data.len() % 4]; - if let Ok(img) = decode(data, &LIMITS, adobe) { + if let Ok(img) = decode(data, &limits, adobe) { let pixels = u64::from(img.width) * u64::from(img.height); let (len, width) = match &img.samples { Samples::U8(v) => (v.len() as u64, 1), Samples::U16(v) => (v.len() as u64, 2), }; - assert!(pixels <= LIMITS.max_pixels); - assert!(len * width <= LIMITS.max_alloc); + assert!(pixels <= limits.max_pixels); + assert!(len * width <= limits.max_alloc); assert_eq!(len, pixels * u64::from(img.components)); } }); diff --git a/crates/jpeg/src/lib.rs b/crates/jpeg/src/lib.rs index f75c8463..d9ef3bd3 100644 --- a/crates/jpeg/src/lib.rs +++ b/crates/jpeg/src/lib.rs @@ -67,7 +67,13 @@ fn unsupported(m: impl Into) -> Error { /// Decompression-bomb guards, checked against the frame header before any image buffer is /// allocated. +/// +/// ``` +/// let mut limits = craft_jpeg::Limits::default(); +/// limits.max_alloc = 512 << 20; +/// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub struct Limits { /// Maximum `width * height`. pub max_pixels: u64, diff --git a/crates/jpeg/tests/mutation.rs b/crates/jpeg/tests/mutation.rs index a23a706b..41d403d2 100644 --- a/crates/jpeg/tests/mutation.rs +++ b/crates/jpeg/tests/mutation.rs @@ -18,7 +18,12 @@ const INPUTS: [(&str, usize); 8] = [ ]; /// Room for the 227×149 fixtures, too little for a mutated frame header that grows them much. -const LIMITS: Limits = Limits { max_pixels: 1 << 16, max_alloc: 1 << 20 }; +fn test_limits() -> Limits { + let mut limits = Limits::default(); + limits.max_pixels = 1 << 16; + limits.max_alloc = 1 << 20; + limits +} struct Rng(u64); @@ -95,23 +100,24 @@ fn mutate(rng: &mut Rng, original: &[u8]) -> Vec { #[test] fn mutated_streams_never_panic_and_stay_within_limits() { let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let limits = test_limits(); let (mut ok, mut errors) = (0, 0); for (name, cases) in INPUTS { let path = format!("{}/{name}", env!("CARGO_MANIFEST_DIR")); let original = std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}")); - assert!(decode(&original, &LIMITS, None).is_ok(), "{name} must decode within the test limits"); + assert!(decode(&original, &limits, None).is_ok(), "{name} must decode within the test limits"); for case in 0..cases { let bytes = mutate(&mut rng, &original); let _ = frame(&bytes); - match decode(&bytes, &LIMITS, None) { + match decode(&bytes, &limits, None) { Ok(img) => { let pixels = u64::from(img.width) * u64::from(img.height); let (len, width) = match &img.samples { Samples::U8(v) => (v.len() as u64, 1), Samples::U16(v) => (v.len() as u64, 2), }; - assert!(pixels <= LIMITS.max_pixels, "{name} case {case}: {pixels} pixels"); - assert!(len * width <= LIMITS.max_alloc, "{name} case {case}: {} bytes", len * width); + assert!(pixels <= limits.max_pixels, "{name} case {case}: {pixels} pixels"); + assert!(len * width <= limits.max_alloc, "{name} case {case}: {} bytes", len * width); assert_eq!(len, pixels * u64::from(img.components), "{name} case {case}"); ok += 1; } From f9181cc7b90ebefd828a01598c58de070ef611f6 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:11:42 -0400 Subject: [PATCH 158/160] docking: replacing the workspace cancels a drag in progress (#294) * docking: replacing the workspace cancels a drag in progress A dock area keeps the layout it drew while a drag is in progress, with the actions it returned. When the next layout is neither that one nor that one with the actions applied, the app replaced or changed it, and the area cancels its tab, group, column, window bar, native bar, divider and cross-window drags itself. cancel_drag also ends a native window's bar drag. * docking: replacing the layout also ends a divider drag in a native window --- crates/ui/src/docking/native_tests.rs | 81 ++++++++++++++++ crates/ui/src/docking/render/cross.rs | 4 + crates/ui/src/docking/render/mod.rs | 4 +- crates/ui/src/docking/render/native.rs | 6 ++ crates/ui/src/docking/surface.rs | 126 ++++++++++++++++++++++++- crates/ui/src/docking/tests.rs | 71 ++++++++++++++ docs/ui-docking.md | 7 ++ 7 files changed, 294 insertions(+), 5 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 348820b1..c72a0711 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -1405,6 +1405,87 @@ fn a_layout_built_each_frame_without_ids_is_warned_about() { assert!(!warned(&d)); } +#[test] +fn replacing_the_layout_or_cancel_drag_ends_a_bar_drag() { + let moving = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))); + for replace in [false, true] { + let mut d = driver(layout(true)); + let id = id_of(&d); + d.window_step(id, vec![Event::PointerMoved(pos2(40.0, 9.0)), button(pos2(40.0, 9.0), true)]); + d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 19.0))]); + assert!(moving(&d).is_some()); + d.step(vec![]); + if replace { + // The same window where the OS has it, beside swapped frames. + let mut replacement = layout(true); + replacement.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[3, 4])), + second: Box::new(tabs(&[1, 2])), + }); + replacement.floating[0] = Floating { native: window(&d, 5).native, ..replacement.floating[0].clone().with_id(id) }; + d.layout = replacement; + } else { + super::cancel_drag::(&d.ctx, d.id); + } + d.settle(); + assert_eq!(moving(&d), None); + // The OS window goes where the layout has it. + let before = os_window(&d, 5).unwrap().0; + let native = window(&d, 5).native.unwrap(); + assert_eq!(before.min, pos2(native[0], native[1])); + d.window_step(id, vec![Event::PointerMoved(pos2(90.0, 39.0))]); + d.window_step(id, vec![Event::PointerMoved(pos2(120.0, 59.0))]); + assert_eq!(moving(&d), None); + assert_eq!(os_window(&d, 5).unwrap().0, before); + let out = d.window_step(id, vec![button(pos2(120.0, 59.0), false)]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::MoveWindow { .. })), "{:?}", out.actions); + } +} + +#[test] +fn replacing_the_layout_ends_a_divider_drag_in_a_native_window() { + let resizes = |out: &super::DockOutput| out.actions.iter().filter(|a| matches!(a, Action::ResizeWindowSplit { .. })).count(); + let split = |d: &DockDriver| match &window(d, 5).root { + Node::Split { size, .. } => *size, + root => panic!("{root:?}"), + }; + for replace in [false, true] { + let mut start = layout(false); + let mut float = Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]); + float.root = + Node::Split { axis: SplitAxis::Horizontal, size: SplitSize::Ratio(0.5), first: Box::new(tabs(&[5])), second: Box::new(tabs(&[6])) }; + start.floating.push(float); + let mut d = driver(start); + d.settle(); + let id = id_of(&d); + let out = d.window_hold(id, pos2(120.0, 150.0), pos2(150.0, 150.0)); + assert!(resizes(&out) > 0, "{:?}", out.actions); + if replace { + d.layout.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::Ratio(0.5), + first: Box::new(tabs(&[3, 4])), + second: Box::new(tabs(&[1, 2])), + }); + } + let before = split(&d); + let mut moved = 0; + for x in [160.0, 170.0, 180.0] { + moved += resizes(&d.window_step(id, vec![Event::PointerMoved(pos2(x, 150.0))])); + } + moved += resizes(&d.window_release(id, pos2(180.0, 150.0))); + if replace { + assert_eq!(moved, 0); + assert_eq!(split(&d), before); + } else { + assert!(moved > 0); + assert_ne!(split(&d), before); + } + } +} + /// [`layout`] with window `[5, 6]` saved as a native window at `NATIVE`. fn saved_native() -> Layout { let mut layout = layout(true); diff --git a/crates/ui/src/docking/render/cross.rs b/crates/ui/src/docking/render/cross.rs index fafdb4b2..4516da33 100644 --- a/crates/ui/src/docking/render/cross.rs +++ b/crates/ui/src/docking/render/cross.rs @@ -99,6 +99,9 @@ pub(in crate::docking) struct Cross

{ pub moved: Option, /// Resolving a window move that docks whatever `title_bar_docks` says. pub docks: bool, + /// A native window's dragged widget is one of the area's controls: egui keeps a drag per + /// viewport. + pub held: bool, /// The panel whose body is under the pointer in the native window drawing now. pub pointed: Option

, } @@ -117,6 +120,7 @@ impl

Default for Cross

{ bar: false, moved: None, docks: false, + held: false, pointed: None, } } diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 2aad65cf..49c1c2c7 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -14,10 +14,12 @@ mod frame; mod iconic; #[cfg(feature = "native-windows")] mod native; +#[cfg(feature = "native-windows")] +pub(in crate::docking) use native::WindowDrag; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, MACOS_CLOSE, Session, WindowDrag, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, + native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, floats_shown, resize_bands, session, wayland}, }; #[cfg(feature = "native-windows")] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 5d6f217a..11d98117 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -420,12 +420,18 @@ impl Renderer<'_, P> { let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); ctx.with_plugin(|input: &mut NativeInput| input.windows.insert(viewport, self.id)); let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { + // `cancel_drag` reaches this viewport's drag here. + let cancelled = ui.ctx().data(|d| d.get_temp::>(crate::docking::surface::native_cancel_key(self.id))); + if ui.ctx().dragged_id().is_some_and(|id| cancelled.is_some_and(|ids| ids.contains(&id))) { + ui.ctx().stop_dragging(); + } let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, self.style.floating_background); let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); self.cross.pointed = None; self.floating_body(ui, window, body, full); + self.cross.held |= ui.ctx().dragged_id().is_some_and(|id| self.owned_interactions.contains(&id)); if debug_close() { debug_outlines(ui, full, &buttons); } diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index cef9fe0d..32e1756c 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -410,6 +410,104 @@ fn tab_focus_key(area: Id) -> Id { area.with("tab-focus") } +/// The layout an area drew while one of its drags was in progress, and the actions it returned. +#[derive(Clone)] +struct DragFrame

{ + layout: Layout

, + actions: Vec>, +} + +fn drag_frame_key(area: Id) -> Id { + area.with("drag-frame") +} + +/// The controls [`cancel_drag`] ended drags of, until the next frame's native windows, each +/// with its own egui drag, have drawn. +#[cfg(feature = "native-windows")] +pub(in crate::docking) fn native_cancel_key(area: Id) -> Id { + area.with("cancel-native") +} + +/// A drag ends when its layout changes other than by the area's own actions: a workspace +/// switch, Reset or an app command. The app applies all of them or none; an action that +/// fails on its own is skipped, as it is for an app that applies them one at a time. Sizes +/// and rectangles are not compared, so an app that clamps a moved window or adjusts a resized +/// split keeps the drag going. +fn cancel_drag_if_replaced(ctx: &Context, area: Id, layout: &Layout

) { + let Some(last) = ctx.data_mut(|d| { + let last = d.get_temp::>(drag_frame_key(area)); + d.remove::>(drag_frame_key(area)); + last + }) else { + return; + }; + let now = shape(layout); + if now == shape(&last.layout) { + return; + } + let mut applied = last.layout; + for action in last.actions { + let _ = applied.apply(action); + } + if now != shape(&applied) { + cancel_drag::

(ctx, area); + } +} + +/// `layout` without its sizes and rectangles: which panels are where, and their state. +fn shape(layout: &Layout

) -> Layout

{ + use super::{Column, Floating, Group, Node, StackEntry}; + use crate::layout::SplitSize; + fn node(n: &Node

) -> Node

{ + match n { + Node::Split { axis, first, second, .. } => { + Node::Split { axis: *axis, size: SplitSize::Ratio(0.5), first: Box::new(node(first)), second: Box::new(node(second)) } + } + Node::Tabs { panels, active } => Node::Tabs { panels: panels.clone(), active: *active }, + Node::Stack { entries } => Node::Stack { entries: entries.iter().map(|e| StackEntry { height: None, ..e.clone() }).collect() }, + Node::Dock { side, columns } => Node::Dock { + side: *side, + columns: columns + .iter() + .map(|c| Column { + groups: c.groups.iter().map(|g| Group { height: None, ..g.clone() }).collect(), + width: 0.0, + iconic: c.iconic, + iconic_width: None, + }) + .collect(), + }, + } + } + Layout { + root: layout.root.as_ref().map(node), + floating: layout + .floating + .iter() + .map(|w| Floating { root: node(&w.root), rect: [0.0; 4], native: None, monitor: None, monitor_size: None, ..w.clone() }) + .collect(), + } +} + +/// Keep what [`cancel_drag_if_replaced`] compares the next frame against, while a drag of this +/// area goes on. +fn record_drag_frame( + ctx: &Context, + area: Id, + layout: &Layout

, + owned: &[Id], + native_held: bool, + actions: &[Action

], +) { + let held = native_held || ctx.dragged_id().is_some_and(|id| owned.contains(&id)); + #[cfg(feature = "native-windows")] + let held = held || ctx.data(|d| d.get_temp::(area.with("window-drag")).is_some()); + if held || is_dragging::

(ctx, area) { + let frame = DragFrame { layout: layout.clone(), actions: actions.to_vec() }; + ctx.data_mut(|d| d.insert_temp(drag_frame_key(area), frame)); + } +} + /// Keyboard focus stays on a tab only while its panel stays where the user put it: a panel /// hidden, closed or moved by the host (a command or a workspace reset) gives focus up. fn release_moved_tab_focus(ctx: &Context, area: Id, layout: &Layout

) { @@ -597,22 +695,31 @@ pub fn is_dragging(ctx: &Context, area_id: Id) ctx.data(|data| data.get_temp::>(area_id.with("drag")).is_some() || data.get_temp::>(area_id.with("group-drag")).is_some()) } -/// Discard pending tab/group gestures and an actively dragged renderer-owned control before -/// replacing a workspace. Body/custom-header/menu gestures and other areas are untouched. +/// Discard pending tab/group gestures, a native window's bar drag and an actively dragged +/// renderer-owned control, in the main window now and in a native window when it next draws. +/// Body/custom-header/menu gestures and other areas are untouched. /// Ownership is recorded as controls render; before an initial render or drag threshold there -/// may be no active egui drag to cancel. Keyboard focus is unchanged. The host owns the -/// replacement snapshot, so this does not restore a group's previous rectangle. +/// may be no active egui drag to cancel. Keyboard focus is unchanged. Nothing is restored from +/// before the drag: floating and native windows go where the next layout drawn has them. +/// +/// An area cancels its drags itself when the layout it is given is not the one it drew last +/// frame with its own actions applied (see [`DockArea::show`]), so replacing the workspace +/// needs no call. pub fn cancel_drag(ctx: &Context, area_id: Id) { let dragged = ctx.dragged_id(); let owned = ctx.data_mut(|d| { d.remove::>(area_id.with("drag")); d.remove::>(area_id.with("group-drag")); + #[cfg(feature = "native-windows")] + d.remove::(area_id.with("window-drag")); let ids = d.remove_temp::>(area_id.with("owned-interactions")); let resize = d.get_temp::(area_id.with("pointer-resize")); d.remove::(area_id.with("pointer-resize")); for id in resize.iter().chain(ids.iter().flatten()) { d.remove::(id.with("pointer-anchor")); } + #[cfg(feature = "native-windows")] + d.get_temp_mut_or_default::>(native_cancel_key(area_id)).extend(ids.iter().flatten()); dragged.is_some_and(|id| resize == Some(id) || ids.is_some_and(|ids| ids.contains(&id))) }); if owned { @@ -928,6 +1035,9 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { /// Render a validated snapshot and return requested actions for the app's command handler. /// No action is applied here. Panel contents get a global stable egui ID across moves. + /// A drag in progress (tab, group, column, window bar, divider) ends when `layout` is not + /// the one drawn last frame, with or without the returned actions applied, apart from sizes + /// and rectangles: a workspace switch, Reset or a command changed it. /// Floating windows are egui areas inside this viewport; with feature `native-windows`, each /// gets its own OS window where the backend has viewports (see docs/ui-docking.md). pub fn show( @@ -987,6 +1097,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + cancel_drag_if_replaced(ui.ctx(), self.id, layout); release_moved_tab_focus(ui.ctx(), self.id, layout); let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); let places = layout.places(); @@ -1036,6 +1147,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { renderer.report_flyout(ui.ctx()); super::hits::record(ui.ctx(), self.id, layout, &renderer.owned_interactions); record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty()); + #[cfg(feature = "native-windows")] + let native_held = renderer.cross.held; + #[cfg(not(feature = "native-windows"))] + let native_held = false; + record_drag_frame(ui.ctx(), self.id, layout, &renderer.owned_interactions, native_held, &renderer.output.actions); + #[cfg(feature = "native-windows")] + ui.ctx().data_mut(|d| d.remove::>(native_cancel_key(self.id))); ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space)); ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions)); // egui runs a discarded pass again with the same hovered files (dropped ones are diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index c4494b79..f8921652 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -1769,3 +1769,74 @@ fn overflowing_strip_paints_a_single_ellipsis_symbol() { h.run_steps(2); assert!(h.output().shapes.iter().any(|shape| paints(&shape.shape, "\u{2026}"))); } + +struct Plain; + +impl DockContent for Plain { + fn body(&mut self, _: &mut egui::Ui, _: &u32) {} +} + +/// `sample` with its docked frames swapped: every panel stays, in other places. +fn swapped() -> Layout { + let mut layout = sample(); + layout.root = Some(Node::Split { + axis: SplitAxis::Horizontal, + size: SplitSize::FixedFirst(240.0), + first: Box::new(tabs(&[2, 3, 4], 1)), + second: Box::new(tabs(&[1], 0)), + }); + layout +} + +#[test] +fn replacing_the_layout_ends_a_drag_in_progress() { + use super::testing::DockDriver; + let area = Id::new("replaced"); + let driver = || { + let mut d = DockDriver::new(sample(), Plain); + d.id = area; + d.settle(); + d + }; + // Over frame [1], clear of the floating window. + let over = pos2(500.0, 450.0); + for replace in [false, true] { + let mut d = driver(); + let tab = d.hit(&Hit::Tab(2)).unwrap().center(); + d.hold(tab, over); + assert!(is_dragging::(&d.ctx, area)); + if replace { + d.layout = swapped(); + } + d.move_to(over + vec2(4.0, 0.0)); + assert_eq!(is_dragging::(&d.ctx, area), !replace); + let out = d.release(over); + if replace { + assert!(out.actions.is_empty(), "{:?}", out.actions); + assert_eq!(d.layout, swapped()); + } else { + assert!(matches!(out.actions.as_slice(), [Action::Move { panel: 2, anchor: 1, .. }]), "{:?}", out.actions); + } + } + + // A window follows its bar through its own moves, and through an app that adjusts the + // moved rectangle or a split's size, and stops when the app switches workspaces. + let mut d = driver(); + let bar = d.hit(&Hit::Title(5)).unwrap().center(); + d.hold(bar, bar + vec2(60.0, 40.0)); + d.move_to(bar + vec2(90.0, 60.0)); + assert!(is_dragging::(&d.ctx, area)); + assert_ne!(d.layout.floating[0].rect, sample().floating[0].rect); + d.layout.floating[0].rect[0] += 3.0; + if let Some(Node::Split { size, .. }) = &mut d.layout.root { + *size = SplitSize::FixedSecond(260.0); + } + d.move_to(bar + vec2(100.0, 70.0)); + assert!(is_dragging::(&d.ctx, area)); + d.layout = swapped(); + let out = d.move_to(bar + vec2(120.0, 80.0)); + assert!(!is_dragging::(&d.ctx, area)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + d.release(bar + vec2(120.0, 80.0)); + assert_eq!(d.layout, swapped()); +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 8b54d60b..a58d55df 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -268,6 +268,13 @@ A flyout fits its content the same way, without the title bar. group does not take, by its permissions or because a split leaves no room, are not drawn; nor is the center of the group a tab comes from. Escape or losing window focus cancels a drag. +- A drag in progress (a tab, group, column, window bar, native window bar or divider, in any of + the app's windows) also ends when the layout passed to the area is not the one it drew last + frame, with or without its returned actions applied: the app switched workspaces, ran Reset + or changed the layout by command. Sizes and rectangles are not compared, so an app may clamp + a moved window or adjust a split during the drag. Nothing is restored from before the drag; + windows go where the new layout has them. `cancel_drag` ends the area's drags at any other + time. - With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such as a panel dropped on a protected canvas. Without it, that release cancels the drag and nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release From acb7290804afb3de9061f0133f7d74eec6bdf94a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:12:06 -0400 Subject: [PATCH 159/160] docking: normal floating windows come forward when the app is activated (#301) * docking: normal floating windows come forward when the app is activated * docking: drop windows that became palettes from the raise queue * docking: the main window comes forward with the normal windows, in focus order --- crates/ui/src/docking/native_tests.rs | 75 ++++++++++++++++++++++ crates/ui/src/docking/render/native.rs | 87 +++++++++++++++++++++++--- crates/ui/src/docking/surface.rs | 4 +- crates/ui/src/docking/testing.rs | 5 +- docs/ui-docking.md | 16 +++++ 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index c72a0711..39a2ecf9 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -391,6 +391,81 @@ fn viewer_windows_stay_shown_at_normal_level() { assert_eq!(shown(&d), [true, true, true]); } +#[test] +fn the_app_comes_forward_with_its_viewers_when_it_is_activated() { + use egui::{ViewportId, WindowLevel}; + let start = || { + let mut layout = layout(false); + layout.floating = vec![ + Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]), + Floating::single(vec![6], 0, [560.0, 80.0, 240.0, 300.0]), + Floating::single(vec![7], 0, [820.0, 80.0, 240.0, 300.0]), + ]; + let mut d = driver(layout); + d.content.normal = vec![5, 7]; + d.step(vec![]); + let [back, palette, front] = [5, 6, 7].map(|p| d.viewport(window(&d, p).id)); + (d, back, palette, front) + }; + let focus = |d: &mut DockDriver, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let levels = |d: &mut DockDriver| -> Vec<(ViewportId, WindowLevel)> { + let mut host = d.host(); + assert!(!host.commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Focus)), "no window takes focus"); + let levels = host.commands.iter().filter_map(|(v, c)| if let ViewportCommand::WindowLevel(l) = c { Some((*v, *l)) } else { None }).collect(); + host.commands.clear(); + levels + }; + // Each window once, in this order: the main window's commands are the root's. + let raised = |order: &[ViewportId]| -> Vec<(ViewportId, WindowLevel)> { + if cfg!(target_os = "macos") { + return Vec::new(); + } + order.iter().flat_map(|v| [(*v, WindowLevel::AlwaysOnTop), (*v, WindowLevel::Normal)]).collect() + }; + const MAIN: ViewportId = ViewportId::ROOT; + + let (mut d, back, palette, front) = start(); + // Focus moving between the app's own windows, with a frame of none between two, raises + // nothing. + focus(&mut d, Some(back), 2); + focus(&mut d, None, 1); + focus(&mut d, Some(front), 2); + focus(&mut d, Some(MAIN), 2); + assert_eq!(levels(&mut d), []); + // Another application, then back through the main window: the viewers are raised back to + // front and the main window last, without focus; the palette only shows again. + focus(&mut d, None, 20); + focus(&mut d, Some(MAIN), 10); + assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); + assert!(d.host().windows[&palette].visible); + // Back through the back viewer: it is raised last, so it stays in front, and the main + // window comes forward behind it. + focus(&mut d, None, 20); + focus(&mut d, Some(back), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN, back])); + // A queued viewer that turns into a palette before its turn is not raised: the raise + // would leave it at the normal level. + focus(&mut d, None, 20); + focus(&mut d, Some(MAIN), 1); + d.content.normal = vec![7]; + focus(&mut d, Some(MAIN), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN])); + + // A press in the main window brings it in front of the viewers that opened over it, though + // it kept focus. + let (mut d, back, _, front) = start(); + d.click(pos2(100.0, 300.0)); + levels(&mut d); + focus(&mut d, None, 20); + focus(&mut d, Some(back), 10); + assert_eq!(levels(&mut d), raised(&[front, MAIN, back])); +} + #[test] fn unused_keys_in_a_native_window_reach_the_app() { let mut d = driver(layout(true)); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 11d98117..4d47c04a 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -1,7 +1,8 @@ //! Floating windows in their own OS windows (feature `native-windows`). Where the backend has //! viewports, every floating window whose panels allow it is drawn with -//! `show_viewport_immediate`, always on top of other windows and hidden while none of the -//! application's windows has focus. Where egui embeds viewports (wasm, tests) windows stay +//! `show_viewport_immediate`. Palettes stay on top of other windows and hide while none of the +//! application's windows has focus; viewers are normal windows that come forward when the +//! application is activated. Where egui embeds viewports (wasm, tests) windows stay //! in-window at their in-window rect, and so do they on Wayland, unless the app turns native //! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows //! drag between OS windows (see `cross.rs`). @@ -25,6 +26,10 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0]; /// How long floating windows stay after the application's last window lost focus, so focus /// moving between our own windows does not hide them. const HIDE_DELAY: f64 = 0.25; +/// Whether normal windows are raised when the application is activated. macOS brings an +/// application's windows forward itself: all of them on activation from the Dock or the +/// application switcher, the clicked one alone on a click, as its own windows do. +const RAISE_ON_ACTIVATION: bool = !cfg!(target_os = "macos"); /// How long reports of a native window's place may lag a move the dock area commanded. const MOVE_WAIT: f64 = 0.5; /// How far the resize bands reach into a native window from its edges, where the window has @@ -204,7 +209,7 @@ impl Renderer<'_, P> { pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session())); let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO); - let visible = !native || self.float_visibility(ctx); + let (visible, activated) = if native { self.float_visibility(ctx) } else { (true, false) }; self.cross.on = native; let mut shown = Vec::new(); let mut in_window = Vec::new(); @@ -235,7 +240,8 @@ impl Renderer<'_, P> { if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(key)); } - self.map_desktop(ctx, &natives, visible); + self.map_desktop(ctx, &natives, visible, activated); + self.raise_next(ctx, &natives); // The main window owns drags of the panels it draws. let id = self.id; let dragged = ctx.data(|d| { @@ -285,13 +291,21 @@ impl Renderer<'_, P> { /// The main window and the shown native windows on the desktop, front first: palettes /// above the rest, each the window focused most recently first. A window whose position is - /// unknown is left out. `visible`: palettes show. - fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool) { + /// unknown is left out. `visible`: palettes show. `activated`: the application was just + /// activated, so the main window and its normal windows are raised (see + /// [`Self::raise_next`]). + fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)], visible: bool, activated: bool) { let key = self.id.with("native-order"); // Focus order, front first; `None` is the main window. let mut order = ctx.data(|d| d.get_temp::>>(key)).unwrap_or_default(); order.retain(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id))); - if ctx.input(|i| i.viewport().focused == Some(true)) || !order.contains(&None) { + // The main window comes to the front when it gains focus, and on a press in it: the + // window system raises it then, though it may have kept focus. + let main_key = self.id.with("native-main-focused"); + let (main_focused, pressed) = ctx.input(|i| (i.viewport().focused == Some(true), i.pointer.any_pressed())); + let gained = main_focused && !ctx.data(|d| d.get_temp::(main_key)).unwrap_or(false); + ctx.data_mut(|d| d.insert_temp(main_key, main_focused)); + if gained || pressed || !order.contains(&None) { let front = order.contains(&None); order.retain(Option::is_some); if front { order.insert(0, None) } else { order.push(None) } @@ -314,6 +328,19 @@ impl Renderer<'_, P> { windows.push((window.id, placed, normal)); } } + if activated && RAISE_ON_ACTIVATION { + // The back one first, so each lands in front of those raised before it, and the + // window just focused, at the front of the order, last. + let queue: Vec> = order + .iter() + .rev() + .filter(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id && self.normal_window(w)))) + .copied() + .collect(); + if queue.iter().any(Option::is_some) { + ctx.data_mut(|d| d.insert_temp(self.id.with("native-raise"), queue)); + } + } let rank = |id: Option| order.iter().position(|o| *o == id); windows.sort_by_key(|(id, _, normal)| (*normal, rank(Some(*id)))); self.cross.front = windows.iter().filter(|(id, _, normal)| !normal || rank(Some(*id)) < rank(None)).count(); @@ -322,8 +349,40 @@ impl Renderer<'_, P> { self.cross.main = ctx.input(|i| i.viewport().outer_rect.and(i.viewport().inner_rect)).filter(|r| r.is_finite()); } - /// Whether palettes show this frame. - fn float_visibility(&mut self, ctx: &Context) -> bool { + /// Raise the next window [`Self::map_desktop`] queued when the application was activated, + /// the main window (`None`) or a normal window: one per pass, since egui sends the commands + /// of different viewports in no fixed order. Toggling the window level raises a window without focusing it: on Windows + /// `HWND_NOTOPMOST` puts the window in front of every window that is not topmost, and on + /// X11 the window manager raises a window as it takes `_NET_WM_STATE_ABOVE`. winit has no + /// plain raise. `ViewportCommand::Focus` would take focus from the window the user + /// activated, and egui-winit ignores it for a window that has focus, so focus could not be + /// handed back. `Visible(true)` does nothing to a shown window. + fn raise_next(&self, ctx: &Context, natives: &[(&Floating

, [f32; 4], ViewportId)]) { + let key = self.id.with("native-raise"); + let Some(mut queue) = ctx.data_mut(|d| d.remove_temp::>>(key)) else { return }; + // A window that became a palette since activation keeps its `AlwaysOnTop` level. + let viewport = |id: Option| match id { + None => Some(ViewportId::ROOT), + Some(id) => natives.iter().find(|(w, ..)| w.id == id && self.normal_window(w)).map(|(.., v)| *v), + }; + queue.retain(|id| viewport(*id).is_some()); + if queue.is_empty() { + return; + } + if let Some(viewport) = viewport(queue.remove(0)) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop)); + ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::Normal)); + } + if !queue.is_empty() { + ctx.data_mut(|d| d.insert_temp(key, queue)); + ctx.request_repaint(); + } + } + + /// Whether palettes show this frame, and whether the application was just activated: one + /// of its windows has focus after none had it for long enough to hide the palettes. + /// Losing focus again stops the raise of normal windows that activation started. + fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) { let (focused, now) = ctx.input(|i| { let infos = || i.raw.viewports.values(); // A backend that reports no focus at all keeps the windows. @@ -338,7 +397,15 @@ impl Renderer<'_, P> { ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); } ctx.data_mut(|d| d.insert_temp(key, last)); - visible + let away_key = self.id.with("native-away"); + let away = ctx.data(|d| d.get_temp::(away_key)).unwrap_or(false); + ctx.data_mut(|d| { + d.insert_temp(away_key, !focused && (away || !visible)); + if !focused { + d.remove::>>(self.id.with("native-raise")); + } + }); + (visible, focused && away) } /// A window holding a viewer is a normal window, so it neither vanishes nor sits over other diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 32e1756c..f2f473d7 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -640,7 +640,9 @@ pub enum WindowKind { #[default] Palette, /// A window of its own, such as a viewer on a second display: normal window level, and it - /// stays shown while the app is in the background. + /// stays shown while the app is in the background. When the app is activated, these + /// windows and the main window come forward, and the main window is left at the normal + /// level. Normal, } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 49fe34d6..dd3dbe6e 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -389,7 +389,7 @@ pub struct NativeHost { /// frame still reports the old place, and gets pointer events relative to it. pub lag: bool, pub windows: std::collections::HashMap, - /// Every command sent to a native window, in order. + /// Every command sent to a native window or the main window, in order. pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, /// Moves waiting to land under `lag`: window, content position, frame sent. late: Vec<(egui::ViewportId, Pos2, usize)>, @@ -527,9 +527,6 @@ impl NativeHost { /// Carry out the commands a frame sent to native windows. fn apply(&mut self, full: &egui::FullOutput) { for (id, output) in &full.viewport_output { - if *id == egui::ViewportId::ROOT { - continue; - } for command in &output.commands { self.commands.push((*id, command.clone())); let Some(window) = self.windows.get_mut(id) else { continue }; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index a58d55df..34f41695 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -393,6 +393,22 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho background. They stack with the main window by focus, below every palette; a drag over a normal window that lies behind the main window targets the main window where the two overlap, and the normal window outside the main window. + - Like palettes, normal windows have no taskbar entry; they come forward with the app + instead. When one of the app's windows gains focus after none had it for the hide delay, + the main window and each normal window get `WindowLevel::AlwaysOnTop` and then + `WindowLevel::Normal`, one window per frame, in their focus order from back to front. + They keep their order, nothing takes focus, and the window the user activated is raised + last, so it stays in front. The focus order counts a press in the main window as focusing + it, since the window system raises it then. The main window is left at the normal level, + so an app that keeps its main window always on top should not use normal windows. On + Windows the toggle puts the window in front of every window that is not topmost, without + activating it; on X11 the window manager raises a window as it takes + `_NET_WM_STATE_ABOVE`. egui 0.36 cannot make the main window a native window's owner, + which on Windows would bring the window forward with the main window, and + `ViewportCommand::Focus` would take focus. On + macOS nothing is raised: activation from the Dock or the application switcher brings all + of the app's windows forward, and a click brings the clicked window alone, as with the + app's other windows. - **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style: the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On Wayland, where a window cannot be placed, the bar hands the move to the compositor. On From 38c4ecab17c7f9dae3861e88d0492d109342056a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:27:19 -0400 Subject: [PATCH 160/160] docking: native windows ignore input while the app shows a modal (#298) * docking: native windows ignore input while the app shows a modal DockArea::input_blocked disables each native floating window's widgets, drops its pointer, key, text and scroll input, covers it with DockStyle::blocked_dim over a layer that takes the pointer, offers no drop targets, forwards no events and cancels its OS close request. Turning it on cancels a drag in progress. * docking: a blocked native window also drops hovered and dropped files * docking: blocked palettes drop to the normal window level * docking: a blocked native window still forwards key releases * docking: while input is blocked, activation raises the main window last * docking: blocked palettes stay shown, and the main window comes in front on the app's return --- crates/ui/src/docking/native_tests.rs | 199 +++++++++++++++++++++++++ crates/ui/src/docking/render/mod.rs | 3 + crates/ui/src/docking/render/native.rs | 82 ++++++++-- crates/ui/src/docking/surface.rs | 26 ++++ crates/ui/src/docking/testing.rs | 18 ++- docs/ui-docking.md | 25 +++- 6 files changed, 336 insertions(+), 17 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 39a2ecf9..9003682f 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -25,8 +25,14 @@ struct Content { focus_text: bool, /// The panel with a button that takes focus. focused_button: Option, + /// The panel with a counter button, its clicks, and where it was drawn. + counter: Option, + count: usize, + counter_rect: Option, /// Pointer buttons pressed in each panel's body. pressed: Vec<(u32, PointerButton)>, + /// A panel body saw hovered or dropped files. + saw_files: bool, } impl DockContent for Content { @@ -41,6 +47,12 @@ impl DockContent for Content { if self.focused_button == Some(*panel) { ui.button("OK").request_focus(); } + if self.counter == Some(*panel) { + let response = ui.button("Add"); + self.count += usize::from(response.clicked()); + self.counter_rect = Some(response.rect); + } + self.saw_files |= ui.input(|i| !i.raw.hovered_files.is_empty() || !i.raw.dropped_files.is_empty()); if ui.rect_contains_pointer(ui.max_rect()) { let pressed = [PointerButton::Primary, PointerButton::Secondary].into_iter().filter(|b| ui.input(|i| i.pointer.button_pressed(*b))); self.pressed.extend(pressed.map(|b| (*panel, b))); @@ -464,6 +476,13 @@ fn the_app_comes_forward_with_its_viewers_when_it_is_activated() { focus(&mut d, None, 20); focus(&mut d, Some(back), 10); assert_eq!(levels(&mut d), raised(&[front, MAIN, back])); + + // While a modal blocks input, the main window with the modal is raised last, even when + // the app is activated through a viewer. + d.input_blocked = true; + focus(&mut d, None, 20); + focus(&mut d, Some(front), 10); + assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); } #[test] @@ -1179,6 +1198,30 @@ fn files_dragged_onto_a_native_window_are_reported_with_its_panel() { assert!(d.step(Vec::new()).window_files.is_empty()); } +#[test] +fn a_blocked_native_window_reports_no_files() { + let mut d = driver(layout(true)); + let (id, viewport) = (id_of(&d), d.viewport(id_of(&d))); + d.input_blocked = true; + d.step(Vec::new()); + let drop = |d: &mut DockDriver| { + let mut host = d.host(); + let window = host.windows.get_mut(&viewport).unwrap(); + window.hovered_files = vec![egui::HoveredFile { path: Some("a.png".into()), mime: String::new() }]; + window.dropped_files = vec![std::sync::Arc::new(File("a.png".into()))]; + }; + drop(&mut d); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert!(out.window_files.is_empty(), "{:?}", out.window_files); + assert!(!d.content.saw_files); + + d.input_blocked = false; + drop(&mut d); + let out = d.window_step(id, vec![Event::PointerMoved(pos2(60.0, 150.0))]); + assert_eq!(out.window_files.len(), 1); + assert!(d.content.saw_files); +} + #[test] fn escape_or_focus_loss_ends_a_drag_a_native_window_owns() { // Escape cancels it, and is no shortcut. @@ -1480,6 +1523,162 @@ fn a_layout_built_each_frame_without_ids_is_warned_about() { assert!(!warned(&d)); } +/// Fill of the overlay over a blocked native window in [`blocked_driver`]. +const DIM: Color32 = Color32::from_rgba_premultiplied(10, 20, 30, 120); + +fn blocked_driver() -> DockDriver { + let mut d = DockDriver::new(layout(true), Content { counter: Some(5), ..Content::default() }) + .with_style(|s| s.blocked_dim = DIM) + .with_native_host(ORIGIN); + d.settle(); + d +} + +/// Whether the native window `viewport` drew the overlay over all of its content. +fn dimmed(d: &DockDriver, viewport: egui::ViewportId) -> bool { + let full = Rect::from_min_size(Pos2::ZERO, d.host().windows[&viewport].rect.size()); + d.host().windows[&viewport].shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.fill == DIM && r.rect == full)) +} + +#[test] +fn a_native_window_takes_no_input_while_the_app_blocks_it() { + let mut d = blocked_driver(); + let id = id_of(&d); + let viewport = d.viewport(id); + let counter = d.content.counter_rect.unwrap().center(); + let tab = pos2(90.0, TAB_ROW); + let key = Event::Key { key: Key::S, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + let level = |d: &DockDriver| d.host().windows[&viewport].level; + assert!(!dimmed(&d, viewport)); + assert_eq!(level(&d), Some(egui::WindowLevel::AlwaysOnTop)); + + d.input_blocked = true; + d.step(vec![]); + assert!(dimmed(&d, viewport), "drawn dimmed"); + assert_eq!(level(&d), Some(egui::WindowLevel::Normal), "the main window can cover the palette"); + let out = d.window_click(id, tab); + assert!(out.actions.is_empty(), "{:?}", out.actions); + d.window_click(id, counter); + assert_eq!(d.content.count, 0, "the body takes no click"); + let out = d.window_step(id, vec![key.clone(), Event::Text("s".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + // A key released while blocked still reaches the app, which may have seen it go down. + let release = Event::Key { key: Key::S, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }; + let out = d.window_step(id, vec![release.clone()]); + assert_eq!(out.forwarded_events, vec![release]); + // The bar starts no move, of the dock area's or the window system's. + let sent = d.host().commands.len(); + d.window_hold(id, pos2(40.0, 9.0), pos2(140.0, 59.0)); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_none()); + assert!( + !d.host().commands[sent..].iter().any(|(v, c)| *v == viewport && matches!(c, ViewportCommand::StartDrag | ViewportCommand::OuterPosition(_))) + ); + let out = d.window_release(id, pos2(140.0, 59.0)); + assert!(out.actions.is_empty(), "{:?}", out.actions); + // The OS close request is cancelled. + d.host().windows.get_mut(&viewport).unwrap().close = true; + let out = d.step(vec![]); + assert!(!out.actions.iter().any(|a| matches!(a, Action::CloseWindow { .. })), "{:?}", out.actions); + assert!(d.host().commands.contains(&(viewport, ViewportCommand::CancelClose))); + + // Unblocked, the same input works again. + d.input_blocked = false; + d.step(vec![]); + assert!(!dimmed(&d, viewport)); + assert_eq!(level(&d), Some(egui::WindowLevel::AlwaysOnTop)); + d.window_click(id, counter); + assert_eq!(d.content.count, 1); + let out = d.window_click(id, tab); + assert!(out.actions.contains(&Action::Activate { panel: 6 }), "{:?}", out.actions); + let out = d.window_step(id, vec![key.clone()]); + assert_eq!(out.forwarded_events, vec![key]); +} + +#[test] +fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns() { + use egui::{ViewportId, WindowLevel}; + let mut d = blocked_driver(); + let palette = d.viewport(id_of(&d)); + d.input_blocked = true; + d.step(vec![]); + d.host().commands.clear(); + let focus = |d: &mut DockDriver, window: Option| { + d.host().focus = window; + for _ in 0..20 { + d.step(vec![]); + } + }; + // Showing it again would raise it over the modal and, on macOS, give it focus. + focus(&mut d, None); + assert!(d.host().windows[&palette].visible); + focus(&mut d, Some(palette)); + let host = d.host(); + assert!(!host.commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))), "{:?}", host.commands); + // Activated through the palette, the main window comes in front of it, with no viewers. + let levels: Vec<_> = host.commands.iter().filter(|(_, c)| matches!(c, ViewportCommand::WindowLevel(_))).cloned().collect(); + let expected = if cfg!(target_os = "macos") { + Vec::new() + } else { + [WindowLevel::AlwaysOnTop, WindowLevel::Normal].map(|l| (ViewportId::ROOT, ViewportCommand::WindowLevel(l))).to_vec() + }; + assert_eq!(levels, expected); +} + +#[test] +fn a_focused_field_and_a_held_press_stay_inert_across_a_block() { + let mut d = blocked_driver(); + let id = id_of(&d); + d.content.text = Some(5); + d.content.focus_text = true; + d.step(vec![]); + d.window_step(id, vec![Event::Text("a".into())]); + assert_eq!(d.content.typed, "a"); + // The field loses focus when the block starts, and stays without it after. + d.input_blocked = true; + d.step(vec![]); + let out = d.window_step(id, vec![Event::Text("b".into())]); + assert!(out.forwarded_events.is_empty(), "{:?}", out.forwarded_events); + d.input_blocked = false; + d.step(vec![]); + let out = d.window_step(id, vec![Event::Text("c".into())]); + assert_eq!(d.content.typed, "a"); + assert_eq!(out.forwarded_events, vec![Event::Text("c".into())]); + + // A press made while blocked and released after is no click. + let counter = d.content.counter_rect.unwrap().center(); + d.input_blocked = true; + d.step(vec![]); + d.window_step(id, vec![Event::PointerMoved(counter), button(counter, true)]); + d.input_blocked = false; + d.step(vec![]); + d.window_step(id, vec![button(counter, false)]); + assert_eq!(d.content.count, 0); +} + +#[test] +fn blocking_input_cancels_a_drag_in_progress() { + // A tab drag a native window owns. + let mut d = blocked_driver(); + let id = id_of(&d); + let over = d.window_local(id, ORIGIN + vec2(600.0, 300.0)).unwrap(); + d.window_hold(id, pos2(15.0, TAB_ROW), over); + assert!(super::is_dragging::(&d.ctx, d.id)); + d.input_blocked = true; + d.step(vec![]); + assert!(!super::is_dragging::(&d.ctx, d.id)); + let out = d.window_release(id, over); + assert!(!out.actions.iter().any(|a| matches!(a, Action::Move { .. } | Action::Float { .. })), "{:?}", out.actions); + + // A bar drag. + let mut d = blocked_driver(); + let id = id_of(&d); + d.window_hold(id, pos2(40.0, 9.0), pos2(140.0, 59.0)); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_some()); + d.input_blocked = true; + d.step(vec![]); + assert!(d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))).is_none()); +} + #[test] fn replacing_the_layout_or_cancel_drag_ends_a_bar_drag() { let moving = |d: &DockDriver| d.ctx.data(|data| data.get_temp::(d.id.with("window-drag"))); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index 49c1c2c7..276dc451 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -81,6 +81,9 @@ pub(super) struct Renderer<'a, P> { /// [`crate::docking::DockArea::monitors`]. #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] pub(super) monitors: Vec, + /// [`crate::docking::DockArea::input_blocked`]. + #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] + pub(super) input_blocked: bool, /// [`crate::docking::DockArea::native_windows`]. #[cfg_attr(not(feature = "native-windows"), allow(dead_code))] pub(super) native_windows: Option, diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 4d47c04a..a3c30526 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -10,8 +10,8 @@ use std::{fmt::Debug, hash::Hash}; use egui::{ - Context, CursorIcon, Event, Id, Key, Modifiers, Pos2, RawInput, Rect, ResizeDirection, Sense, Ui, Vec2, ViewportBuilder, ViewportClass, - ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, vec2, + Color32, Context, CursorIcon, Event, Id, Key, LayerId, Modifiers, Order, Pos2, RawInput, Rect, ResizeDirection, Sense, Ui, UiBuilder, Vec2, + ViewportBuilder, ViewportClass, ViewportCommand, ViewportId, ViewportInfo, WindowLevel, pos2, vec2, }; use super::Renderer; @@ -331,13 +331,19 @@ impl Renderer<'_, P> { if activated && RAISE_ON_ACTIVATION { // The back one first, so each lands in front of those raised before it, and the // window just focused, at the front of the order, last. + // While input is blocked the main window, with the modal, ends in front. + if self.input_blocked { + order.retain(Option::is_some); + order.insert(0, None); + } let queue: Vec> = order .iter() .rev() .filter(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id && self.normal_window(w)))) .copied() .collect(); - if queue.iter().any(Option::is_some) { + // Blocked, the main window comes in front of a palette the app was activated through. + if self.input_blocked || queue.iter().any(Option::is_some) { ctx.data_mut(|d| d.insert_temp(self.id.with("native-raise"), queue)); } } @@ -462,7 +468,9 @@ impl Renderer<'_, P> { .with_position([spawn[0], spawn[1]]) .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) - .with_window_level(if normal { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) + // A blocked palette could cover the modal, and its bar moves nothing: the main + // window can come in front of it at the normal level. + .with_window_level(if normal || self.input_blocked { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) .with_taskbar(false); // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and // buttons hidden: AppKit resizes a movable window from its edges and corners with its @@ -473,18 +481,22 @@ impl Renderer<'_, P> { .with_title_shown(false) .with_fullsize_content_view(true) .with_titlebar_buttons_shown(false) - .with_resizable(window.panels().all(|p| self.permissions(p).movable)); + .with_resizable(!self.input_blocked && window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); + // A blocked palette keeps showing while the app is away: showing it on the app's return + // would raise it over the modal and, on macOS, give it focus. + let visible = visible || (self.input_blocked && place.shown); // Only on a change: showing a shown window raises it and, on macOS, takes focus. if place.shown != visible { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); place.shown = visible; } - if visible && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + if visible && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); + let blocked = self.input_blocked; ctx.with_plugin(|input: &mut NativeInput| input.windows.insert(viewport, self.id)); let report = ctx.show_viewport_immediate(viewport, builder, |ui, class| { // `cancel_drag` reaches this viewport's drag here. @@ -493,6 +505,10 @@ impl Renderer<'_, P> { ui.ctx().stop_dragging(); } let full = ui.max_rect(); + // An embedded fallback reads the main window's input: its close request, place, + // keys and resizes are not this window's. + let own = matches!(class, ViewportClass::Immediate | ViewportClass::Deferred); + let released = if blocked && own { block_input(ui) } else { Vec::new() }; ui.painter().rect_filled(full, 0.0, self.style.floating_background); let buttons = self.native_bar(ui, window); let body = Rect::from_min_max(pos2(full.left(), (full.top() + BAR_HEIGHT).min(full.bottom())), full.max); @@ -502,9 +518,7 @@ impl Renderer<'_, P> { if debug_close() { debug_outlines(ui, full, &buttons); } - // An embedded fallback reads the main window's input: its close request, place, - // keys and resizes are not this window's. - if !matches!(class, ViewportClass::Immediate | ViewportClass::Deferred) { + if !own { return Report::default(); } if window.panels().all(|p| self.permissions(p).movable) { @@ -523,9 +537,18 @@ impl Renderer<'_, P> { windows: self.frame.windows.split_off(windows), }; surface.origin = self.window_gestures(ui, window); - self.paint_cross(ui, &mut surface); - self.forward_events(ui); - self.report_files(ui, window, viewport); + if blocked { + (surface.targets, surface.dock_targets, surface.windows) = Default::default(); + cover(ui, self.id.with(("native-blocked", window.id)), full, self.style.blocked_dim); + if !released.is_empty() { + self.output.forwarded_modifiers = Some(ui.input(|i| i.modifiers)); + self.output.forwarded_events.extend(released); + } + } else { + self.paint_cross(ui, &mut surface); + self.forward_events(ui); + self.report_files(ui, window, viewport); + } ui.input(|i| Report { closed: i.viewport().close_requested(), placed: placed(i.viewport()), @@ -575,7 +598,7 @@ impl Renderer<'_, P> { if report.closed { // Like the bar's ×: its panels close and reopen from the application's menu. A // window the application would refuse to close stays open. - if window.panels().all(|p| self.permissions(p).closable) { + if !blocked && window.panels().all(|p| self.permissions(p).closable) { self.output.actions.push(Action::CloseWindow { panel: first }); return Some(key); } @@ -892,6 +915,39 @@ fn panels(windows: &[(u64, Vec

)]) -> std::collections::HashSet< windows.iter().map(|(_, panels)| panels).collect() } +/// The start of a blocked native window's frame: its widgets are disabled, and the pointer, +/// keys, text and scrolling are taken out of its input before anything reads them, so a panel +/// that reads input directly sees none either. Returns the key releases it took out, for the +/// app: a key pressed before the block would otherwise stay down in the app's shortcuts. +fn block_input(ui: &mut Ui) -> Vec { + // Disabling fades what the window paints, its opaque background too; the cover dims it. + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + let kept = |e: &Event| matches!(e, Event::Screenshot { .. } | Event::WindowFocused(_)); + let released = ui.input(|i| i.events.iter().filter(|e| matches!(e, Event::Key { pressed: false, .. })).cloned().collect()); + ui.input_mut(|i| { + i.events.retain(kept); + i.raw.events.retain(kept); + // File drops are fields of their own, not events. + i.raw.dropped_files.clear(); + i.raw.hovered_files.clear(); + i.keys_down.clear(); + i.smooth_scroll_delta = Vec2::ZERO; + i.pointer = Default::default(); + }); + ui.memory_mut(|m| m.stop_text_input()); + released +} + +/// The end of a blocked native window's frame: `dim` over its content `full`, in a layer above +/// its popups that takes the pointer, so a popup left open takes no click either. +fn cover(ui: &Ui, id: Id, full: Rect, dim: Color32) { + let cover = Ui::new(ui.ctx().clone(), id, UiBuilder::new().layer_id(LayerId::new(Order::Tooltip, id)).max_rect(full)); + cover.painter().rect_filled(full, 0.0, dim); + cover.interact(full, id.with("sink"), Sense::CLICK | Sense::DRAG); +} + /// Move and size a native window so its content is the desktop rect `to`. The size goes /// first, so the move puts the top-left corner in place whichever corner the window system /// keeps on a resize (AppKit's frame origin is the bottom left). diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index f2f473d7..3e994ebc 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -49,6 +49,8 @@ pub struct DockStyle { /// Painted under every floating window, so it stays opaque when the panel colors are not. pub floating_background: Color32, pub floating_stroke: Stroke, + /// Painted over a native floating window while [`DockArea::input_blocked`] is on. + pub blocked_dim: Color32, /// Height of a floating window's title bar. pub title_height: f32, /// Width of the band along the layout's outer edge that splits at the root. @@ -169,6 +171,7 @@ impl DockStyle { drag_bar_width: 40.0, floating_background: visuals.window_fill, floating_stroke: visuals.window_stroke, + blocked_dim: visuals.panel_fill.gamma_multiply(0.6), title_height: super::chrome::TITLE_HEIGHT, edge_band: 8.0, side_edge_zones: false, @@ -908,6 +911,7 @@ pub struct DockArea<'a, P = ()> { auto_collapse: bool, float_bounds: Option, monitors: Vec, + input_blocked: bool, native_windows: Option, #[cfg(feature = "native-windows")] input_hook: Option, @@ -925,6 +929,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { auto_collapse: true, float_bounds: None, monitors: Vec::new(), + input_blocked: false, native_windows: None, #[cfg(feature = "native-windows")] input_hook: None, @@ -940,6 +945,19 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { self } + /// Native floating windows ignore input while `blocked` (feature `native-windows`): pass + /// true while the application shows a modal dialog. egui's `Modal` blocks only the + /// viewport it is shown in, so the OS windows of floating panels would otherwise still take + /// clicks and keys. A blocked window is drawn under [`DockStyle::blocked_dim`]; its bar, + /// tabs, body and resize edges take no pointer, keys, text, scrolling or files, forward only + /// key releases, and offer no drop targets, and its OS close request is cancelled. Turning + /// it on cancels any drag in progress. Floating windows drawn in the main window need + /// nothing from this: the modal covers them. + pub fn input_blocked(mut self, blocked: bool) -> Self { + self.input_blocked = blocked; + self + } + /// Whether floating windows open in their own OS windows (feature `native-windows`), where /// the backend has viewports. Off, they stay in the app window, and their native rects are /// kept for a later run with it on. By default it is on, except on Wayland: the compositor @@ -1099,6 +1117,13 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { cancel_drag::

(ui.ctx(), self.id); return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() }; } + // Blocking starts while the pointer may be down: a drag that went on would act on its + // release, under the modal. + let blocked_key = self.id.with("input-blocked"); + if self.input_blocked && !ui.ctx().data(|d| d.get_temp::(blocked_key).unwrap_or(false)) { + cancel_drag::

(ui.ctx(), self.id); + } + ui.ctx().data_mut(|d| d.insert_temp(blocked_key, self.input_blocked)); cancel_drag_if_replaced(ui.ctx(), self.id, layout); release_moved_tab_focus(ui.ctx(), self.id, layout); let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content); @@ -1130,6 +1155,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> { dividers: Vec::new(), tree: None, monitors: self.monitors, + input_blocked: self.input_blocked, native_windows: self.native_windows, #[cfg(feature = "native-windows")] cross: Default::default(), diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index dd3dbe6e..95a53d65 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -50,6 +50,8 @@ pub struct DockDriver { pub monitors: Vec, /// Passed to [`DockArea::locked`]. pub locked: bool, + /// Passed to [`DockArea::input_blocked`]. + pub input_blocked: bool, /// Passed to [`DockArea::native_windows`]: on by default, whatever the test's desktop. pub native_windows: bool, /// The stand-in for OS windows, in use after [`Self::with_native_host`]. @@ -84,6 +86,7 @@ impl> Do float_bounds: None, monitors: Vec::new(), locked: false, + input_blocked: false, native_windows: true, #[cfg(feature = "native-windows")] host: None, @@ -137,8 +140,16 @@ impl> Do let monitors = self.monitors.clone(); #[cfg(feature = "native-windows")] let input_hook = self.input_hook.clone(); - let (style_fn, maximized, raise, auto, bounds, locked, native) = - (&self.style, self.maximized.clone(), self.raise.take(), self.auto_collapse, self.float_bounds, self.locked, self.native_windows); + let (style_fn, maximized, raise, auto, bounds, locked, native, blocked) = ( + &self.style, + self.maximized.clone(), + self.raise.take(), + self.auto_collapse, + self.float_bounds, + self.locked, + self.native_windows, + self.input_blocked, + ); let mut full = self.ctx.run_ui(input, |ui| { let mut style = DockStyle::from_ui(ui); if let Some(adjust) = style_fn { @@ -150,7 +161,8 @@ impl> Do .auto_collapse_iconic(auto) .monitors(monitors.clone()) .locked(locked) - .native_windows(native); + .native_windows(native) + .input_blocked(blocked); if let Some(panel) = raise.clone() { dock = dock.raise_panel(panel); } diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 34f41695..eef8db51 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -274,7 +274,8 @@ A flyout fits its content the same way, without the title bar. or changed the layout by command. Sizes and rectangles are not compared, so an app may clamp a moved window or adjust a split during the drag. Nothing is restored from before the drag; windows go where the new layout has them. `cancel_drag` ends the area's drags at any other - time. + time. A native window moved by the compositor (`ViewportCommand::StartDrag`, as on Wayland) + is not the area's drag: it ends when the user releases the button. - With `float_on_empty_drop`, a release over no target floats what moves at the pointer, such as a panel dropped on a protected canvas. Without it, that release cancels the drag and nothing moves: the gap between two groups, a menu bar or a status bar is no target. A release @@ -473,6 +474,28 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho `Paste` without the key). The app feeds them to the shortcut handling it runs on the main window. A window where any widget has keyboard focus forwards nothing, and an Escape that cancels a drag is not forwarded. +- **Modal dialogs.** egui's `Modal` blocks input only in the viewport it is shown in. While the + app shows one, it passes `DockArea::input_blocked(true)`. Each native window is then drawn + under `DockStyle::blocked_dim` (by default the panel fill at 60 %), and its bar, tabs, body + and resize edges take no input: its widgets are disabled, the pointer, key, text and scroll + events are dropped at the start of its frame, and a layer over its content takes the pointer, + so a popup left open takes no click either. It forwards only key releases, so a key pressed + before the block does not stay down in the app's shortcuts. It reports no `window_files`, + offers no drop targets, starts no move or resize, is not focused by `raise_panel`, and + answers the OS close request with `ViewportCommand::CancelClose`. On macOS it is not + resizable meanwhile. + - *Stacking.* A palette drops to `WindowLevel::Normal` while blocked, so a click on the main + window brings it in front of a palette that covers the modal. A shown palette stays shown + while the app is in the background, since showing it again on the app's return would raise + it over the modal and, on macOS, give it focus. When the app is activated meanwhile, the + main window is raised last, in front of the normal windows and of a palette the user + clicked. + - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window + loses keyboard focus. A zoom gesture still shows in egui's `InputState::zoom_delta`, which + cannot be cleared; a widget that zooms only while hovered ignores it. egui smooths wheel + scrolling, so a scroll area in a native window may still scroll for about 100 ms after + blocking ends, with the remainder of a wheel turn made just before it began. + - Floating windows drawn in the main window need nothing: the modal covers them. - **Input hook.** eframe runs `App::raw_input_hook` for the main window only. An app that rewrites input there (say, Ctrl+click to a secondary click on macOS, or blocking input during automation) passes the same rewrite to `DockArea::native_input_hook` every frame as a