| author | |
| committer | |
| log | c7f6712ad38d29d83cef963b14d7a473da6546e3 |
| tree | 379c9076477e3292f500273b0918cffe7e69e2e0 |
| parent | ba6f9efea7036fac3aaa6e862ce554464d106cd0 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- Segmented controls slide a raised face along a track, as macOS, GNOME and Windows 11 draw them, and press one of a row of joined buttons on Mac OS X 10.6 and Windows 7
- A segmented control is a radio group: Tab enters at its choice and arrows pick the one beside it
- Unchecked check boxes show their edge on light backgrounds
Assisted-by: claude-opus-5.57 files changed, 210 insertions(+), 9 deletions(-)
arc/ui.md+3-1| ... | @@ -29,7 +29,9 @@ fades in, a combo's field widens into its list, and a dialog swings up into | ... | @@ -29,7 +29,9 @@ fades in, a combo's field widens into its list, and a dialog swings up into |
| 29 | place near the window's top over a dimmed window, as Windows opens a window; the | 29 | place near the window's top over a dimmed window, as Windows opens a window; the |
| 30 | command palette swings in the same way, undimmed. A filtered list shows its new | 30 | command palette swings in the same way, undimmed. A filtered list shows its new |
| 31 | results at once. On GNOME and KDE, menus instead | 31 | results at once. On GNOME and KDE, menus instead |
| 32 | look and move as the desktop's own (see [platforms](platforms.md)). | 32 | look and move as the desktop's own (see [platforms](platforms.md)). A segmented control |
| 33 | slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on | ||
| 34 | Mac OS X 10.6 and Windows 7 presses one of a row of joined buttons. | ||
| 33 | 35 | ||
| 34 | ## Why not native widgets | 36 | ## Why not native widgets |
| 35 | 37 |
crates/snowbound/src/aqua.rs+3-2| ... | @@ -112,13 +112,14 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { | ... | @@ -112,13 +112,14 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool { |
| 112 | Bool::YES | 112 | Bool::YES |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | /// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, and its | 115 | /// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, its |
| 116 | /// scrollers. | 116 | /// scrollers and its segmented controls. |
| 117 | pub fn system_interface(ui: &mut ui::Ui) { | 117 | pub fn system_interface(ui: &mut ui::Ui) { |
| 118 | if !before_lion() { | 118 | if !before_lion() { |
| 119 | return; | 119 | return; |
| 120 | } | 120 | } |
| 121 | ui.set_system_font("Lucida Grande"); | 121 | ui.set_system_font("Lucida Grande"); |
| 122 | ui.segments = ui::Segments::Joined; | ||
| 122 | ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) { | 123 | ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) { |
| 123 | sampled_scrollers() | 124 | sampled_scrollers() |
| 124 | } else { | 125 | } else { |
crates/snowbound/src/windows.rs+7-2| ... | @@ -1057,8 +1057,13 @@ unsafe extern "system" fn fault( | ... | @@ -1057,8 +1057,13 @@ unsafe extern "system" fn fault( |
| 1057 | 0 | 1057 | 0 |
| 1058 | } | 1058 | } |
| 1059 | 1059 | ||
| 1060 | /// The interface keeps its own font, which fontique finds as Segoe UI. | 1060 | /// The interface keeps its own font, which fontique finds as Segoe UI. Windows 7 has no |
| 1061 | pub fn system_interface(_: &mut Ui) {} | 1061 | /// segmented control, so Office's joined toggle buttons stand in. |
| 1062 | pub fn system_interface(ui: &mut Ui) { | ||
| 1063 | if version() < (6, 2, 0) { | ||
| 1064 | ui.segments = ui::Segments::Joined; | ||
| 1065 | } | ||
| 1066 | } | ||
| 1062 | 1067 | ||
| 1063 | /// Resizing takes the window's edges. | 1068 | /// Resizing takes the window's edges. |
| 1064 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} | 1069 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} |
crates/ui/src/access.rs+3-1| ... | @@ -304,7 +304,9 @@ impl Ui { | ... | @@ -304,7 +304,9 @@ impl Ui { |
| 304 | rect: built.rect, | 304 | rect: built.rect, |
| 305 | group, | 305 | group, |
| 306 | popup, | 306 | popup, |
| 307 | selected: access.is_selected() == Some(true), | 307 | selected: access.is_selected() == Some(true) |
| 308 | || role == Role::RadioButton | ||
| 309 | && access.toggled() == Some(accesskit::Toggled::True), | ||
| 308 | typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM), | 310 | typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM), |
| 309 | custom: built.flags.contains(Flags::CUSTOM), | 311 | custom: built.flags.contains(Flags::CUSTOM), |
| 310 | }); | 312 | }); |
crates/ui/src/access/tests.rs+52| ... | @@ -748,3 +748,55 @@ impl accesskit_consumer::TreeChangeHandler for NoChanges { | ... | @@ -748,3 +748,55 @@ impl accesskit_consumer::TreeChangeHandler for NoChanges { |
| 748 | fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} | 748 | fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} |
| 749 | fn node_removed(&mut self, _: &NodeRef) {} | 749 | fn node_removed(&mut self, _: &NodeRef) {} |
| 750 | } | 750 | } |
| 751 | |||
| 752 | /// A segmented control of sides with `chosen` picked, which a pick moves. | ||
| 753 | fn sides(ui: &mut Ui, chosen: &mut usize) { | ||
| 754 | if let Some(picked) = segmented(ui, "sides", "Page tabs", &["Left", "Right"], *chosen) { | ||
| 755 | *chosen = picked; | ||
| 756 | } | ||
| 757 | } | ||
| 758 | |||
| 759 | #[test] | ||
| 760 | fn a_segmented_control_is_a_radio_group_tab_enters_at_its_choice_and_arrows_pick() { | ||
| 761 | let mut ui = new_ui(); | ||
| 762 | let mut chosen = 1; | ||
| 763 | for _ in 0..2 { | ||
| 764 | frame(&mut ui, |ui| sides(ui, &mut chosen)); | ||
| 765 | } | ||
| 766 | assert_eq!( | ||
| 767 | snapshot(&tree(&mut ui)), | ||
| 768 | r#"Window "Notes" [focused] | ||
| 769 | RadioGroup "Page tabs" | ||
| 770 | RadioButton "Left" [untoggled] {click focus} | ||
| 771 | RadioButton "Right" [toggled] {click focus} | ||
| 772 | "# | ||
| 773 | ); | ||
| 774 | let segment = |index: usize| Some(Id::ROOT.child("sides").child(index)); | ||
| 775 | key(&mut ui, NamedKey::Tab); | ||
| 776 | frame(&mut ui, |ui| sides(ui, &mut chosen)); | ||
| 777 | assert_eq!(ui.focused(), segment(1), "Tab enters at the choice"); | ||
| 778 | assert_eq!(chosen, 1); | ||
| 779 | key(&mut ui, NamedKey::ArrowLeft); | ||
| 780 | for _ in 0..2 { | ||
| 781 | frame(&mut ui, |ui| sides(ui, &mut chosen)); | ||
| 782 | } | ||
| 783 | assert_eq!((ui.focused(), chosen), (segment(0), 0), "an arrow picks"); | ||
| 784 | // A click picks without moving the focus, which stays put on later frames. | ||
| 785 | let [left, top, right, bottom] = ui.rect(segment(1).unwrap()).unwrap(); | ||
| 786 | ui.event(Event::PointerMoved([ | ||
| 787 | (left + right) / 2.0, | ||
| 788 | (top + bottom) / 2.0, | ||
| 789 | ])); | ||
| 790 | for pressed in [true, false] { | ||
| 791 | ui.event(Event::Button { | ||
| 792 | button: winit::event::MouseButton::Left, | ||
| 793 | pressed, | ||
| 794 | at: Instant::now(), | ||
| 795 | }); | ||
| 796 | frame(&mut ui, |ui| sides(ui, &mut chosen)); | ||
| 797 | } | ||
| 798 | for _ in 0..2 { | ||
| 799 | frame(&mut ui, |ui| sides(ui, &mut chosen)); | ||
| 800 | } | ||
| 801 | assert_eq!(chosen, 1); | ||
| 802 | } |
crates/ui/src/lib.rs+5-2| ... | @@ -15,8 +15,9 @@ mod widgets; | ... | @@ -15,8 +15,9 @@ mod widgets; |
| 15 | pub use list::{List, Row, Rows, list}; | 15 | pub use list::{List, Row, Rows, list}; |
| 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; | 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; |
| 17 | pub use widgets::{ | 17 | pub use widgets::{ |
| 18 | PaintedScroller, Scroller, ScrollerPart, Scrollers, badge, badge_width, button, check_box, | 18 | PaintedScroller, Scroller, ScrollerPart, Scrollers, Segments, badge, badge_width, button, |
| 19 | dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, text_field, | 19 | check_box, dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, segmented, |
| 20 | text_field, | ||
| 20 | }; | 21 | }; |
| 21 | 22 | ||
| 22 | use draw::{ | 23 | use draw::{ |
| ... | @@ -684,6 +685,7 @@ pub struct Ui { | ... | @@ -684,6 +685,7 @@ pub struct Ui { |
| 684 | /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars | 685 | /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars |
| 685 | /// overlay the content otherwise. | 686 | /// overlay the content otherwise. |
| 686 | pub scrollers: Option<Scrollers>, | 687 | pub scrollers: Option<Scrollers>, |
| 688 | pub segments: Segments, | ||
| 687 | frame: u64, | 689 | frame: u64, |
| 688 | now: Instant, | 690 | now: Instant, |
| 689 | scale: f32, | 691 | scale: f32, |
| ... | @@ -747,6 +749,7 @@ impl Ui { | ... | @@ -747,6 +749,7 @@ impl Ui { |
| 747 | icon_palette: draw::Palette::default(), | 749 | icon_palette: draw::Palette::default(), |
| 748 | window_focused: true, | 750 | window_focused: true, |
| 749 | scrollers: None, | 751 | scrollers: None, |
| 752 | segments: Segments::default(), | ||
| 750 | frame: 0, | 753 | frame: 0, |
| 751 | now: Instant::now(), | 754 | now: Instant::now(), |
| 752 | scale: 1.0, | 755 | scale: 1.0, |
crates/ui/src/widgets.rs+137-1| ... | @@ -138,7 +138,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si | ... | @@ -138,7 +138,7 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si |
| 138 | border: Some(if checked || signal.hovered { | 138 | border: Some(if checked || signal.hovered { |
| 139 | theme.accent | 139 | theme.accent |
| 140 | } else { | 140 | } else { |
| 141 | theme.chip | 141 | crate::mix(theme.chip, theme.text, 0.45) |
| 142 | }), | 142 | }), |
| 143 | radius: 3.0, | 143 | radius: 3.0, |
| 144 | icon: checked.then_some(crate::popup::CHECK), | 144 | 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 | ... | @@ -158,6 +158,142 @@ pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Si |
| 158 | signal | 158 | signal |
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | /// How a segmented control draws its choice, as the platform draws its own. | ||
| 162 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | ||
| 163 | pub enum Segments { | ||
| 164 | /// A raised face sliding along a sunken track: macOS since 11, libadwaita's toggle | ||
| 165 | /// groups and Windows 11's segmented control. | ||
| 166 | #[default] | ||
| 167 | Track, | ||
| 168 | /// Buttons joined in a row, the chosen one pressed in: Mac OS X 10.6's segmented control | ||
| 169 | /// and Windows 7's toggle groups. | ||
| 170 | Joined, | ||
| 171 | } | ||
| 172 | |||
| 173 | /// `choices` side by side, equally wide, with `chosen` picked: a radio group named `label` | ||
| 174 | /// for assistive technology. Tab enters at the choice, and arrows pick the one beside it. | ||
| 175 | /// Returns the choice a click or the keyboard picks. | ||
| 176 | pub fn segmented( | ||
| 177 | ui: &mut Ui, | ||
| 178 | part: impl Hash, | ||
| 179 | label: &str, | ||
| 180 | choices: &[&str], | ||
| 181 | chosen: usize, | ||
| 182 | ) -> Option<usize> { | ||
| 183 | let theme = ui.theme.clone(); | ||
| 184 | let joined = ui.segments == Segments::Joined; | ||
| 185 | let dark = draw::oklab(theme.text)[0] > 0.5; | ||
| 186 | let [red, green, blue, _] = theme.text; | ||
| 187 | let ink = |alpha| [red, green, blue, alpha]; | ||
| 188 | let height = theme.font_size * 2.0; | ||
| 189 | let widest = (choices.iter()).fold(0.0_f32, |widest, choice| widest.max(ui.measure(choice)[0])); | ||
| 190 | let width = (widest + 2.0 * theme.font_size).round(); | ||
| 191 | // A track shows around its face; joined buttons fill their border. | ||
| 192 | let inset = if joined { 1.0 } else { 2.0 }; | ||
| 193 | let button = |shade: f32| { | ||
| 194 | if dark { | ||
| 195 | crate::mix(theme.popup, theme.text, shade) | ||
| 196 | } else { | ||
| 197 | crate::mix([1.0; 4], theme.chip, 0.75 - shade * 4.0) | ||
| 198 | } | ||
| 199 | }; | ||
| 200 | let group = ui.open( | ||
| 201 | part, | ||
| 202 | Spec { | ||
| 203 | size: [crate::children(), px(height)], | ||
| 204 | fill: Some(if joined { | ||
| 205 | button(0.12) | ||
| 206 | } else { | ||
| 207 | ink(if dark { 0.1 } else { 0.07 }) | ||
| 208 | }), | ||
| 209 | gradient: joined.then(|| button(0.06)), | ||
| 210 | border: joined.then_some(theme.chip), | ||
| 211 | radius: if joined { 4.0 } else { 6.0 }, | ||
| 212 | pad: [inset, inset], | ||
| 213 | role: Some(Role::RadioGroup), | ||
| 214 | ..Spec::default() | ||
| 215 | }, | ||
| 216 | ); | ||
| 217 | if let Some(node) = ui.access(group) { | ||
| 218 | node.set_label(label); | ||
| 219 | node.set_orientation(accesskit::Orientation::Horizontal); | ||
| 220 | } | ||
| 221 | let target = chosen as f32 * width; | ||
| 222 | let slid = if joined { | ||
| 223 | ui.hold(group.child("slide"), target) | ||
| 224 | } else { | ||
| 225 | ui.animate(group.child("slide"), target) | ||
| 226 | }; | ||
| 227 | ui.leaf( | ||
| 228 | "thumb", | ||
| 229 | Spec { | ||
| 230 | flags: Flags::FLOAT, | ||
| 231 | size: [px(width), px(height - 2.0 * inset)], | ||
| 232 | position: [inset + slid, inset], | ||
| 233 | fill: Some(match (joined, dark) { | ||
| 234 | (true, _) => theme.accent, | ||
| 235 | (false, true) => crate::mix(theme.popup, theme.text, 0.3), | ||
| 236 | (false, false) => [1.0; 4], | ||
| 237 | }), | ||
| 238 | gradient: joined.then(|| crate::mix(theme.accent, [0.0, 0.0, 0.0, 1.0], 0.25)), | ||
| 239 | border: (!joined && !dark).then(|| ink(0.08)), | ||
| 240 | shadow: (!joined && !dark).then_some([0.0, 0.0, 0.0, 0.12]), | ||
| 241 | radius: if joined { 3.0 } else { 4.0 }, | ||
| 242 | ..Spec::default() | ||
| 243 | }, | ||
| 244 | ); | ||
| 245 | // Rules between the choices; a track leaves them out beside its face. | ||
| 246 | for between in 1..choices.len() { | ||
| 247 | if !joined && (between == chosen || between == chosen + 1) { | ||
| 248 | continue; | ||
| 249 | } | ||
| 250 | let length = if joined { height - 2.0 } else { height * 0.5 }; | ||
| 251 | ui.leaf( | ||
| 252 | ("rule", between), | ||
| 253 | Spec { | ||
| 254 | flags: Flags::FLOAT, | ||
| 255 | size: [px(1.0), px(length)], | ||
| 256 | position: [ | ||
| 257 | inset + between as f32 * width - 0.5, | ||
| 258 | (height - length) / 2.0, | ||
| 259 | ], | ||
| 260 | fill: Some(if joined { theme.chip } else { ink(0.15) }), | ||
| 261 | ..Spec::default() | ||
| 262 | }, | ||
| 263 | ); | ||
| 264 | } | ||
| 265 | let mut picked = None; | ||
| 266 | for (index, choice) in choices.iter().enumerate() { | ||
| 267 | let on = index == chosen; | ||
| 268 | let id = ui.open( | ||
| 269 | index, | ||
| 270 | Spec { | ||
| 271 | flags: Flags::CLICKABLE, | ||
| 272 | size: [px(width), crate::fill()], | ||
| 273 | text: Some(choice), | ||
| 274 | color: Some(if on && joined { [1.0; 4] } else { theme.text }), | ||
| 275 | hover_fill: (!on).then(|| ink(0.06)), | ||
| 276 | radius: if joined { 3.0 } else { 4.0 }, | ||
| 277 | center: true, | ||
| 278 | role: Some(Role::RadioButton), | ||
| 279 | ..Spec::default() | ||
| 280 | }, | ||
| 281 | ); | ||
| 282 | if let Some(node) = ui.access(id) { | ||
| 283 | node.set_toggled(on.into()); | ||
| 284 | } | ||
| 285 | ui.close(); | ||
| 286 | let signal = ui.signal(id); | ||
| 287 | // The keyboard's arrows pick the segment they move to, as a radio group's do. | ||
| 288 | let arrived = ui.lasted(id.child("focus"), signal.focused) == Duration::ZERO; | ||
| 289 | if !on && (signal.clicked || signal.focused && arrived) { | ||
| 290 | picked = Some(index); | ||
| 291 | } | ||
| 292 | } | ||
| 293 | ui.close(); | ||
| 294 | picked | ||
| 295 | } | ||
| 296 | |||
| 161 | /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. | 297 | /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. |
| 162 | pub struct Scrollers { | 298 | pub struct Scrollers { |
| 163 | /// Across a scroller, in logical pixels. | 299 | /// Across a scroller, in logical pixels. |