From 5685cc256ca68e843e0df195a72f1581f0a45182 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 16:41:51 -0800 Subject: [PATCH] Tabs: Above Text keeps the ruler at the zoom when the panel comes out wider Position Panel Above Text sizes the Tabs panel so its ruler spans the text at the canvas zoom. A floating panel is at least 220 pt wide, so for narrow text at a low zoom the panel comes out wider than asked, and the ruler stretched the text's width across the whole panel. Its marks then no longer sat over the text. After Above Text, while the same text in the same document is selected and the panel floats no wider than the narrowest panel, the ruler now draws the text at its screen scale and runs on past the text's width. Any other ruler fits the span to its width as before. --- crates/ui-egui/src/panel_docking.rs | 4 +- crates/ui-egui/src/panels/tabs.rs | 43 +++++++++++++-- crates/ui-egui/src/panels/tabs/tests.rs | 70 +++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a35166d97..0375ed76f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -39,6 +39,8 @@ pub(crate) const COLUMN_MIN: f32 = 220.0; pub(crate) const COLUMN_MAX: f32 = 720.0; /// Width of a column collapsed to icons. pub(crate) const ICON_WIDTH: f32 = 38.0; +/// The narrowest a panel other than the canvas is drawn, docked or floating. +pub(crate) const PANEL_MIN_WIDTH: f32 = 220.0; /// Docking state that lives for the session only. #[derive(Default)] @@ -1298,7 +1300,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { - let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(220.0, 80.0) }; + let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(PANEL_MIN_WIDTH, 80.0) }; craft_ui::docking::PanelLimits { min, ..Default::default() } } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index c8ef4331c..2e6f0cab8 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -41,6 +41,9 @@ fn align_id(a: TabAlign) -> &'static str { /// The panel body's margin and the ruler's own inset: the ruler's 0 is this far right of the panel. const RULER_LEFT: f32 = 10.0 + 4.0; +/// The most ticks the ruler draws. +const MAX_TICKS: usize = 10_000; + /// The largest stop position `text.tabs.set` takes, in points. const MAX_STOP: f64 = 100_000.0; @@ -248,6 +251,27 @@ fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { Some((n, para, span.max(72.0))) } +/// [`above_text`], then note the active document and text `n`. A floating panel is +/// at least [`crate::panel_docking::PANEL_MIN_WIDTH`] wide, so for narrow text at a low zoom the +/// panel comes out wider than asked; the ruler then draws text `n` at [`screen_scale`] and runs +/// on past the text's width. +fn place_above_text(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, span: f64) { + if above_text(app, ctx, n, span).is_some() { + set_pstate(ctx, "tabs-above", app.session.active().map(|doc| (doc.uid, n.id))); + } +} + +/// Screen points per point of text `n` at the canvas zoom, as [`above_text`] sizes the ruler: +/// across the frame for area type, the zoom for point type. +fn screen_scale(app: &VectorcraftApp, n: &Node, span: f64) -> Option { + let xf = crate::canvas::Xf::new(app.canvas_rect?, app.view()?); + let per = match &n.kind { + NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => f64::from(xf.rect_to_screen(n.geometric_bounds()?).width()) / span, + _ => xf.zoom, + }; + (per.is_finite() && per > 0.0).then_some(per) +} + /// Position Panel Above Text: float the panel just above text `n`, as wide as its ruler needs to /// put 0 on the text's left edge and its marks at the canvas zoom, so each stop sits over the /// column it sets (#729). `span`: the ruler's length in the text's points. @@ -383,7 +407,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button(tl!("Above Text")).on_hover_text(tl!("Position Panel Above Text")).clicked() { - above_text(app, ui.ctx(), &node, span); + place_above_text(app, ui.ctx(), &node, span); } }); }); @@ -430,7 +454,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let p = ui.painter_at(rect.expand(2.0)); p.rect_filled(rect, 2.0, t.input); p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - let scale = (w as f64 - 8.0) / span; + // After Position Panel Above Text on this text, while the panel floats no wider than the + // narrowest floating window, a ruler wider than the text is on screen draws the text at its + // screen scale and runs on past its width. Any other ruler fits the span to its width. + let above: Option<(u64, vectorcraft_doc::NodeId)> = pstate(ui.ctx(), "tabs-above"); + let narrowest = || { + let floating = app.ui.docking.as_ref().and_then(|l| l.floating.iter().find(|f| f.contains(&"tabs".to_string()))); + floating.is_some_and(|f| f.rect[2] <= crate::panel_docking::PANEL_MIN_WIDTH + 0.5) + }; + let scale = above + .filter(|at| Some(*at) == app.session.active().map(|doc| (doc.uid, node.id)) && narrowest()) + .and_then(|_| screen_scale(app, &node, span)) + .filter(|per| span * per + 8.0 < w as f64 - 0.5) + .unwrap_or((w as f64 - 8.0) / span); + let length = span.max((w as f64 - 8.0) / scale); let to_x = |pt: f64| rect.left() + 4.0 + (pt * scale) as f32; let to_pt = |x: f32| (((x - rect.left() - 4.0) as f64) / scale).max(0.0); // The ruler is numbered in the General unit, with the canvas rulers' label step at its scale @@ -438,7 +475,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let unit = app.session.general_unit(); let step = unit.ruler_step(scale); let minor = step / 10.0; - for k in 0..=(unit.from_pt(span) / minor + 1e-6).floor() as usize { + for k in 0..=((unit.from_pt(length) / minor + 1e-6).floor() as usize).min(MAX_TICKS) { let v = k as f64 * minor; let x = to_x(unit.to_pt(v)); let len = match k % 10 { diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 7b02b72ff..428b6945d 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -984,6 +984,76 @@ fn delete_tab_after_the_last_stop_deletes_the_stop_the_ruler_shows_selected() { assert!(current(&app).unwrap().1.is_empty()); } +/// Position Panel Above Text on text narrower than a floating panel can be at the zoom: the panel +/// comes out wider than asked, and its ruler draws the text at the zoom, running on past the text, +/// so its marks stay over the text. A ruler wider than the narrowest panel, for other text or in +/// another document fits the span to its width as before. +#[test] +fn above_text_keeps_the_zoom_on_a_ruler_wider_than_asked() { + 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": 100, "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 = 0.5; + } + let ctx = egui::Context::default(); + let (node, _, span) = current(&app).unwrap(); + place_above_text(&mut app, &ctx, &node, span); + // The panel asked for: a 50 pt ruler, less than the narrowest panel's. + let asked = app.ui.docking.clone().unwrap().floating.iter().find(|w| w.contains(&"tabs".to_string())).unwrap().rect[2]; + assert!((asked - RULER_LEFT * 2.0 - 50.0).abs() < 0.01, "{asked}"); + // Screen points per point of text on a ruler `width` wide, from its first two labels. + let scale = |app: &mut VectorcraftApp, width: f32| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 400.0)); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let labels: Vec<(f64, f32)> = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.job.sections.first().is_some_and(|s| s.format.font_id.size == 9.0) => { + Some((t.galley.text().parse::().ok()?, t.pos.x)) + } + _ => None, + }) + .collect(); + let [(0.0, a), (next, b), ..] = labels[..] else { panic!("ruler labels {labels:?}") }; + (b - a) as f64 / next + }; + let narrowest = crate::panel_docking::PANEL_MIN_WIDTH; + let fitted = (f64::from(narrowest) - 8.0) / span; + let at_zoom = scale(&mut app, narrowest); + assert!((at_zoom - 0.5).abs() < 1e-3, "{at_zoom}"); + let other = + app.session.execute("text.create", &json!({"x": 300, "y": 200, "text": "c", "area": {"width": 100, "height": 100}})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [other]})).unwrap(); + assert!((scale(&mut app, narrowest) - fitted).abs() < 1e-3, "other text fits its span to the ruler"); + app.session.execute("select.set", &json!({"ids": [node.id.0]})).unwrap(); + assert!((scale(&mut app, narrowest) - 0.5).abs() < 1e-3); + // At another zoom the ruler follows it. + if let Some(v) = app.view_mut() { + v.zoom = 2.0; + } + assert!((scale(&mut app, narrowest) - 2.0).abs() < 1e-3, "another zoom"); + if let Some(v) = app.view_mut() { + v.zoom = 0.5; + } + app.session.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + app.session.execute("text.create", &json!({"x": 40, "y": 200, "text": "d", "area": {"width": 100, "height": 100}})).unwrap(); + if let Some(v) = app.view_mut() { + v.zoom = 0.5; + } + assert!((scale(&mut app, narrowest) - fitted).abs() < 1e-3, "another document"); + assert!(app.session.set_active(0)); + assert!((scale(&mut app, narrowest) - 0.5).abs() < 1e-3); + // The panel made wider than the narrowest floating window fits the span to its ruler again. + let docking = app.ui.docking.as_mut().unwrap(); + docking.floating.iter_mut().find(|w| w.contains(&"tabs".to_string())).unwrap().rect[2] = 400.0; + let wide = scale(&mut app, 400.0); + assert!((wide - (400.0 - 8.0) / span).abs() < 1e-3, "{wide}"); +} + /// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left /// edge and a stop's mark over the column it sets, at any zoom (#729). #[test]