From 7c4414fed71af798deafd5a613caaf1d1a96936e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:44:48 -0800 Subject: [PATCH] Learn: a located tool's toolbar comes to the front and a tall flyout scrolls - A Learn link that locates a tool on a toolbar made with New Toolbar brings that toolbar to the front once before its button is marked, so another floating toolbar no longer covers the mark. A group torn off into a strip still brings the strip to the front instead. - A docked flyout whose rows are taller than the window scrolls them, and a Learn link scrolls the located row into view before marking it. The mark is clipped to the rows' view. --- crates/ui-egui/src/custom_toolbars.rs | 11 ++++ crates/ui-egui/src/panels/learn/locate.rs | 15 ++++- crates/ui-egui/src/toolbar.rs | 55 +++++++++++++++--- crates/ui-egui/src/toolbar/tests.rs | 71 +++++++++++++++++++++++ 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index 077b89efe7..f356f18b68 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -601,6 +601,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { // A Learn link locating a tool of this toolbar: its button, scrolled into view once. let locate = crate::panels::learn::locate::wanted(&app.ui, Some(&bar.name)); let scrolled = app.ui.learn_locate.as_ref().is_some_and(|l| l.scrolled); + // The toolbar comes to the front once, over other floating toolbars, unless the tool's + // group is torn off into a strip, which comes to the front instead. + let holder: Option> = locate + .and_then(|tool| bar.slots.iter().find(|s| s.iter().any(|t| t == tool))) + .map(|slot| slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect()); + let torn_off = holder + .as_ref() + .is_some_and(|tools| tools.len() > 1 && tools.first().is_some_and(|key| crate::toolbar::is_floating(app, Some(&bar.name), key))); + if holder.is_some() && !torn_off && crate::panels::learn::locate::raise_once(&mut app.ui) { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + } let (mut found, mut did_scroll) = (None, false); egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/learn/locate.rs b/crates/ui-egui/src/panels/learn/locate.rs index 787e1677eb..607da1c45c 100644 --- a/crates/ui-egui/src/panels/learn/locate.rs +++ b/crates/ui-egui/src/panels/learn/locate.rs @@ -42,6 +42,8 @@ pub struct Locate { pub(crate) scrolled: bool, /// The group's flyout was opened or its strip raised. pub(crate) revealed: bool, + /// The toolbar made with New Toolbar that holds the tool was brought to the front. + raised: bool, /// The mark drew in this pass. drawn: bool, /// Passes in a row in which the mark did not draw. @@ -76,6 +78,17 @@ pub(crate) fn reveal_once(ui: &mut UiState) -> bool { ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.revealed, true)) } +/// Whether the toolbar made with New Toolbar that holds the tool is still to be brought to the +/// front; true once. +pub(crate) fn raise_once(ui: &mut UiState) -> bool { + ui.learn_locate.as_mut().is_some_and(|l| !std::mem::replace(&mut l.raised, true)) +} + +/// Whether a locate is under way and its mark has not drawn yet. +pub(crate) fn unmarked(ui: &UiState) -> bool { + ui.learn_locate.as_ref().is_some_and(|l| l.since.is_none()) +} + /// Mark `rect`, the located tool's control as drawn this pass on `layer`, clipped to `clip` and the /// window. The first mark starts the [`LIFETIME`]; once it has passed, the locate ends. pub(crate) fn mark(app: &mut VectorcraftApp, ctx: &Context, layer: LayerId, clip: Rect, rect: Rect) { @@ -129,7 +142,7 @@ fn start(app: &mut VectorcraftApp, tool: &'static str, target: Target) { app.ui.tools_drawer = false; } } - app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, drawn: false, unseen: 0 }); + app.ui.learn_locate = Some(Locate { tool, target, since: None, scrolled: false, revealed: false, raised: false, drawn: false, unseen: 0 }); } /// `learn.locateTool {tool}`: mark where `tool` is in the current layout → diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index fc36d8cf41..8f2d2395fb 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -747,14 +747,27 @@ struct FlyoutInput { close: bool, /// Where the tool a Learn link locates is drawn. located: Option, + /// What a docked flyout's rows are clipped to (they scroll in a group taller than the window). + located_clip: Option, } /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag /// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar /// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn -/// link locates, whose place it returns). -fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>, locate: Option<&str>) -> FlyoutInput { +/// link locates, whose place it returns; `reveal_row`: a located row outside the flyout's view +/// scrolls into it, and is returned once it shows). +#[allow(clippy::too_many_arguments)] +fn flyout_body( + ui: &mut Ui, + t: &Tokens, + active: &str, + tools: &[String], + floating: bool, + toolbar: Option<&str>, + locate: Option<&str>, + reveal_row: bool, +) -> FlyoutInput { let mut out = FlyoutInput::default(); let key = tools.first().map_or("", String::as_str); let bar_id = bar_id(toolbar, key); @@ -763,7 +776,29 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools, locate)) } else { - ui.vertical(|ui| tool_rows(ui, t, active, tools, locate)) + // A group taller than the window scrolls its rows (inside the frame's 1 pt border). The + // rows' height is given, since the row this sits in starts one widget tall. + let max = ui.ctx().content_rect().height() - 2.0; + let height = (tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * ROW_HEIGHT).min(max); + ui.vertical(|ui| { + egui::ScrollArea::vertical() + .id_salt(bar_id.with("rows")) + .max_height(max) + .min_scrolled_height(height) + .show(ui, |ui| { + let (chosen, located) = tool_rows(ui, t, active, tools, locate); + // The mark is clipped to the rows' view, not to the whole flyout. + out.located_clip = Some(ui.clip_rect()); + match located.filter(|r| reveal_row && !ui.clip_rect().contains_rect(*r)) { + Some(r) => { + ui.scroll_to_rect_animation(r, Some(egui::Align::Center), egui::style::ScrollAnimation::none()); + (chosen, None) + } + None => (chosen, located), + } + }) + .inner + }) }; (out.chosen, out.located) = rows.inner; let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover()); @@ -810,7 +845,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Op let w = rows_width(rows.iter().map(|(_, text)| text)); for (tool, (name, shortcut)) in rows { let is_active = active == tool.id; - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW_HEIGHT), Sense::click()); if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } @@ -843,6 +878,8 @@ type RowText = (Arc, Option>); const NAME_X: f32 = 52.0; const NAME_GAP: f32 = 14.0; const ROW_END: f32 = 18.0; +/// The height of a flyout row. +const ROW_HEIGHT: f32 = 30.0; /// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them. fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText { @@ -947,18 +984,20 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { let active = app.session.tool_id(); let toolbar = ctx.data(|d| d.get_temp::>(flyout_toolbar_id())).flatten(); let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool)); + let reveal_row = learn_locate::unmarked(&app.ui); let mut input = FlyoutInput::default(); // Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right. let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size()); let x = beside(anchor, size.x, 8.0, room(app, ctx)); // The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened - // near the bottom moves up, so every row, a located one included, shows. + // near the bottom moves up, and a group taller than the window scrolls a located row into view. let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| { - flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate)); + flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate, reveal_row)); }); if let Some(rect) = input.located { - learn_locate::mark(app, ctx, resp.response.layer_id, resp.response.rect, rect); + let clip = input.located_clip.map_or(resp.response.rect, |c| c.intersect(resp.response.rect)); + learn_locate::mark(app, ctx, resp.response.layer_id, clip, rect); } // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the // end of the long press that opened the flyout): it floats where the pointer is. @@ -1065,7 +1104,7 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio let pos = crate::tools_float::clamp(f.pos, size, screen); let mut input = FlyoutInput::default(); let area = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| { - flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate)); + flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar, locate, false)); }); if let Some(rect) = input.located { located = Some((area.response.layer_id, area.response.rect, rect)); diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs index 8ea1928130..9dec20b9dc 100644 --- a/crates/ui-egui/src/toolbar/tests.rs +++ b/crates/ui-egui/src/toolbar/tests.rs @@ -1301,6 +1301,77 @@ fn a_learn_link_in_a_short_window_marks_a_flyout_row_inside_the_window() { assert_marks(&marks, row, tool); } +/// A group with more rows than the window holds scrolls its flyout, and a Learn link scrolls the +/// located row into view before marking it. +#[test] +fn a_learn_link_scrolls_the_row_of_a_group_taller_than_the_window_into_view() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let tools: Vec<&'static str> = slots(&app).into_iter().filter_map(|(_, s)| s.first().copied()).skip(1).take(16).collect(); + for tool in &tools { + app.run("toolbar.addTool", json!({"tool": tool, "slot": 0, "place": "group"})).unwrap(); + } + let tool = *tools.last().expect("a tool"); + let group = slots(&app).into_iter().map(|(_, s)| s).find(|s| s.contains(&tool)).expect("the group"); + assert!(group.len() * 30 > 400, "{} rows of 30 points do not fit in 400", group.len()); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 400.0); + assert_eq!(app.run("learn.locateTool", json!({"tool": tool})).unwrap()["location"], "flyout", "{tool}"); + let mut drawn = (vec![], vec![]); + for k in 1..20 { + drawn = locate_frame(&mut app, &ctx, f64::from(k) * 0.1, vec![], 400.0); + } + let (marks, nodes) = drawn; + let row = tool_rect(&nodes, tool); + assert!(row.top() >= 0.0 && row.bottom() <= 400.0, "{tool}: the row is inside the window: {row:?}"); + assert_marks(&marks, row, tool); + assert_eq!(app.session.tool_id(), "selection"); + // The mark is clipped to the rows' view inside the flyout's 1 pt border, so a row scrolled + // part way out shows no mark over the border above or below the rows. + let view = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout shows").shrink(1.0); + let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))); + let out = ctx.run_ui(egui::RawInput { time: Some(2.0), screen_rect, ..Default::default() }, |ui| show(&mut app, ui)); + let color = Tokens::get(&ctx).selection; + let clips: Vec = out + .shapes + .iter() + .filter(|c| matches!(&c.shape, egui::Shape::Rect(r) if r.stroke.width == MARK_WIDTH && r.stroke.color == color)) + .map(|c| c.clip_rect) + .collect(); + assert!( + !clips.is_empty() && clips.iter().all(|c| c.top() >= view.top() && c.bottom() <= view.bottom()), + "the mark is clipped to {view:?}: {clips:?}" + ); +} + +/// A toolbar made with New Toolbar under another floating toolbar comes to the front for a Learn +/// link, so its marked button shows. +#[test] +fn a_learn_link_brings_a_covered_toolbar_made_with_new_toolbar_to_the_front() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + for (name, tool) in [("Mine", "artboard"), ("Other", "slice")] { + app.run("window.toolbar.new", json!({"name": name})).unwrap(); + app.run("toolbar.addTool", json!({"tool": tool, "toolbar": name})).unwrap(); + } + for bar in &mut app.ui.custom_toolbars { + bar.pos = [200.0, 200.0]; + } + let (mine, other) = (crate::custom_toolbars::area_id("Mine"), crate::custom_toolbars::area_id("Other")); + let ctx = reader_context(); + locate_frame(&mut app, &ctx, 0.0, vec![], 1200.0); + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, other)); + let (_, nodes) = locate_frame(&mut app, &ctx, 0.05, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(other), "Other covers the button of Mine"); + assert_eq!( + app.run("learn.locateTool", json!({"tool": "artboard"})).unwrap(), + json!({"tool": "artboard", "location": "toolbar", "toolbar": "Mine"}) + ); + let (marks, nodes) = locate_frame(&mut app, &ctx, 1.0, vec![], 1200.0); + let button = tool_rect(&nodes, "artboard"); + assert_eq!(ctx.layer_id_at(button.center()).map(|l| l.id), Some(mine), "Mine comes to the front"); + assert_marks(&marks, button, "the toolbar Mine"); +} + /// A second Learn link moves the mark; Escape removes it. #[test] fn a_second_learn_link_replaces_the_mark_and_escape_removes_it() {