Merge pull request #4951 from dotCooCoo/fix/drag-regions-not-tab-stops

Accessibility: Tab no longer stops on window, dialog, panel-grip and ruler drag regions
This commit is contained in:
BFlat
2026-11-01 01:32:08 +07:00
committed by GitHub
11 changed files with 154 additions and 10 deletions
+5 -3
View File
@@ -185,8 +185,9 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
let mut unit_clicked = None;
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
// Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right).
let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag());
// Clicks and drags so the same widget drags out guides (left) and opens the unit menu
// (right). Not focusable (`click_and_drag` is): no key acts on a ruler, so Tab skips it.
let resp = ui.interact(r, egui::Id::unique(id), Sense::CLICK | Sense::DRAG);
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
let kind = if resp.drag_stopped() {
PointerKind::Up
@@ -212,7 +213,8 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c
app.status(e.to_string());
}
}
let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag());
// Not focusable either: no key acts on the box.
let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::CLICK | Sense::DRAG);
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
if let Some((id, p)) = unit_clicked {
crate::menus::invoke(app, &id, p);
+2 -1
View File
@@ -178,7 +178,8 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) {
crumbs.push(Crumb { id: None, is_layer: false, name: pn.clone() });
}
let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0));
ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag());
// Keeps the pointer off the canvas; not focusable (`click_and_drag` is), so Tab skips it.
ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::CLICK | Sense::DRAG);
ui.painter().rect_filled(bar, 0.0, t.panel);
let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0));
let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click());
+29
View File
@@ -973,6 +973,35 @@ fn isolation_bar_navigation_and_back_button_steps_up() {
assert_eq!(app.session.active().unwrap().isolation, Some(NodeId(g2)));
}
/// Tab goes to the isolation bar's back button and breadcrumbs and does not stop on the bar
/// behind them, which only keeps the pointer off the canvas.
#[test]
fn tab_skips_the_isolation_bars_background() {
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
let b = app.run("shape.rectangle", json!({"x": 50, "y": 10, "width": 30, "height": 20})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
app.run("object.group", json!({})).unwrap();
app.run("object.isolate", json!({"id": a})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
let mut frame = |events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
super::isolation_bar(&mut app, ui, screen);
});
out.textures_delta.clear();
};
frame(vec![]);
let bar = egui::Rect::from_min_size(screen.min, egui::vec2(screen.width(), 24.0));
let bar = ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.rect == bar).map(|w| w.id));
let bar = bar.expect("the isolation bar");
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 10, frame);
assert!(!stops.is_empty(), "Tab reaches the back button and the breadcrumbs");
assert!(!stops.contains(&bar), "{stops:?}");
}
/// The Control bar's Isolate Selected Object isolates a path inside a group that Direct
/// Selection selected, and Exit Isolation Mode leaves with the path selected. Type, symbol
/// instances, blends, two objects and an envelope's contents get no isolate button, and pattern
+3 -2
View File
@@ -57,7 +57,8 @@ pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
// Last frame's rectangle: none while the window measures itself the first time it shows.
let Some(outer) = ui.ctx().memory(|m| m.area_rect(id)) else { return };
let band = Rect::from_x_y_ranges(outer.x_range(), outer.top()..=bottom.max(outer.top()));
let r = ui.interact(band, handle(id), egui::Sense::drag());
// Not focusable (`Sense::drag()` is): Tab goes through the dialog's controls, not the band.
let r = ui.interact(band, handle(id), egui::Sense::DRAG);
if r.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if r.hovered() {
@@ -66,7 +67,7 @@ pub(crate) fn drag_band(ui: &mut egui::Ui, bottom: f32) {
}
/// The drag handle of window `id`.
fn handle(id: Id) -> Id {
pub(super) fn handle(id: Id) -> Id {
id.with("modal-drag")
}
+23
View File
@@ -118,6 +118,29 @@ fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() {
}
}
/// Tab goes through a dialog's controls and does not stop on the band that moves the window.
#[test]
fn tab_skips_the_band_that_moves_a_dialog() {
let opens: [(&str, Open, egui::Id); 4] = [
("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::unique(("dialog", "offsetPath"))),
("New Document", open_new_document, egui::Id::unique(("dialog", "newDocument"))),
("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::unique("dialog-preferences")),
("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::unique("dialog-shortcuts")),
];
for (name, open, id) in opens {
let (ctx, mut app) = (ctx(), app_with_selection());
open(&mut app);
settle(&ctx, &mut app);
let band = modal::handle(id);
assert!(ctx.read_response(band).is_some(), "{name}: the band is drawn");
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 30, |events| {
frame(&ctx, &mut app, events);
});
assert!(!stops.is_empty(), "{name}: Tab reaches the dialog's controls");
assert!(!stops.contains(&band), "{name}: {stops:?}");
}
}
/// Open the dialog of a plug-in of `kind` (`effect` or `filter`) with `count` number settings over a
/// selection, settle it and `check` it, given its window and heading. The other tests share the
/// plug-in registry, and the plug-in is removed after.
+14
View File
@@ -110,6 +110,20 @@ fn ruler_origin_double_click_resets_to_the_active_artboard() {
assert_eq!(app.ruler_origin(), Point::new(125.0, 90.0));
}
/// Tab does not stop on the rulers or on the box where they meet, which only the pointer uses.
#[test]
fn tab_skips_the_rulers_and_their_origin_box() {
let mut app = app();
let ctx = context();
frame(&mut app, &ctx, vec![], canvas::show);
let rulers = ["ruler-top", "ruler-left", "ruler-corner"].map(egui::Id::unique);
assert!(rulers.iter().all(|&id| ctx.read_response(id).is_some()), "the rulers are drawn");
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 20, |events| {
frame(&mut app, &ctx, events, canvas::show);
});
assert!(!stops.iter().any(|id| rulers.contains(id)), "{stops:?}");
}
#[test]
fn ruler_origin_cancelled_drags_do_not_modify_either_document() {
#[derive(Debug, PartialEq)]
+19 -2
View File
@@ -45,7 +45,8 @@ fn toggle_maximize(ctx: &egui::Context) {
/// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks.
pub fn drag_area(ui: &Ui, rect: Rect) {
let ctx = ui.ctx();
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag());
// Not focusable (`click_and_drag` is): Tab goes through the bar's widgets, not its background.
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::CLICK | Sense::DRAG);
// egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing
// background below it, so remember whether the press itself landed on bare background.
if ctx.input(|i| i.pointer.any_pressed()) {
@@ -162,7 +163,8 @@ pub fn resize_zones(ui: &mut Ui) {
let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize"));
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() {
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
// Not focusable (`Sense::drag()` is): Tab does not stop on the zones.
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::DRAG).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
handed_to_os(&ctx);
@@ -391,6 +393,21 @@ mod tests {
assert_eq!(w.app.ui.status, "quit");
}
/// Tab goes through the bar's controls and does not stop on the bar's background or on the
/// resize zones, which only the pointer uses.
#[test]
fn tab_skips_the_bar_background_and_the_resize_zones() {
let mut w = Win::new(1000.0);
w.frame(vec![]);
let ctx = w.ctx.clone();
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 40, |events| {
w.frame(events);
});
assert!(!stops.is_empty(), "Tab reaches the bar's controls");
let regions: Vec<Id> = std::iter::once(Id::unique("titlebar-drag")).chain((0..8usize).map(|i| Id::unique(("titlebar-resize", i)))).collect();
assert!(!stops.iter().any(|id| regions.contains(id)), "{stops:?}");
}
#[test]
fn a_move_or_resize_the_window_manager_finished_does_not_block_the_next_resize() {
use ResizeDirection::*;
+4 -2
View File
@@ -153,7 +153,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
let (grip, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::drag());
// Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it.
let (grip, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::DRAG);
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
@@ -631,7 +632,8 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating
} else {
bar
};
let resp = ui.interact(grip, bar_id(key), Sense::drag());
// Not focusable (`Sense::drag()` is): only the pointer drags the bar, so Tab does not stop on it.
let resp = ui.interact(grip, bar_id(key), Sense::DRAG);
let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0);
for k in 0..3 {
let x = grip.center().x - 2.0 + k as f32 * 2.0;
+41
View File
@@ -995,3 +995,44 @@ fn screen_readers_name_a_flyouts_rows_and_a_floating_strips_buttons() {
assert_eq!(role_and_state(&strip, "Put back in the toolbar"), (Role::Button, None));
assert_eq!(role_and_state(&strip, "Drag to move"), (Role::Button, None));
}
/// Tab goes through the tool buttons and does not stop on the Tools panel's grip, a flyout's
/// tear-off bar or a floating strip's bar, which only the pointer drags.
#[test]
fn tab_skips_the_grip_and_the_tear_off_bars() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let group: Vec<String> = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec();
let tools: Vec<String> = ["polygon", "star"].map(String::from).to_vec();
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [150.0, 600.0] });
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut time = 0.0;
let mut frame = |events: Vec<Event>| {
time += 0.05;
let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0)));
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), events, screen_rect, ..Default::default() }, |ui| {
app.ui.flyout = Some(0);
ui.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), group.clone()));
show(&mut app, ui);
});
out.textures_delta.clear();
};
frame(vec![]);
frame(vec![]);
// The grip: the 6 pt strip under the title bar that senses drags and no clicks.
let grip = ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.find(|w| w.rect.height() == 6.0 && w.sense.senses_drag() && !w.sense.senses_click())
.map(|w| w.id)
});
let grip = grip.expect("the Tools panel's grip");
let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 120, frame);
assert!(!stops.is_empty(), "Tab reaches the tool buttons");
for (name, id) in [("the grip", grip), ("the flyout's bar", bar_id("rectangle")), ("the strip's bar", bar_id("polygon"))] {
assert!(!stops.contains(&id), "Tab stops on {name}: {stops:?}");
}
}
+13
View File
@@ -117,6 +117,19 @@ pub(crate) mod accessibility_tests {
nodes
}
/// The widgets that `tabs` presses of Tab give the keyboard focus to, in order. `frame` runs
/// one frame of `ctx` with the events it is given.
pub(crate) fn tab_stops(ctx: &egui::Context, tabs: usize, mut frame: impl FnMut(Vec<egui::Event>)) -> Vec<egui::Id> {
let tab = egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let mut stops = vec![];
for _ in 0..tabs {
frame(vec![tab.clone()]);
frame(vec![]);
stops.extend(ctx.memory(|m| m.focused()));
}
stops
}
/// The node of `tree` that screen readers name `label`.
pub(crate) fn named<'a>(tree: &'a [Node], label: &str) -> &'a Node {
tree.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("nothing is named {label:?}"))
+1
View File
@@ -580,6 +580,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator, whose bar has items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips.
- Tab does not stop on the regions that only the pointer uses: the custom title bar's background and the window's resize zones, the band at the top of a dialog that moves it, the Tools panel's grip, a flyout's and a floating strip's tear-off bar, the isolation bar's background, and the rulers and the box where they meet.
- These dialog sliders, each beside a number field, respond to the keyboard: those of the effect, tool option, brush and transform dialogs, the Perspective Grid, the 3D dialogs, and the flattener options, which the Flattener Preview panel also shows. A click, or Tab, gives a slider the keyboard focus, and an outline shows it. Then → and ↑ step the field's number up to the next whole number and ← and ↓ step it down to the next whole number, Shift with an arrow and Page Up and Page Down step it by ten, and Home and End set the slider's first and last value; no step goes past either end. Screen readers' increment and decrement actions step the slider as → and ← do. The Color panel's, Swatch Options', the Navigator's and the Character panel's sliders, and the Color Picker's channel slider, respond only to the pointer. The keys follow the WAI-ARIA slider pattern (<https://www.w3.org/WAI/ARIA/apg/patterns/slider/>); the reference app's help does not describe keys on a dialog's slider, so the step sizes are VectorCraft's own.
- Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.