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.
This commit is contained in:
dotCooCoo
2026-11-12 11:44:48 -08:00
parent eed65939fc
commit 7c4414fed7
4 changed files with 143 additions and 9 deletions
+11
View File
@@ -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<Vec<&'static str>> = 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| {
+14 -1
View File
@@ -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 →
+47 -8
View File
@@ -747,14 +747,27 @@ struct FlyoutInput {
close: bool,
/// Where the tool a Learn link locates is drawn.
located: Option<egui::Rect>,
/// What a docked flyout's rows are clipped to (they scroll in a group taller than the window).
located_clip: Option<egui::Rect>,
}
/// 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<egui::Galley>, Option<Arc<egui::Galley>>);
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::<Option<String>>(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));
+71
View File
@@ -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<egui::Rect> = 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() {