authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 02:08:14-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 07:14:46-07:00
logc7f6712ad38d29d83cef963b14d7a473da6546e3
tree379c9076477e3292f500273b0918cffe7e69e2e0
parentba6f9efea7036fac3aaa6e862ce554464d106cd0
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a segmented control, drawn as each platform draws its own

- 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.5

7 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
2929place near the window's top over a dimmed window, as Windows opens a window; the
3030command palette swings in the same way, undimmed. A filtered list shows its new
3131results at once. On GNOME and KDE, menus instead
32look and move as the desktop's own (see [platforms](platforms.md)).
32look and move as the desktop's own (see [platforms](platforms.md)). A segmented control
33slides a raised face along a track, as macOS, libadwaita and Windows 11 draw theirs, and on
34Mac OS X 10.6 and Windows 7 presses one of a row of joined buttons.
3335
3436## Why not native widgets
3537
crates/snowbound/src/aqua.rs+3-2
......@@ -112,13 +112,14 @@ extern "C" fn yes(_: &AnyObject, _: Sel) -> Bool {
112112 Bool::YES
113113}
114114
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.
117117pub fn system_interface(ui: &mut ui::Ui) {
118118 if !before_lion() {
119119 return;
120120 }
121121 ui.set_system_font("Lucida Grande");
122 ui.segments = ui::Segments::Joined;
122123 ui.scrollers = Some(if PRETEND.load(Ordering::Relaxed) {
123124 sampled_scrollers()
124125 } else {
crates/snowbound/src/windows.rs+7-2
......@@ -1057,8 +1057,13 @@ unsafe extern "system" fn fault(
10571057 0
10581058}
10591059
1060/// The interface keeps its own font, which fontique finds as Segoe UI.
1061pub 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.
1062pub fn system_interface(ui: &mut Ui) {
1063 if version() < (6, 2, 0) {
1064 ui.segments = ui::Segments::Joined;
1065 }
1066}
10621067
10631068/// Resizing takes the window's edges.
10641069pub fn resize_grip(_: &mut Ui, _: &Window, _: [f32; 2]) {}
crates/ui/src/access.rs+3-1
......@@ -304,7 +304,9 @@ impl Ui {
304304 rect: built.rect,
305305 group,
306306 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),
308310 typing: built.flags.intersects(Flags::FOCUSABLE | Flags::CUSTOM),
309311 custom: built.flags.contains(Flags::CUSTOM),
310312 });
crates/ui/src/access/tests.rs+52
......@@ -748,3 +748,55 @@ impl accesskit_consumer::TreeChangeHandler for NoChanges {
748748 fn focus_moved(&mut self, _: Option<&NodeRef>, _: Option<&NodeRef>) {}
749749 fn node_removed(&mut self, _: &NodeRef) {}
750750}
751
752/// A segmented control of sides with `chosen` picked, which a pick moves.
753fn 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]
760fn 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;
1515pub use list::{List, Row, Rows, list};
1616pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
1717pub 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,
2021};
2122
2223use draw::{
......@@ -684,6 +685,7 @@ pub struct Ui {
684685 /// The platform's scrollers, where it draws fixed ones beside the content; scrollbars
685686 /// overlay the content otherwise.
686687 pub scrollers: Option<Scrollers>,
688 pub segments: Segments,
687689 frame: u64,
688690 now: Instant,
689691 scale: f32,
......@@ -747,6 +749,7 @@ impl Ui {
747749 icon_palette: draw::Palette::default(),
748750 window_focused: true,
749751 scrollers: None,
752 segments: Segments::default(),
750753 frame: 0,
751754 now: Instant::now(),
752755 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
138138 border: Some(if checked || signal.hovered {
139139 theme.accent
140140 } else {
141 theme.chip
141 crate::mix(theme.chip, theme.text, 0.45)
142142 }),
143143 radius: 3.0,
144144 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
158158 signal
159159}
160160
161/// How a segmented control draws its choice, as the platform draws its own.
162#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
163pub 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.
176pub 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
161297/// Scrollers the platform paints, fixed along the content's edges, as Mac OS X 10.6's are.
162298pub struct Scrollers {
163299 /// Across a scroller, in logical pixels.