diff --git a/arc/ui.md b/arc/ui.md index 421de1aa1db3ee3e3340093d0141bb11ae68a886..91a7b7d425f0c4bab20a2c71b5d222c9fbda2eab 100644 --- a/arc/ui.md +++ b/arc/ui.md @@ -29,7 +29,9 @@ fades in, a combo's field widens into its list, and a dialog swings up into place near the window's top over a dimmed window, as Windows opens a window; the command palette swings in the same way, undimmed. A filtered list shows its new results at once. On GNOME and KDE, menus instead -look and move as the desktop's own (see [platforms](platforms.md)). +look and move as the desktop's own (see [platforms](platforms.md)). A segmented control +slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on +Mac OS X 10.6 and Windows 7 presses one of a row of joined buttons. ## Why not native widgets diff --git a/crates/snowbound/src/aqua.rs b/crates/snowbound/src/aqua.rs index 398be5fde606b9af2d36e26e59f663b3d44426da..ece9cfb795e0bd48420a297e7514b06d587c2e37 100644 --- a/crates/snowbound/src/aqua.rs +++ b/crates/snowbound/src/aqua.rs @@ -112,13 +112,14 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { Bool::YES } -/// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, and its -/// scrollers. +/// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, its +/// scrollers and its segmented controls. pub fn system_interface(ui: &mut ui::Ui) { if !before_lion() { return; } ui.set_system_font("Lucida Grande"); + ui.segments = ui::Segments::Joined; ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) { sampled_scrollers() } else { diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index dc1da2c144402b325c80f48208ece5d2d16d0bd5..f7621be1cec1c12a1b265a99b00d0a1be52a5441 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -1057,8 +1057,13 @@ unsafe extern "system" fn fault( 0 } -/// The interface keeps its own font, which fontique finds as Segoe UI. -pub fn system_interface(_: &mut Ui) {} +/// The interface keeps its own font, which fontique finds as Segoe UI. Windows 7 has no +/// segmented control, so Office's joined toggle buttons stand in. +pub fn system_interface(ui: &mut Ui) { + if version() < (6, 2, 0) { + ui.segments = ui::Segments::Joined; + } +} /// Resizing takes the window's edges. pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} diff --git a/crates/ui/src/access.rs b/crates/ui/src/access.rs index 4c9549bdd4443d258b7f3694e916ffc69b388f66..6ee80a893f5453fe405d49c8fa279292934c9a10 100644 --- a/crates/ui/src/access.rs +++ b/crates/ui/src/access.rs @@ -304,7 +304,9 @@ impl Ui { rect: built.rect, group, popup, - selected: access.is_selected() == Some(true), + selected: access.is_selected() == Some(true) + || role == Role::RadioButton + && access.toggled() == Some(accesskit::Toggled::True), typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM), custom: built.flags.contains(Flags::CUSTOM), }); diff --git a/crates/ui/src/access/tests.rs b/crates/ui/src/access/tests.rs index 6f9b70b21b795f746e95e3fd8ebc71022edb7c40..198e97cf4a980facd49187f8210cc99603dc663c 100644 --- a/crates/ui/src/access/tests.rs +++ b/crates/ui/src/access/tests.rs @@ -748,3 +748,55 @@ impl accesskit_consumer::TreeChangeHandler for NoChanges { fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} fn node_removed(&mut self, _: &NodeRef) {} } + +/// A segmented control of sides with `chosen` picked, which a pick moves. +fn sides(ui: &mut Ui, chosen: &mut usize) { + if let Some(picked) = segmented(ui, "sides", "Page tabs", &["Left", "Right"], *chosen) { + *chosen = picked; + } +} + +#[test] +fn a_segmented_control_is_a_radio_group_tab_enters_at_its_choice_and_arrows_pick() { + let mut ui = new_ui(); + let mut chosen = 1; + for _ in 0..2 { + frame(&mut ui, |ui| sides(ui, &mut chosen)); + } + assert_eq!( + snapshot(&tree(&mut ui)), + r#"Window "Notes" [focused] + RadioGroup "Page tabs" + RadioButton "Left" [untoggled] {click focus} + RadioButton "Right" [toggled] {click focus} +"# + ); + let segment = |index: usize| Some(Id::ROOT.child("sides").child(index)); + key(&mut ui, NamedKey::Tab); + frame(&mut ui, |ui| sides(ui, &mut chosen)); + assert_eq!(ui.focused(), segment(1), "Tab enters at the choice"); + assert_eq!(chosen, 1); + key(&mut ui, NamedKey::ArrowLeft); + for _ in 0..2 { + frame(&mut ui, |ui| sides(ui, &mut chosen)); + } + assert_eq!((ui.focused(), chosen), (segment(0), 0), "an arrow picks"); + // A click picks without moving the focus, which stays put on later frames. + let [left, top, right, bottom] = ui.rect(segment(1).unwrap()).unwrap(); + ui.event(Event::PointerMoved([ + (left + right) / 2.0, + (top + bottom) / 2.0, + ])); + for pressed in [true, false] { + ui.event(Event::Button { + button: winit::event::MouseButton::Left, + pressed, + at: Instant::now(), + }); + frame(&mut ui, |ui| sides(ui, &mut chosen)); + } + for _ in 0..2 { + frame(&mut ui, |ui| sides(ui, &mut chosen)); + } + assert_eq!(chosen, 1); +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index ec8cfa0a3c5fcbf17eac16f102d85dfdd92074e4..0d122699df93402eae364eee956f0a966c60fa39 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -15,8 +15,9 @@ mod widgets; pub use list::{List, Row, Rows, list}; pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; pub use widgets::{ - PaintedScroller, Scroller, ScrollerPart, Scrollers, badge, badge_width, button, check_box, - dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, text_field, + PaintedScroller, Scroller, ScrollerPart, Scrollers, Segments, badge, badge_width, button, + check_box, dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, segmented, + text_field, }; use draw::{ @@ -684,6 +685,7 @@ pub struct Ui { /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars /// overlay the content otherwise. pub scrollers: Option, + pub segments: Segments, frame: u64, now: Instant, scale: f32, @@ -747,6 +749,7 @@ impl Ui { icon_palette: draw::Palette::default(), window_focused: true, scrollers: None, + segments: Segments::default(), frame: 0, now: Instant::now(), scale: 1.0, diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index 3d4513ef662a5a786f1375a691d85805fa9ee655..61d7285b132fd87357c04bdcb288aac6c878caa2 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -138,7 +138,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si border: Some(if checked || signal.hovered { theme.accent } else { - theme.chip + crate::mix(theme.chip, theme.text, 0.45) }), radius: 3.0, icon: checked.then_some(crate::popup::CHECK), @@ -158,6 +158,142 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si signal } +/// How a segmented control draws its choice, as the platform draws its own. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Segments { + /// A raised face sliding along a sunken track: macOS since 11, libadwaita's toggle + /// groups and Windows 11's segmented control. + #[default] + Track, + /// Buttons joined in a row, the chosen one pressed in: Mac OS X 10.6's segmented control + /// and Windows 7's toggle groups. + Joined, +} + +/// `choices` side by side, equally wide, with `chosen` picked: a radio group named `label` +/// for assistive technology. Tab enters at the choice, and arrows pick the one beside it. +/// Returns the choice a click or the keyboard picks. +pub fn segmented( + ui: &mut Ui, + part: impl Hash, + label: &str, + choices: &[&str], + chosen: usize, +) -> Option { + let theme = ui.theme.clone(); + let joined = ui.segments == Segments::Joined; + let dark = draw::oklab(theme.text)[0] > 0.5; + let [red, green, blue, _] = theme.text; + let ink = |alpha| [red, green, blue, alpha]; + let height = theme.font_size * 2.0; + let widest = (choices.iter()).fold(0.0_f32, |widest, choice| widest.max(ui.measure(choice)[0])); + let width = (widest + 2.0 * theme.font_size).round(); + // A track shows around its face; joined buttons fill their border. + let inset = if joined { 1.0 } else { 2.0 }; + let button = |shade: f32| { + if dark { + crate::mix(theme.popup, theme.text, shade) + } else { + crate::mix([1.0; 4], theme.chip, 0.75 - shade * 4.0) + } + }; + let group = ui.open( + part, + Spec { + size: [crate::children(), px(height)], + fill: Some(if joined { + button(0.12) + } else { + ink(if dark { 0.1 } else { 0.07 }) + }), + gradient: joined.then(|| button(0.06)), + border: joined.then_some(theme.chip), + radius: if joined { 4.0 } else { 6.0 }, + pad: [inset, inset], + role: Some(Role::RadioGroup), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(group) { + node.set_label(label); + node.set_orientation(accesskit::Orientation::Horizontal); + } + let target = chosen as f32 * width; + let slid = if joined { + ui.hold(group.child("slide"), target) + } else { + ui.animate(group.child("slide"), target) + }; + ui.leaf( + "thumb", + Spec { + flags: Flags::FLOAT, + size: [px(width), px(height - 2.0 * inset)], + position: [inset + slid, inset], + fill: Some(match (joined, dark) { + (true, _) => theme.accent, + (false, true) => crate::mix(theme.popup, theme.text, 0.3), + (false, false) => [1.0; 4], + }), + gradient: joined.then(|| crate::mix(theme.accent, [0.0, 0.0, 0.0, 1.0], 0.25)), + border: (!joined && !dark).then(|| ink(0.08)), + shadow: (!joined && !dark).then_some([0.0, 0.0, 0.0, 0.12]), + radius: if joined { 3.0 } else { 4.0 }, + ..Spec::default() + }, + ); + // Rules between the choices; a track leaves them out beside its face. + for between in 1..choices.len() { + if !joined && (between == chosen || between == chosen + 1) { + continue; + } + let length = if joined { height - 2.0 } else { height * 0.5 }; + ui.leaf( + ("rule", between), + Spec { + flags: Flags::FLOAT, + size: [px(1.0), px(length)], + position: [ + inset + between as f32 * width - 0.5, + (height - length) / 2.0, + ], + fill: Some(if joined { theme.chip } else { ink(0.15) }), + ..Spec::default() + }, + ); + } + let mut picked = None; + for (index, choice) in choices.iter().enumerate() { + let on = index == chosen; + let id = ui.open( + index, + Spec { + flags: Flags::CLICKABLE, + size: [px(width), crate::fill()], + text: Some(choice), + color: Some(if on && joined { [1.0; 4] } else { theme.text }), + hover_fill: (!on).then(|| ink(0.06)), + radius: if joined { 3.0 } else { 4.0 }, + center: true, + role: Some(Role::RadioButton), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id) { + node.set_toggled(on.into()); + } + ui.close(); + let signal = ui.signal(id); + // The keyboard's arrows pick the segment they move to, as a radio group's do. + let arrived = ui.lasted(id.child("focus"), signal.focused) == Duration::ZERO; + if !on && (signal.clicked || signal.focused && arrived) { + picked = Some(index); + } + } + ui.close(); + picked +} + /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. pub struct Scrollers { /// Across a scroller, in logical pixels.