Blob Brush: the tip preview is a named Angle slider with arrow keys

The tip preview square in the Blob Brush Tool Options set the Angle by a
drag but had no accessible name, no value and no keyboard control.
Screen readers now get it as the Angle slider with the Angle in degrees
as its value. A click or a drag gives it the keyboard focus, and the
arrow keys, Shift with an arrow, Page Up and Page Down, Home and End
step the Angle within -180 to 180 degrees, as the dialog sliders built
on color_slider_stepped do (crates/ui-egui/src/widgets/color.rs). The
new widgets::stepped_area gives a caller-drawn control that slider's
screen-reader node, focus, keys and focus outline.
This commit is contained in:
dotCooCoo
2026-10-31 23:48:55 +07:00
committed by bflatastic
parent a3199f3424
commit 5751dcc293
5 changed files with 85 additions and 8 deletions
+11 -6
View File
@@ -253,16 +253,16 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
const TIP_PREVIEW: f32 = 64.0;
/// The Blob Brush's tip preview: a square with the tip at its Angle and Roundness and a line along
/// its long axis (a drag in the square sets the Angle), and beside it three dabs of the tip, the
/// middle one darker.
/// its long axis, and beside it three dabs of the tip, the middle one darker. A click or a drag in
/// the square points the Angle at the pointer and gives the square the keyboard focus; screen
/// readers get it as the Angle slider, and its keys step the Angle as a dialog slider's do
/// ([`widgets::stepped_area`]) within -180° to 180°.
fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(egui::vec2(CONTENT_W, TIP_PREVIEW), egui::Sense::hover());
let square = egui::Rect::from_min_size(rect.min, egui::vec2(TIP_PREVIEW, TIP_PREVIEW));
let response = ui.interact(square, ui.scope_id().with("freehand-tip-angle"), egui::Sense::drag());
if response.dragged()
&& let Some(p) = response.interact_pointer_pos()
{
let response = ui.interact(square, ui.scope_id().with("freehand-tip-angle"), egui::Sense::click_and_drag());
if let Some((p, _)) = widgets::pointer_phase(&response) {
let delta = p - square.center();
if delta.length_sq() > 1.0 {
d.fields.insert("angle".into(), json!(f64::from((-delta.y).atan2(delta.x)).to_degrees().round()));
@@ -271,6 +271,11 @@ fn blob_tip_preview(ui: &mut egui::Ui, d: &mut Dialog) {
let painter = ui.painter_at(rect);
painter.rect_filled(square, 2.0, t.input);
painter.rect_stroke(square, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
// After the square's frame, so the focus outline draws over it.
let shown = widgets::SliderValue::field(d.f64("angle", 0.0), -180.0..=180.0, "°", 0);
if let Some(a) = widgets::stepped_area(ui, &response, tl!("Angle"), Some(shown)) {
d.fields.insert("angle".into(), json!(a));
}
let a = d.f64("angle", 0.0).clamp(-180.0, 180.0).to_radians();
// The long axis on screen (y down: a positive angle tilts it up to the right) and the short one.
let u = egui::vec2(a.cos() as f32, -a.sin() as f32);
+53
View File
@@ -1900,6 +1900,59 @@ fn the_flattener_and_3d_sliders_step_with_the_arrow_keys() {
}
}
/// The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle
/// as its value. A click in it points the Angle at the pointer and gives it the focus, its arrow
/// keys then step the Angle by one degree (ten with Shift) within -180° to 180°, Shift+Tab leaves
/// it for the Roundness field and Tab comes back to it, and a drag still sets the Angle.
#[test]
fn the_blob_brush_tip_preview_is_a_named_slider_with_arrow_keys() {
use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, Tab};
use egui::Modifiers as M;
let mut app = app();
app.run("tool.options", json!({"tool": "blobBrush"})).unwrap();
let angle = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Blob Brush Tool Options is open").f64("angle", f64::NAN);
let mut f = Frames::new();
f.run(&mut app, vec![]);
let out = f.run(&mut app, vec![]);
let (_, value, text) = slider_node(&out, "Angle");
assert_eq!((value, text.as_deref()), (Some(0.0), Some("0°")));
let center = f.control(&mut app, "Angle", egui::accesskit::Role::Slider);
let up_right = center + egui::vec2(20.0, -20.0);
f.press(&mut app, up_right, true);
f.press(&mut app, up_right, false);
assert_eq!(angle(&app), 45.0, "the click points the Angle up to the right");
let mut seen = vec![];
for (key, mods, want) in [
(ArrowRight, M::NONE, 46.0),
(ArrowUp, M::NONE, 47.0),
(ArrowLeft, M::NONE, 46.0),
(ArrowDown, M::NONE, 45.0),
(ArrowRight, M::SHIFT, 55.0),
(End, M::NONE, 180.0),
(ArrowRight, M::NONE, 180.0),
(Home, M::NONE, -180.0),
(ArrowLeft, M::SHIFT, -180.0),
(ArrowRight, M::NONE, -179.0),
] {
f.key(&mut app, key, mods);
seen.push((key, mods.shift, angle(&app)));
assert_eq!(angle(&app), want, "{seen:?}");
}
let out = f.run(&mut app, vec![]);
let (_, value, text) = slider_node(&out, "Angle");
assert_eq!((value, text.as_deref()), (Some(-179.0), Some("-179°")));
f.key(&mut app, Tab, M::SHIFT);
f.key(&mut app, ArrowRight, M::NONE);
assert_eq!(angle(&app), -179.0, "the Roundness field has the focus");
// Tab goes from the Roundness field to the scrub area on its label, then to the preview.
f.key(&mut app, Tab, M::NONE);
f.key(&mut app, Tab, M::NONE);
f.key(&mut app, ArrowRight, M::NONE);
assert_eq!(angle(&app), -178.0, "Tab comes back to the preview");
f.drag(&mut app, center + egui::vec2(20.0, 0.0), egui::vec2(0.0, 20.0));
assert_eq!(angle(&app), -45.0, "the drag ends down to the right");
}
#[test]
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
use vectorcraft_doc::NodeId;
+1 -1
View File
@@ -35,7 +35,7 @@ pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy,
pub(crate) use color::pointer_phase;
pub use color::{
Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider,
color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture,
color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, stepped_area,
};
pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source};
#[cfg(test)]
+19 -1
View File
@@ -135,6 +135,24 @@ pub fn color_slider_stepped(
) -> (Option<f32>, Live, Option<f64>) {
let id = id_with(ui.scope_id(), id);
let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track);
let stepped = slider_keys(ui, &resp, shown);
(picked, phase, stepped.filter(|_| picked.is_none()))
}
/// A control that its caller draws and reads the pointer on (`resp`, from a click-and-drag
/// [`Ui::interact`]), which screen readers get as a slider named `name` (already translated) with
/// `shown`, and which takes the focus, the keys and the screen readers' actions as the rail of
/// [`color_slider_stepped`] does, with the focus outline around `resp.rect`. Returns the number in
/// `shown`'s units that a key or an action gives.
pub fn stepped_area(ui: &Ui, resp: &Response, name: &str, shown: Option<SliderValue>) -> Option<f64> {
slider_info(ui, resp, name, shown.clone());
slider_keys(ui, resp, shown)
}
/// The focus, keys, actions and focus outline of [`color_slider_stepped`] on the control `resp`:
/// the number a key or an action gives.
fn slider_keys(ui: &Ui, resp: &Response, shown: Option<SliderValue>) -> Option<f64> {
let id = resp.id;
// Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells
// a keyboard user where the focus is, and after a drag it only framed the rail.
let by_pointer = id.with("focused-by-pointer");
@@ -161,7 +179,7 @@ pub fn color_slider_stepped(
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
(picked, phase, stepped.filter(|_| picked.is_none()))
stepped
}
/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen
+1
View File
@@ -751,6 +751,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The planar map's edge-source grid stays linear in memory on any input: a segment is filed once per cell it crosses, and the grid holds at most a budget of cell references (a few dozen per item plus a few rows' worth), past which the remaining items go to a short scanned list and are still found, so thousands of coincident segments cannot make the index quadratic (`crates/pathops/src/planar.rs` test `rect_index_holds_a_bounded_number_of_references`).
The Blob Brush Tool Options have Keep selected and Merge only with selection, both off by default: while Keep selected is off, the blob a stroke draws or merges into is not selected afterwards, and while Merge only with selection is on, a stroke merges only with selected blobs of its color that it touches (`path.blob` `keepSelected` and `mergeOnlySelection`).
The Blob Brush Tool Options also have Angle and Roundness, as the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list them: Angle from −180° to 180° (0° by default) and Roundness from 0% to 100% (100%, a round tip, by default). The elliptical tip keeps its angle on the page along a stroke, a 0% tip is drawn 0.1% as wide as it is long, and the dialog shows the tip and three dabs of it. The values are kept with the tool's options (`path.blob` `angle` and `roundness`). Not done: the Random and pen-driven variations of Angle and Roundness.
The Blob Brush Tool Options' tip preview is the Angle slider for screen readers, with the Angle in degrees as its value: a click or a drag in it points the Angle at the pointer and gives it the keyboard focus, Tab reaches it after the Roundness field, and the arrow keys step the Angle by 1° (10° with Shift, Home and End to −180° and 180°).
The Blob Brush Tool Options also have Fidelity, as the reference app's [Blob Brush options](https://web.archive.org/web/20261012042029/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/blob-brush-options-and-best-practices.html) list it: the Pencil's logarithmic slider from Accurate (0.01 pt) to Smooth (20 pt) and its field (up to 100 pt), 1.5 pt by default and kept with the tool's options. The brush follows the stroke fitted at the Fidelity, as a Pencil stroke is fitted: a low value follows the drag closely, jitter included, and a higher one gives smoother curves (`path.blob` `fidelity`).
A Blob Brush stroke merges with the topmost path of its fill color and no stroke that it touches, and with the other such paths it touches in the same group or layer that are next to that path in stacking order with no other kind of object between them; matching paths in other groups or layers are left alone, as the Blob Brush tool guidelines in [Paint with fills and strokes](https://web.archive.org/web/20241219085603/https://helpx.adobe.com/illustrator/using/painting-fills-strokes.html) describe (`path.blob`).
- **Ruler guides as layer objects:** a guide goes on the current layer, shows, hides, locks and is deleted with it, is listed in the Layers panel (its row selects it) and moves to another layer (`guide.setLayer`, or its row dragged onto that layer's rows); guides on a hidden or locked layer can't be selected, moved or deleted, Duplicate copies a layer's guides and Merge Selected and Flatten Artwork keep them on the merged layer; guides from files saved before guides had layers go on the top visible, unlocked layer when the document opens. Not done: guides stacked among the art of their layer (they are listed above it), a guide row's own eye and lock. Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score.