| 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 | 29 | place near the window's top over a dimmed window, as Windows opens a window; the |
| 30 | 30 | command palette swings in the same way, undimmed. A filtered list shows its new |
| 31 | 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 | 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 | 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 | |
| 116 | /// scrollers. | |
| 115 | /// 10.6's interface font, Lucida Grande, which fontique's system-ui doesn't name, its | |
| 116 | /// scrollers and its segmented controls. | |
| 117 | 117 | pub fn system_interface(ui: &mut ui::Ui) { |
| 118 | 118 | if !before_lion() { |
| 119 | 119 | return; |
| 120 | 120 | } |
| 121 | 121 | ui.set_system_font("Lucida Grande"); |
| 122 | ui.segments = ui::Segments::Joined; | |
| 122 | 123 | ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) { |
| 123 | 124 | sampled_scrollers() |
| 124 | 125 | } else { |
crates/snowbound/src/windows.rs+7-2| ... | ... | @@ -1057,8 +1057,13 @@ unsafe extern "system" fn fault( |
| 1057 | 1057 | 0 |
| 1058 | 1058 | } |
| 1059 | 1059 | |
| 1060 | /// The interface keeps its own font, which fontique finds as Segoe UI. | |
| 1061 | pub fn system_interface(_: &mut Ui) {} | |
| 1060 | /// The interface keeps its own font, which fontique finds as Segoe UI. Windows 7 has no | |
| 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 | 1068 | /// Resizing takes the window's edges. |
| 1064 | 1069 | pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {} |
crates/ui/src/access.rs+3-1| ... | ... | @@ -304,7 +304,9 @@ impl Ui { |
| 304 | 304 | rect: built.rect, |
| 305 | 305 | group, |
| 306 | 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 | 310 | typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM), |
| 309 | 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 | 748 | fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {} |
| 749 | 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 | 15 | pub use list::{List, Row, Rows, list}; |
| 16 | 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; |
| 17 | 17 | pub use widgets::{ |
| 18 | PaintedScroller, Scroller, ScrollerPart, Scrollers, badge, badge_width, button, check_box, | |
| 19 | dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, text_field, | |
| 18 | PaintedScroller, Scroller, ScrollerPart, Scrollers, Segments, badge, badge_width, button, | |
| 19 | check_box, dialog_buttons, edit_key, edit_modifiers, password_field, scrollbar, segmented, | |
| 20 | text_field, | |
| 20 | 21 | }; |
| 21 | 22 | |
| 22 | 23 | use draw::{ |
| ... | ... | @@ -684,6 +685,7 @@ pub struct Ui { |
| 684 | 685 | /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars |
| 685 | 686 | /// overlay the content otherwise. |
| 686 | 687 | pub scrollers: Option<Scrollers>, |
| 688 | pub segments: Segments, | |
| 687 | 689 | frame: u64, |
| 688 | 690 | now: Instant, |
| 689 | 691 | scale: f32, |
| ... | ... | @@ -747,6 +749,7 @@ impl Ui { |
| 747 | 749 | icon_palette: draw::Palette::default(), |
| 748 | 750 | window_focused: true, |
| 749 | 751 | scrollers: None, |
| 752 | segments: Segments::default(), | |
| 750 | 753 | frame: 0, |
| 751 | 754 | now: Instant::now(), |
| 752 | 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 | 138 | border: Some(if checked || signal.hovered { |
| 139 | 139 | theme.accent |
| 140 | 140 | } else { |
| 141 | theme.chip | |
| 141 | crate::mix(theme.chip, theme.text, 0.45) | |
| 142 | 142 | }), |
| 143 | 143 | radius: 3.0, |
| 144 | 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 | 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 | 297 | /// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are. |
| 162 | 298 | pub struct Scrollers { |
| 163 | 299 | /// Across a scroller, in logical pixels. |