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]