diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 5d9eb457f..3d9fef45c 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -125,6 +125,13 @@ impl App { } open_files(&mut self.app, files); } + let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor()); + if let Some(w) = &self.winit_window + && window::monitors_due(self.monitors_listed, now, zoom) + { + self.app.services.dock_monitors = window::dock_monitors(w, zoom); + self.monitors_listed = Some((now, zoom)); + } self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 0078dd4b4..8019276ec 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -111,6 +111,8 @@ struct App { pending_maximize: bool, /// Frames left to retry [`window::apply_pending_maximize`]. maximize_tries: u8, + /// The time and UI zoom of the last monitor list ([`window::monitors_due`]). + monitors_listed: Option<(f64, f32)>, } /// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s. @@ -505,6 +507,7 @@ fn main() -> std::process::ExitCode { winit_window, pending_maximize, maximize_tries: 8, + monitors_listed: None, })) }), ) diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs index abe60f69f..c969e5226 100644 --- a/apps/vectorcraft/src/window.rs +++ b/apps/vectorcraft/src/window.rs @@ -3,6 +3,7 @@ //! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved //! onto a monitor that is still connected. +use vectorcraft_ui_egui::DockMonitor; use vectorcraft_ui_egui::state::WindowGeometry; use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; @@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0]; /// say how much of the screen they take): a monitor with less room opens it maximized. const SCREEN_ROOM: [f32; 2] = [64.0, 128.0]; +/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs. +const MONITORS_EVERY: f64 = 1.0; + /// A monitor: its top-left corner and size in physical pixels, and its scale factor. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Monitor { @@ -67,6 +71,15 @@ impl Monitor { i32::try_from(want.max(lo).min(hi)).unwrap_or_default() }) } + + /// This monitor for floating panels, in the points egui reports for a window on it: physical + /// pixels divided by the monitor's scale factor and the UI zoom. + fn docking(&self, name: Option, zoom: f32) -> DockMonitor { + let scale = self.scale * zoom; + let [x, y] = self.pos; + let [w, h] = self.size; + DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale } + } } /// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else @@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] { std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0)) } +/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most +/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom. +pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool { + listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY) +} + +/// The connected monitors for floating panels (`Services::dock_monitors`). +pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec { + let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect(); + docking_list(&monitors, zoom, cfg!(target_os = "macos")) +} + +/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is +/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the +/// scale of the monitor it is created on, which can put a window saved on another scale off every +/// monitor. With an empty list craft-ui opens the window on the main window's monitor. +/// +/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS +/// can give two displays of one model the same name. +fn docking_list(monitors: &[(Monitor, Option)], zoom: f32, shared_points: bool) -> Vec { + if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) { + return Vec::new(); + } + let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1; + monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect() +} + /// Size and place the new window (eframe shows it after drawing the first frame) and return its /// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run /// until the host reports maximized (fixes #3635). @@ -204,6 +244,51 @@ mod tests { assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE); } + #[test] + fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() { + let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom); + let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0); + assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 }); + // A window at (3000, 200) on this monitor reports (1500, 100). + let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0); + assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 }); + // The UI zoom divides what egui reports as well. + let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25); + assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 }); + } + + #[test] + fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() { + let named = |m: Monitor, name: &str| (m, Some(name.to_string())); + let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")]; + assert_eq!(docking_list(&same, 1.0, false).len(), 2); + let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")]; + assert_eq!(docking_list(&mixed, 1.0, false), Vec::new()); + assert_eq!(docking_list(&mixed, 1.0, true).len(), 2); + assert_eq!(docking_list(&[], 1.0, false), Vec::new()); + } + + #[test] + fn a_name_that_two_monitors_share_is_left_out_of_the_list() { + let list = [ + (monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())), + (monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())), + (monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())), + (monitor(5760, 0, 1920, 1080, 1.0), None), + ]; + let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect(); + assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]); + } + + #[test] + fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() { + assert!(monitors_due(None, 0.0, 1.0)); + assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0)); + assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0)); + assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0)); + assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25)); + } + #[test] fn the_saved_window_comes_back_where_it_was() { let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index afb8cbbc4..21c000c12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,7 @@ mod services; pub use app::MAX_RECENT_FONTS; pub use caches::{CacheKey, CanvasCache, LivePaintGapsCache, Perf, PrintTilingCache, SliceCache}; pub use clock::{now_ms, now_secs}; +pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index ff39d90cf..e2cb86d34 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1147,6 +1147,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut dock = craft_ui::docking::DockArea::new(area) .blocked_rects(&blocked) .input_blocked(modal) + .monitors(app.services.dock_monitors.clone()) .auto_collapse_iconic(app.session.prefs.auto_collapse_icon_panels); #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index 6ef2ab536..5b538b765 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -42,6 +42,8 @@ struct Host { time: f64, /// Every command sent to a window, in order. commands: Vec<(ViewportId, ViewportCommand)>, + /// The monitor size each window reports. + monitor_size: Option, } impl Host { @@ -51,6 +53,7 @@ impl Host { outer_rect: Some(rect), focused: Some(self.focus == id), native_pixels_per_point: Some(1.0), + monitor_size: self.monitor_size, ..Default::default() }; std::iter::once((ViewportId::ROOT, info(ViewportId::ROOT, SCREEN.translate(ORIGIN.to_vec2())))) @@ -199,6 +202,7 @@ impl Harness { focus: ViewportId::ROOT, time: 0.0, commands: Vec::new(), + monitor_size: None, })); let shared = host.clone(); egui::Context::set_immediate_viewport_renderer(move |ctx, viewport| Host::render(&shared, ctx, viewport)); @@ -477,6 +481,61 @@ fn assert_submenu_reaches_its_end(h: &mut Harness, panel: &str, height: f32, row assert!(end.bottom() <= height, "{last} at {end:?} is inside the {height} pt window"); } +/// A new launch on a desktop of `monitors`, with `saved` as the panel layout: where the window of +/// the floating Swatches panel opens. +fn reopened(saved: &craft_ui::docking::Layout, monitors: &[crate::DockMonitor]) -> Rect { + let mut h = Harness::new(); + h.host.borrow_mut().monitor_size = Some(vec2(1920.0, 1080.0)); + h.app.services.dock_monitors = monitors.to_vec(); + h.app.ui.docking = Some(saved.clone()); + h.frames(3); + let viewport = h.viewport("swatches"); + h.host.borrow().windows[&viewport].rect +} + +/// Two monitors side by side, with the main window on the left one (#5582). +#[test] +fn a_floating_panel_reopens_on_the_monitor_where_it_was_left() { + let left = crate::DockMonitor { name: Some("left".into()), rect: [0.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let right = crate::DockMonitor { name: Some("right".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 }; + let both = [left.clone(), right.clone()]; + + let mut h = Harness::new(); + h.host.borrow_mut().monitor_size = Some(vec2(1920.0, 1080.0)); + h.app.services.dock_monitors = both.to_vec(); + let viewport = h.float("swatches"); + let size = h.host.borrow().windows[&viewport].rect.size(); + let placed_at = pos2(2400.0, 300.0); + h.host.borrow_mut().windows.get_mut(&viewport).unwrap().rect = Rect::from_min_size(placed_at, size); + // The layout follows the window after craft-ui's wait for a move to settle, half a second. + h.frames(30); + + let layout = layout_of(&h.app.ui); + let window = &layout.floating[0]; + assert_eq!(window.native.map(|[x, y, ..]| pos2(x, y)), Some(placed_at)); + assert_eq!(window.monitor.as_ref(), Some(&right), "the window stores the monitor it is on"); + // The preferences keep the layout as JSON. + let saved: craft_ui::docking::Layout = serde_json::from_str(&serde_json::to_string(&layout).unwrap()).unwrap(); + + assert_eq!(reopened(&saved, &both).min, placed_at, "the monitor is connected: the saved place"); + + // The monitor got a new name and the same place. + let renamed = [left.clone(), crate::DockMonitor { name: Some("DP-2".into()), ..right.clone() }]; + assert_eq!(reopened(&saved, &renamed).min, placed_at); + + // A layout saved before the monitors were listed stores none. + let mut unstored = saved.clone(); + unstored.floating[0].monitor = None; + assert_eq!(reopened(&unstored, &both).min, placed_at, "a place on a connected monitor is kept"); + + // The monitor is gone: the main window's monitor, at the same offset from its origin. + assert_eq!(reopened(&saved, std::slice::from_ref(&left)).min, pos2(placed_at.x - 1920.0, placed_at.y)); + + // No monitor list (a host that can't list them): onto the main window's monitor by egui's size. + let no_list = reopened(&saved, &[]); + assert_eq!(no_list.min, pos2(1920.0 - size.x, placed_at.y)); +} + #[test] fn keys_typed_in_a_native_window_run_the_main_windows_shortcuts() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 81b152a79..47374c7e6 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -169,6 +169,11 @@ pub struct Services { /// bar's open menu) for floating panels in OS windows of their own: eframe runs the host's /// `raw_input_hook` for the main window only. pub native_input_hook: Option, + /// The connected monitors, kept current by the desktop host: floating panels in OS windows of + /// their own store the monitor they are on and reopen on it. The rects are in the points egui + /// reports for a window on that monitor. Empty on the web, and where the host cannot place a + /// window by these points, which sends floating panels to the main window's monitor. + pub dock_monitors: Vec, } /// Rewrites one window's raw input before egui reads it ([`Services::native_input_hook`]). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e35763702..e6f448a79 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -678,6 +678,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent. - A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`). - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. + - On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`. - A menu in a floating panel's OS window stops at that window's bottom edge and scrolls past it, and is never taller than the window (#5347; `a_long_panel_menu_scrolls_inside_a_short_native_window`). A panel's long submenus (Digits, Stylistic Sets, Save Selection, Move to Group), the libraries menu, the Color Guide's Limit to Library menu and the Layers panel's Filter popup scroll the same way. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. The toolbars are kept with the workspace they were made in: switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new workspace with a copy of them (; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`).