From d8394d5d87525fa756610b71ab4629d92e15cc7e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 13:21:37 -0800 Subject: [PATCH 1/4] Fix #4995: Above Text moves a Tabs panel that already floats Position Panel Above Text moved a floating Tabs panel only in the layout's in-window rect. On desktop builds a floating panel is an OS window, which moves only when its desktop rect changes, so the panel stayed where it was and the layout followed it back on the next frame. A docked panel floated above the text as expected. place_floating now also sets the window's desktop rect, from the main window's content origin, when the panel's window already has one. --- crates/ui-egui/src/panel_docking.rs | 12 +++++++--- crates/ui-egui/src/panels/tabs.rs | 4 ++-- crates/ui-egui/src/panels/tabs/tests.rs | 32 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index cdec00eaa..83cad5282 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -787,8 +787,9 @@ fn float_space(app: &VectorcraftApp) -> FloatSpace { } /// Float `panel` (open or not) as a window of its own with its top-left corner at `at`, `width` -/// wide: the Tabs panel over the text it sets. -pub(crate) fn place_floating(app: &mut VectorcraftApp, panel: &str, at: egui::Pos2, width: f32) -> Result<(), String> { +/// wide: the Tabs panel over the text it sets. `origin`: the main window's content top-left on the +/// desktop. A panel already floating in an OS window of its own moves to `at` from there (#4995). +pub(crate) fn place_floating(app: &mut VectorcraftApp, panel: &str, at: egui::Pos2, width: f32, origin: Option) -> Result<(), String> { let panel = normalize(panel).ok_or("unknown panel")?.to_string(); if !(at.x.is_finite() && at.y.is_finite() && width.is_finite()) { return Err("panel position must be finite".into()); @@ -800,8 +801,13 @@ pub(crate) fn place_floating(app: &mut VectorcraftApp, panel: &str, at: egui::Po let height = floating_window(&txn.workspace.layout, &panel).map_or(floating_size(&panel).y, |w| w.rect[3]); let rect = [at.x, at.y, width.max(1.0), height]; let whole = floating_window(&txn.workspace.layout, &panel).is_some_and(|w| w.panels().count() == 1); - let action = if whole { Action::MoveFloating { panel, rect } } else { Action::Float { panel, rect } }; + let native = whole && floating_window(&txn.workspace.layout, &panel).is_some_and(|w| w.native.is_some()); + let action = if whole { Action::MoveFloating { panel: panel.clone(), rect } } else { Action::Float { panel: panel.clone(), rect } }; txn.apply(action)?; + // The OS window follows its desktop rect, not the in-window one. + if let Some(origin) = origin.filter(|o| native && o.is_finite()) { + txn.apply(Action::SetNative { panel, rect: Some([origin.x + at.x, origin.y + at.y, rect[2], rect[3]]) })?; + } txn.commit(app, Vec::new(), None); Ok(()) } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 1e0818454..f8a2d758e 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -266,8 +266,8 @@ fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64 let width = span as f32 * per + RULER_LEFT * 2.0; let height = crate::panel_docking::layout_of(&app.ui).floating.iter().find(|w| w.contains(&"tabs".to_string())).map_or(240.0, |w| w.rect[3]); let pos = egui::pos2(on_screen.left() - RULER_LEFT, on_screen.top() - height - 6.0); - let _ = ctx; - crate::panel_docking::place_floating(app, "tabs", pos, width).ok()?; + let origin = ctx.input(|i| i.raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)).map(|r| r.min); + crate::panel_docking::place_floating(app, "tabs", pos, width, origin).ok()?; Some(()) } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index b598d0c2a..5fd7b17c4 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1012,3 +1012,35 @@ fn above_text_puts_the_ruler_over_the_text_at_the_zoom() { } assert_eq!(app.ui.docking.unwrap().floating.len(), 1, "placed again, not floated twice"); } + +/// Position Panel Above Text moves the panel when it already floats in an OS window of its own: +/// the window's desktop rect goes above the text too, from the main window's top-left (#4995). +#[test] +fn above_text_moves_a_panel_floating_in_its_own_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + if let Some(v) = app.view_mut() { + v.zoom = 1.0; + } + let (node, _, span) = current(&app).unwrap(); + let ctx = egui::Context::default(); + above_text(&mut app, &ctx, &node, span).unwrap(); + // The panel's OS window sits elsewhere on the desktop, as after the user dragged it there. + let mut layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter_mut().find(|w| w.contains(&"tabs".to_string())).unwrap(); + window.native = Some([1500.0, 900.0, window.rect[2], window.rect[3]]); + app.ui.docking = Some(layout); + // The main window's content starts at (200, 100) on the desktop. + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().inner_rect = + Some(egui::Rect::from_min_size(egui::pos2(200.0, 100.0), vec2(1200.0, 800.0))); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); + let native = g.native.unwrap(); + assert_eq!([native[0], native[1]], [200.0 + g.rect[0], 100.0 + g.rect[1]], "the OS window goes above the text"); + assert_eq!([native[2], native[3]], [g.rect[2], g.rect[3]]); +} From 48e2bc65086428ae2d56a783752d7fdbd277d30d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:27:13 -0800 Subject: [PATCH 2/4] Tabs: Above Text keeps the panel's bar inside the window With text near the top of the window, Position Panel Above Text put the panel, and its OS window, above the main window's content and could put the bar above every screen. The panel now goes no higher than the top of the main window's content. --- crates/ui-egui/src/panels/tabs.rs | 4 ++- crates/ui-egui/src/panels/tabs/tests.rs | 34 ++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index f8a2d758e..e68ae9b54 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -265,7 +265,9 @@ fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64 } let width = span as f32 * per + RULER_LEFT * 2.0; let height = crate::panel_docking::layout_of(&app.ui).floating.iter().find(|w| w.contains(&"tabs".to_string())).map_or(240.0, |w| w.rect[3]); - let pos = egui::pos2(on_screen.left() - RULER_LEFT, on_screen.top() - height - 6.0); + // Text near the top of the window: the panel's bar stays inside the window, where it can be + // grabbed, not above the screen. + let pos = egui::pos2(on_screen.left() - RULER_LEFT, (on_screen.top() - height - 6.0).max(0.0)); let origin = ctx.input(|i| i.raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)).map(|r| r.min); crate::panel_docking::place_floating(app, "tabs", pos, width, origin).ok()?; Some(()) diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 5fd7b17c4..8ab728afe 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -991,7 +991,8 @@ fn above_text_puts_the_ruler_over_the_text_at_the_zoom() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + // The canvas starts low enough in the window for the panel to fit above the text at 200%. + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 440.0), vec2(1000.0, 700.0))); for zoom in [1.0, 2.0] { if let Some(v) = app.view_mut() { v.zoom = zoom; @@ -1044,3 +1045,34 @@ fn above_text_moves_a_panel_floating_in_its_own_window() { assert_eq!([native[0], native[1]], [200.0 + g.rect[0], 100.0 + g.rect[1]], "the OS window goes above the text"); assert_eq!([native[2], native[3]], [g.rect[2], g.rect[3]]); } + +/// Text near the top of the window: Position Panel Above Text keeps the panel's bar inside the +/// window, so neither the panel nor its OS window goes above the screen. +#[test] +fn above_text_keeps_the_panel_bar_in_the_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 10, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + if let Some(v) = app.view_mut() { + v.zoom = 1.0; + } + let (node, _, span) = current(&app).unwrap(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(xf.rect_to_screen(node.geometric_bounds().unwrap()).top() < 240.0, "no room above the text"); + let ctx = egui::Context::default(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let mut layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter_mut().find(|w| w.contains(&"tabs".to_string())).unwrap(); + assert_eq!(window.rect[1], 0.0, "the panel's bar at the window's top"); + window.native = Some([1500.0, 900.0, window.rect[2], window.rect[3]]); + app.ui.docking = Some(layout); + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().inner_rect = + Some(egui::Rect::from_min_size(egui::pos2(200.0, 100.0), vec2(1200.0, 800.0))); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); + assert_eq!(g.native.unwrap()[1], 100.0, "the OS window's bar at the main window's content top"); +} From 442430ff12c54dc50a6f152f3236cdb46718f053 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 14:57:49 -0800 Subject: [PATCH 3/4] Tabs: Above Text puts the panel below text near the window's top With no room above the text, the panel went to the top of the main window's content and could cover the text. It now goes just below the text when it fits there in the canvas, and at the window's top only when it does not. --- crates/ui-egui/src/panels/tabs.rs | 16 +++++-- crates/ui-egui/src/panels/tabs/tests.rs | 57 ++++++++++++++----------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index e68ae9b54..ae49d2048 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -265,9 +265,19 @@ fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64 } let width = span as f32 * per + RULER_LEFT * 2.0; let height = crate::panel_docking::layout_of(&app.ui).floating.iter().find(|w| w.contains(&"tabs".to_string())).map_or(240.0, |w| w.rect[3]); - // Text near the top of the window: the panel's bar stays inside the window, where it can be - // grabbed, not above the screen. - let pos = egui::pos2(on_screen.left() - RULER_LEFT, (on_screen.top() - height - 6.0).max(0.0)); + // Text near the top of the window: the panel goes just below the text when it fits there in + // the canvas, else at the window's top, so its bar stays where it can be grabbed rather than + // above the screen. + let above = on_screen.top() - height - 6.0; + let below = on_screen.bottom() + 6.0; + let top = if above >= 0.0 { + above + } else if below + height <= app.canvas_rect.map_or(f32::NEG_INFINITY, |c| c.bottom()) { + below + } else { + 0.0 + }; + let pos = egui::pos2(on_screen.left() - RULER_LEFT, top); let origin = ctx.input(|i| i.raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)).map(|r| r.min); crate::panel_docking::place_floating(app, "tabs", pos, width, origin).ok()?; Some(()) diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 8ab728afe..8a6d2394e 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1046,33 +1046,38 @@ fn above_text_moves_a_panel_floating_in_its_own_window() { assert_eq!([native[2], native[3]], [g.rect[2], g.rect[3]]); } -/// Text near the top of the window: Position Panel Above Text keeps the panel's bar inside the -/// window, so neither the panel nor its OS window goes above the screen. +/// Text near the top of the window: Position Panel Above Text puts the panel just below the text, +/// so neither the panel nor its OS window goes above the screen and the text stays in view. Text +/// with no room below it either gets the panel at the window's top. #[test] fn above_text_keeps_the_panel_bar_in_the_window() { - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); - app.session.execute("text.create", &json!({"x": 40, "y": 10, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); - if let Some(v) = app.view_mut() { - v.zoom = 1.0; + for (text_height, below) in [(100.0, true), (760.0, false)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 10, "text": "a b", "area": {"width": 300, "height": text_height}})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + if let Some(v) = app.view_mut() { + v.zoom = 1.0; + } + let (node, _, span) = current(&app).unwrap(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let frame = xf.rect_to_screen(node.geometric_bounds().unwrap()); + assert!(frame.top() < 240.0, "no room above the text"); + let ctx = egui::Context::default(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let mut layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter_mut().find(|w| w.contains(&"tabs".to_string())).unwrap(); + let top = if below { frame.bottom() + 6.0 } else { 0.0 }; + assert_eq!(window.rect[1], top, "text {text_height} high"); + window.native = Some([1500.0, 900.0, window.rect[2], window.rect[3]]); + app.ui.docking = Some(layout); + let mut raw = egui::RawInput::default(); + raw.viewports.entry(egui::ViewportId::ROOT).or_default().inner_rect = + Some(egui::Rect::from_min_size(egui::pos2(200.0, 100.0), vec2(1200.0, 800.0))); + ctx.run_ui(raw, |_| {}).textures_delta.clear(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); + assert_eq!(g.native.unwrap()[1], 100.0 + top, "text {text_height} high: the OS window"); } - let (node, _, span) = current(&app).unwrap(); - let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); - assert!(xf.rect_to_screen(node.geometric_bounds().unwrap()).top() < 240.0, "no room above the text"); - let ctx = egui::Context::default(); - above_text(&mut app, &ctx, &node, span).unwrap(); - let mut layout = app.ui.docking.clone().unwrap(); - let window = layout.floating.iter_mut().find(|w| w.contains(&"tabs".to_string())).unwrap(); - assert_eq!(window.rect[1], 0.0, "the panel's bar at the window's top"); - window.native = Some([1500.0, 900.0, window.rect[2], window.rect[3]]); - app.ui.docking = Some(layout); - let mut raw = egui::RawInput::default(); - raw.viewports.entry(egui::ViewportId::ROOT).or_default().inner_rect = - Some(egui::Rect::from_min_size(egui::pos2(200.0, 100.0), vec2(1200.0, 800.0))); - ctx.run_ui(raw, |_| {}).textures_delta.clear(); - above_text(&mut app, &ctx, &node, span).unwrap(); - let layout = app.ui.docking.clone().unwrap(); - let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); - assert_eq!(g.native.unwrap()[1], 100.0, "the OS window's bar at the main window's content top"); } From 0be7609bf7eb987949ae328b6c5e3d2ae81f6582 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 15:31:53 -0800 Subject: [PATCH 4/4] Tabs: Above Text checks the place below text against the window's top The place below the text was taken even when the text was panned above the canvas, which put the panel above the main window. It is now taken only when it is at or below the window's top. A docked Tabs panel is measured at the 360-point height it floats at, not 240, so it is placed below the text only when it fits there. --- crates/ui-egui/src/panel_docking.rs | 2 +- crates/ui-egui/src/panels/tabs.rs | 14 +++++++++----- crates/ui-egui/src/panels/tabs/tests.rs | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 83cad5282..a35166d97 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1237,7 +1237,7 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ]; /// The size a panel floats at (tab strip included), whatever the size of the dock it left. -fn floating_size(panel: &str) -> egui::Vec2 { +pub(crate) fn floating_size(panel: &str) -> egui::Vec2 { match panel { "properties" | "layers" | "libraries" => egui::vec2(300.0, 420.0), // Tall enough to show its Corners and Shared Edges sections, last in it, which the Corner diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index ae49d2048..c8ef4331c 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -264,15 +264,19 @@ fn above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64 return None; } let width = span as f32 * per + RULER_LEFT * 2.0; - let height = crate::panel_docking::layout_of(&app.ui).floating.iter().find(|w| w.contains(&"tabs".to_string())).map_or(240.0, |w| w.rect[3]); - // Text near the top of the window: the panel goes just below the text when it fits there in - // the canvas, else at the window's top, so its bar stays where it can be grabbed rather than - // above the screen. + let height = crate::panel_docking::layout_of(&app.ui) + .floating + .iter() + .find(|w| w.contains(&"tabs".to_string())) + .map_or(crate::panel_docking::floating_size("tabs").y, |w| w.rect[3]); + // Text near the top of the window: the panel goes just below the text when that place is in + // the window and the panel fits there in the canvas, else at the window's top, so its bar + // stays where it can be grabbed rather than above the screen. let above = on_screen.top() - height - 6.0; let below = on_screen.bottom() + 6.0; let top = if above >= 0.0 { above - } else if below + height <= app.canvas_rect.map_or(f32::NEG_INFINITY, |c| c.bottom()) { + } else if below >= 0.0 && below + height <= app.canvas_rect.map_or(f32::NEG_INFINITY, |c| c.bottom()) { below } else { 0.0 diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 8a6d2394e..7b02b72ff 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -1081,3 +1081,28 @@ fn above_text_keeps_the_panel_bar_in_the_window() { assert_eq!(g.native.unwrap()[1], 100.0 + top, "text {text_height} high: the OS window"); } } + +/// Position Panel Above Text puts the panel at the window's top when the place below the text is +/// above the window (text panned above the canvas), or when the panel at the height it floats at +/// does not fit below the text in the canvas. +#[test] +fn above_text_puts_the_panel_at_the_top_when_below_the_text_does_not_fit() { + // The view's centre and the text's height: panned so the text is above the canvas, and a text + // whose bottom leaves room for 240 points of panel but not for the 360 it floats at. + for (center_y, text_height) in [(600.0, 100.0), (350.0, 340.0)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 600, "height": 800})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 20, "text": "a b", "area": {"width": 300, "height": text_height}})).unwrap(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), vec2(1000.0, 700.0))); + if let Some(v) = app.view_mut() { + v.zoom = 1.0; + v.center = vectorcraft_geom::Point::new(300.0, center_y); + } + let (node, _, span) = current(&app).unwrap(); + let ctx = egui::Context::default(); + above_text(&mut app, &ctx, &node, span).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let g = layout.floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap(); + assert_eq!(g.rect[1], 0.0, "view centered at y {center_y}"); + } +}