From 5544efdbdcff10f41dd49892b133665c54b7c4a9 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:03:03 +0200 Subject: [PATCH] Effects angle dial: arrow keys and assistive-technology steps turn it Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/effects_dialog.rs | 98 ++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/effects_dialog.rs b/crates/ui-egui/src/effects_dialog.rs index d0c2228b8..5341c15a6 100644 --- a/crates/ui-egui/src/effects_dialog.rs +++ b/crates/ui-egui/src/effects_dialog.rs @@ -217,8 +217,11 @@ fn angle(ui: &mut egui::Ui, d: &mut Dialog, path: &[&str], enabled: bool, global } /// InDesign's angle dial: a circle with a radius pointing where the light comes from (0° to the -/// right, counter-clockwise). A click or a drag on it points it at the pointer. +/// right, counter-clockwise). A click or a drag on it points it at the pointer. Focused, the +/// arrow keys turn it a degree (Shift: 10°) — Right and Up counter-clockwise — and assistive +/// technology can step it or set its value, as with a slider. fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { + use egui::accesskit::{Action, ActionData}; let sense = if enabled { egui::Sense::click_and_drag() } else { egui::Sense::hover() }; let (r, resp) = ui.allocate_exact_size(egui::vec2(26.0, 26.0), sense); resp.widget_info(|| egui::WidgetInfo::slider(enabled, deg, "Angle")); @@ -227,10 +230,64 @@ fn angle_dial(ui: &mut egui::Ui, deg: f64, enabled: bool) -> Option { ui.painter().circle_stroke(c, rad, egui::Stroke::new(1.2, col)); let a = deg.to_radians() as f32; ui.painter().line_segment([c, c + egui::vec2(a.cos(), -a.sin()) * rad], egui::Stroke::new(1.4, col)); - if enabled && (resp.dragged() || resp.clicked()) { - return resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)); + if resp.has_focus() { + ui.painter().circle_stroke(c, rad + 2.0, ui.visuals().selection.stroke); } - None + if !enabled { + return None; + } + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(-180.0); + node.set_max_numeric_value(180.0); + node.set_numeric_value_step(1.0); + node.add_action(Action::SetValue); + node.add_action(Action::Increment); + node.add_action(Action::Decrement); + }); + let mut next = None; + let mut steps = 0i64; + if resp.has_focus() { + // The arrows turn the dial instead of moving the focus. + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input(|i| { + for e in &i.events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let dir = match key { + egui::Key::ArrowRight | egui::Key::ArrowUp => 1, + egui::Key::ArrowLeft | egui::Key::ArrowDown => -1, + _ => 0, + }; + steps += dir * if modifiers.shift { 10 } else { 1 }; + } + } + }); + } + ui.input(|i| { + steps += i.num_accesskit_action_requests(resp.id, Action::Increment) as i64; + steps -= i.num_accesskit_action_requests(resp.id, Action::Decrement) as i64; + for request in i.accesskit_action_requests(resp.id, Action::SetValue) { + if let Some(ActionData::NumericValue(v)) = request.data { + next = Some(v); + } + } + }); + if steps != 0 { + next = Some(deg + steps as f64); + } + if (resp.dragged() || resp.clicked()) + && let Some(a) = resp.interact_pointer_pos().and_then(|p| dial_angle(c, p)) + { + next = Some(a); + } + next.filter(|v| v.is_finite()).map(wrap_angle) +} + +/// `deg` turned into the dial's range, −180 (exclusive) to 180°. +fn wrap_angle(deg: f64) -> f64 { + let a = (deg + 180.0).rem_euclid(360.0) - 180.0; + if a <= -180.0 { 180.0 } else { a } } /// The angle a dial at `c` points at for the pointer at `p`, in whole degrees (−180 to 180, @@ -769,6 +826,39 @@ mod tests { assert_eq!(dial_angle(c, egui::pos2(101.0, 100.0)), None, "too close to the centre"); } + /// Without a pointer: Tab focuses the dial, the arrows turn it (Shift: 10°), wrapping at 180°. + #[test] + fn the_angle_dial_turns_with_the_keyboard() { + let ctx = egui::Context::default(); + let key = |key, shift| egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }, + }; + let mut deg = 175.0; + let frame = |events: Vec, deg: &mut f64| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + if let Some(a) = angle_dial(ui, *deg, true) { + *deg = a; + } + }); + out.textures_delta.clear(); + }; + frame(vec![], &mut deg); + frame(vec![key(egui::Key::Tab, false)], &mut deg); + frame(vec![key(egui::Key::ArrowRight, false)], &mut deg); + assert_eq!(deg, 176.0); + frame(vec![key(egui::Key::ArrowUp, true)], &mut deg); + assert_eq!(deg, -174.0, "past 180° it comes round to −174°"); + frame(vec![key(egui::Key::ArrowLeft, true)], &mut deg); + frame(vec![key(egui::Key::ArrowDown, false)], &mut deg); + assert_eq!(deg, 175.0); + assert_eq!(wrap_angle(-180.0), 180.0); + assert_eq!(wrap_angle(540.0), 180.0); + } + #[test] fn offsets_follow_distance_and_angle_as_in_indesign() { let (mut app, _) = app_with_frame();