Merge pull request #6159 from dotCooCoo/fix/refpoint-one-tab-stop

Reference point: the locator is one Tab stop, and the arrow keys move the point
This commit is contained in:
BFlat
2026-11-08 14:39:36 +08:00
committed by GitHub
9 changed files with 227 additions and 9 deletions
@@ -48,7 +48,7 @@ fn reset(_: &VectorcraftApp, d: &mut Dialog) {
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::label_row(ui, tl!("Pivot:"), LABEL_W, |ui| {
let current = d.f64("pivot", f64::from(CENTER)).clamp(0.0, 8.0) as usize;
if let Some(i) = widgets::reference_point(ui, current) {
if let Some(i) = widgets::reference_point_named(ui, current, tl!("Pivot:")) {
d.fields.insert("pivot".into(), json!(i));
}
});
@@ -55,7 +55,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_enabled_ui(free, |ui| {
widgets::label_row(ui, tl!("Alignment:"), LABEL, |ui| {
let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4);
if let Some(i) = widgets::reference_point(ui, current)
if let Some(i) = widgets::reference_point_named(ui, current, tl!("Alignment:"))
&& let Some(a) = ALIGN.get(i)
{
d.fields.insert("align".into(), json!(a));
+1 -1
View File
@@ -628,7 +628,7 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_enabled_ui(!tiles && !placed, |ui| {
let origin = choice::<Origin>(d, "placement.origin").unwrap_or_default();
let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4);
if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) {
if let Some(o) = widgets::reference_point_named(ui, current, tl!("Placement:")).and_then(|i| Origin::ALL.get(i)) {
set(d, "placement.origin", json!(o.id()));
}
ui.label("X:");
+1 -1
View File
@@ -123,7 +123,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.end_row();
label(ui, tl!("Registration:"));
let cur = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4) as usize;
if let Some(i) = widgets::reference_point(ui, cur) {
if let Some(i) = widgets::reference_point_named(ui, cur, tl!("Registration:")) {
d.fields.insert("registration".into(), json!(i));
}
ui.end_row();
+1 -1
View File
@@ -435,7 +435,7 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO
egui::Grid::new("props-objects-on-path").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
dim_label(ui, tl!("Pivot:"));
// 9 is no square: the groups' pivots differ.
if let Some(i) = widgets::reference_point(ui, oop.pivot.map_or(9, usize::from))
if let Some(i) = widgets::reference_point_named(ui, oop.pivot.map_or(9, usize::from), tl!("Pivot:"))
&& oop.pivot.is_none_or(|p| usize::from(p) != i)
{
set(app, json!({ "pivot": i }));
@@ -426,6 +426,42 @@ fn use_registration_point_for_symbol_follows_a_transformed_instance() {
}
}
/// The reference point locator is one tab stop: from the widget before it, two presses of Tab reach
/// X, the first stopping on the whole locator; → on the locator moves the shared reference point to
/// the middle of the right side.
#[test]
fn the_reference_point_locator_is_one_tab_stop_and_arrows_move_it() {
use crate::panels::tests_appearance::{click, frame_events, text_rect};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "70 pt").center(), show);
let x = ctx.memory(|m| m.focused());
assert!(x.is_some(), "the click put the focus in X");
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
let mut press = |key| {
frame_events(&ctx, &mut app, vec![key], show);
frame_events(&ctx, &mut app, vec![], show);
ctx.memory(|m| m.focused())
};
let locator = press(key(egui::Key::Tab, egui::Modifiers::SHIFT));
let rect = |id: Option<egui::Id>| id.and_then(|id| ctx.read_response(id)).map(|r| r.rect);
let grid = rect(locator).unwrap_or(egui::Rect::NOTHING);
assert_eq!(grid.size(), egui::Vec2::splat(23.0), "Shift+Tab from X reaches the whole locator, not one of its squares");
let before = press(key(egui::Key::Tab, egui::Modifiers::SHIFT));
assert!(before.is_some() && rect(before).is_some_and(|r| !grid.contains_rect(r)), "a second Shift+Tab leaves the locator");
assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), locator, "Tab from the widget before the locator reaches it");
assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), x, "a second Tab reaches X");
press(key(egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(ctx.data(|d| d.get_temp::<usize>(egui::Id::unique("refpt"))), None, "→ in X leaves the reference point alone");
press(key(egui::Key::Tab, egui::Modifiers::SHIFT));
press(key(egui::Key::ArrowRight, egui::Modifiers::NONE));
assert_eq!(ctx.data(|d| d.get_temp::<usize>(egui::Id::unique("refpt"))), Some(5), "→ on the locator chose the middle of the right side");
}
#[test]
fn artboard_fields_keep_the_reference_point() {
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
+116
View File
@@ -23,6 +23,7 @@ mod toggles;
pub use blend::{
TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point,
reference_point_named,
};
pub use buttons::{
SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button,
@@ -239,6 +240,121 @@ pub(crate) mod accessibility_tests {
let chosen = if i == 4 { Toggled::True } else { Toggled::False };
assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}");
}
let grid = named(&tree, "Reference Point");
assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some("Center")), "the grid is a radio group showing the current point");
}
/// Screen readers find the nine squares inside the Reference Point radio group.
#[test]
fn the_reference_point_group_holds_its_squares() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
let mut nodes = vec![];
for time in [0.0, 0.1] {
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
reference_point(ui, 0);
});
out.textures_delta.clear();
nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default();
}
let node = |id: &egui::accesskit::NodeId| nodes.iter().find(|(n, _)| n == id).map(|(_, n)| n);
let Some((group, _)) = nodes.iter().find(|(_, n)| n.label() == Some("Reference Point")) else { panic!("no Reference Point group") };
let mut inside = vec![];
let mut todo = vec![*group];
while let Some(id) = todo.pop() {
for child in node(&id).map(|n| n.children()).unwrap_or_default() {
inside.extend(node(child).and_then(|n| n.label()).map(str::to_owned));
todo.push(*child);
}
}
for label in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"] {
assert!(inside.iter().any(|l| l == label), "{label} is in the group: {inside:?}");
}
}
/// A frame of a button, the reference point grid at `point` and another button, after `events`;
/// a new point from the grid replaces `point`. Returns the two buttons' ids.
fn grid_frame(ctx: &egui::Context, events: Vec<egui::Event>, point: &mut usize) -> (egui::Id, egui::Id) {
let mut ids = (egui::Id::NULL, egui::Id::NULL);
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
ids.0 = ui.button("Before").id;
if let Some(i) = reference_point(ui, *point) {
*point = i;
}
ids.1 = ui.button("After").id;
});
// No renderer takes the font atlas updates in a test; dropping them unapplied panics.
out.textures_delta.clear();
ids
}
/// Tab stops once on the reference point grid, not on each of its nine squares.
#[test]
fn the_reference_point_grid_is_one_tab_stop() {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut point = 4;
let mut ids = (egui::Id::NULL, egui::Id::NULL);
let stops = tab_stops(&ctx, 3, |events| ids = grid_frame(&ctx, events, &mut point));
assert_eq!(stops.len(), 3, "{stops:?}");
assert_eq!(stops[0], ids.0, "the first Tab reaches the button before the grid");
let size = ctx.read_response(stops[1]).map(|r| r.rect.size());
assert_eq!(size, Some(egui::Vec2::splat(23.0)), "the second Tab reaches the whole grid, not one of its squares");
assert_eq!(stops[2], ids.1, "the third Tab leaves the grid for the button after it");
assert_eq!(point, 4, "Tab leaves the point as it was");
}
/// While the grid has the focus, the arrow keys move the point one square, stop at the edges and
/// keep the focus on the grid.
#[test]
fn arrow_keys_move_the_reference_point() {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut point = 4;
let stops = tab_stops(&ctx, 2, |events| {
grid_frame(&ctx, events, &mut point);
});
let grid = stops[1];
let arrow = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
for (key, want) in [
(egui::Key::ArrowRight, 5),
(egui::Key::ArrowUp, 2),
(egui::Key::ArrowRight, 2),
(egui::Key::ArrowUp, 2),
(egui::Key::ArrowLeft, 1),
(egui::Key::ArrowDown, 4),
(egui::Key::ArrowDown, 7),
(egui::Key::ArrowLeft, 6),
(egui::Key::ArrowDown, 6),
] {
grid_frame(&ctx, vec![arrow(key)], &mut point);
grid_frame(&ctx, vec![], &mut point);
assert_eq!(point, want, "{key:?}");
assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "{key:?} keeps the focus on the grid");
}
// From no point, an arrow moves from the center.
point = 9;
grid_frame(&ctx, vec![arrow(egui::Key::ArrowLeft)], &mut point);
grid_frame(&ctx, vec![], &mut point);
assert_eq!(point, 3, "← from no point chose the middle of the left side");
assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "← from no point keeps the focus on the grid");
}
/// A locator under another label takes that label, without its colon, as the group's name.
#[test]
fn a_named_reference_point_group_takes_its_label() {
// Each locator gets its own id scope, so their ids differ.
let tree = tree(|ui| {
for (point, label) in [(4, "Pivot:"), (0, "Origine :"), (8, "基準点:")] {
ui.push_id(label, |ui| reference_point_named(ui, point, label));
}
});
for (label, value) in [("Pivot", "Center"), ("Origine", "Top Left"), ("基準点", "Bottom Right")] {
let grid = named(&tree, label);
assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some(value)), "{label}");
}
}
#[test]
+69 -3
View File
@@ -94,14 +94,29 @@ pub fn opacity_blend(
}
/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around
/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked.
/// the box (the centre one stands alone), the current one filled. Returns a new index when a square
/// is clicked or an arrow key moves the point.
///
/// The grid is one Tab stop. While it has the keyboard focus it shows an outline, and ←, →, ↑ and
/// ↓ move the point one square, stopping at the edges; from no point (`current` 9 or more) they
/// move from the center. Screen readers get a radio group named Reference Point, with the current
/// point as its value, holding the nine squares as radio buttons.
pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
reference_point_named(ui, current, tl!("Reference Point"))
}
/// [`reference_point`] for a locator that a label other than Reference Point names, such as
/// `Pivot:`: screen readers name the radio group after `label`, without its final colon.
pub fn reference_point_named(ui: &mut Ui, current: usize, label: &str) -> Option<usize> {
let name = label.trim_end().trim_end_matches([':', ':']).trim_end();
let t = Tokens::get(ui.ctx());
// 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through
// the squares' centres stay crisp.
let (sq, step) = (5.0, 9.0);
let size = 2.0 * step + sq;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
// The grid takes the focus; the squares take clicks only, so Tab does not stop on each one.
let grid = ui.interact(rect, ui.scope_id().with("refpt"), Sense::focusable_noninteractive());
let o = rect.min.round();
let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step);
let line = Stroke::new(1.0, t.text_dim);
@@ -119,10 +134,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
tl!("Bottom"),
tl!("Bottom Right"),
];
grid.widget_info(|| {
let mut info = egui::WidgetInfo::labeled(egui::Role::RadioGroup, ui.is_enabled(), name);
info.current_text_value = names.get(current).map(|n| n.to_string());
info
});
if grid.has_focus() && ui.is_enabled() {
out = reference_point_keys(ui, grid.id, current);
}
// The squares go in a child `Ui` whose screen-reader parent is the group, so that the group
// holds them.
let scope = ui.scope_id();
let squares = ui.new_child(egui::UiBuilder::new().max_rect(rect).accessibility_parent(grid.id));
for (i, name) in names.into_iter().enumerate() {
let r = Rect::from_center_size(centre(i), Vec2::splat(sq));
let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name));
let resp = squares.interact(r.expand(2.0), scope.with(("refpt", i)), Sense::CLICK);
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, squares.is_enabled(), i == current, name));
if resp.clicked() {
out = Some(i);
}
@@ -133,9 +160,48 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside);
}
}
if grid.has_focus() {
ui.painter().rect_stroke(rect.expand(2.0), 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
out
}
/// The point this frame's arrow keys move the focused reference point grid `id` to from
/// `current`, when it differs.
fn reference_point_keys(ui: &Ui, id: egui::Id, current: usize) -> Option<usize> {
use egui::{Event, Key};
// The arrows move the point and do not move the focus. egui keeps the filter only when the grid
// also had the focus in the frame before, and applies it from the next frame; until then, an
// arrow the grid takes cancels the focus move that egui set at the start of the frame.
let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
ui.memory_mut(|m| m.set_focus_lock_filter(id, filter));
let mut at = if current < 9 { current } else { 4 };
let mut arrows = false;
ui.input_mut(|i| {
i.events.retain(|e| {
let Event::Key { key, pressed: true, modifiers, .. } = e else { return true };
if !modifiers.is_none() {
return true;
}
let (col, row) = (at % 3, at / 3);
at = match key {
Key::ArrowLeft => row * 3 + col.saturating_sub(1),
Key::ArrowRight => row * 3 + (col + 1).min(2),
Key::ArrowUp => row.saturating_sub(1) * 3 + col,
Key::ArrowDown => (row + 1).min(2) * 3 + col,
_ => return true,
};
arrows = true;
false
});
});
if !arrows {
return None;
}
ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
(at != current).then_some(at)
}
/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees,
/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole
/// degrees (-179..180), Shift in 45° steps. Returns the new angle.
+1 -1
View File
@@ -750,7 +750,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected.
- Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels, and so do the links that label a field (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke), which a click still follows to their panel (#4088). Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off.
- In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401).
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`).
- In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected.
- While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`).