Skip to main content

qframe/widgets/
segmented.rs

1//! Segmented controls.
2
3use super::IndexMessage;
4use super::press::{self, Press};
5use crate::env::Env;
6use crate::event::Event;
7use crate::geometry::{Rect, Size};
8use crate::keymap::Key;
9use crate::style::CellStyle;
10use crate::text;
11use crate::theme::State;
12use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
13
14/// Side-by-side segments of one surface, of which the filled one is chosen: for two to five
15/// short, mutually exclusive choices such as a view mode, where the choice should read at a
16/// glance.
17///
18/// The control takes focus as one: Left and Right choose the neighbouring segment (going round
19/// the ends with [`wrap`](Self::wrap)), Home and End the first and last, and a click chooses the segment under the pointer. The application owns
20/// the choice.
21///
22/// Style keys: `segment` (`bg`, `fg`, `bold`, `padding`, `pillar`) with states `hover`, `focus`,
23/// `checked`, `disabled`. The pillar stands in the first cell of the hovered segment, or of the
24/// chosen one while the keyboard focuses the control.
25pub struct Segmented<Msg> {
26    options: Vec<String>,
27    selected: usize,
28    disabled: bool,
29    wrap: bool,
30    on_select: Option<IndexMessage<Msg>>,
31}
32
33impl<Msg> Segmented<Msg> {
34    /// Segments for `options` with the first chosen.
35    #[must_use]
36    pub fn new(options: impl IntoIterator<Item = impl Into<String>>) -> Self {
37        Self {
38            options: options.into_iter().map(Into::into).collect(),
39            selected: 0,
40            disabled: false,
41            wrap: false,
42            on_select: None,
43        }
44    }
45
46    /// The chosen segment.
47    #[must_use]
48    pub fn selected(mut self, index: usize) -> Self {
49        self.selected = index;
50        self
51    }
52
53    /// Greys the control out; it cannot be focused or changed.
54    #[must_use]
55    pub fn disabled(mut self, disabled: bool) -> Self {
56        self.disabled = disabled;
57        self
58    }
59
60    /// Lets Right on the last segment choose the first and Left on the first choose the last.
61    /// Home and End still stop at the ends. Off by default.
62    #[must_use]
63    pub fn wrap(mut self, wrap: bool) -> Self {
64        self.wrap = wrap;
65        self
66    }
67
68    /// Message for choosing segment `index`.
69    #[must_use]
70    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
71        self.on_select = Some(Box::new(message));
72        self
73    }
74
75    fn active(&self) -> bool {
76        !self.disabled && self.on_select.is_some() && !self.options.is_empty()
77    }
78
79    /// Left edge and width of every segment, relative to the control.
80    fn spans(&self, horizontal_padding: u16) -> Vec<(u16, u16)> {
81        let mut x = 0u16;
82        self.options
83            .iter()
84            .map(|label| {
85                let width = text::width(label).saturating_add(horizontal_padding.saturating_mul(2));
86                let span = (x, width);
87                x = x.saturating_add(width);
88                span
89            })
90            .collect()
91    }
92
93    fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
94        let index = index.min(self.options.len() - 1);
95        if index != self.selected
96            && let Some(message) = &self.on_select
97        {
98            cx.emit(message(index));
99        }
100    }
101}
102
103/// The horizontal padding of a segment from the theme.
104fn padding(env: &Env) -> u16 {
105    env.theme().style("segment", None, &[]).pair("padding").map_or(2, |(_, horizontal)| horizontal)
106}
107
108impl<Msg: 'static> Widget<Msg> for Segmented<Msg> {
109    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
110        let pad = padding(cx.env());
111        let width = self.spans(pad).last().map_or(0, |(x, width)| x.saturating_add(*width));
112        Size::new(width, 1).min(available)
113    }
114
115    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
116        let active = self.active();
117        let focused = active && cx.is_focus_visible();
118        let pointer = if active { cx.pointer() } else { None };
119        let pad = padding(cx.env());
120        for (index, (x, width)) in self.spans(pad).into_iter().enumerate() {
121            let rect = Rect::new(area.x + i32::from(x), area.y, width, 1).intersect(area);
122            if rect.is_empty() {
123                continue;
124            }
125            let chosen = index == self.selected;
126            let mut states = Vec::new();
127            if pointer.is_some_and(|(px, py)| rect.contains(px, py)) {
128                states.push(State::Hover);
129            }
130            if focused && chosen {
131                states.push(State::Focus);
132            }
133            if chosen {
134                states.push(State::Checked);
135            }
136            if self.disabled {
137                states.push(State::Disabled);
138            }
139            let segment_style = cx.style("segment", None, &states);
140            let style = segment_style.text();
141            cx.clear(rect, style.bg.unwrap_or_else(|| cx.color("raised")));
142            // The pillar stands beside the segment itself, never at the far left of the control.
143            if let Some(color) = segment_style.color("pillar").filter(|_| pad >= 1) {
144                cx.pillar(rect.x, rect.y, color);
145            }
146            let budget = rect.width.saturating_sub(pad);
147            let label = text::truncate(&self.options[index], budget).into_owned();
148            cx.text(rect.x + i32::from(pad), rect.y, &label, CellStyle { bg: None, ..style }, budget);
149        }
150        if active {
151            cx.register_hit(area);
152        }
153    }
154
155    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
156        if !self.active() {
157            return false;
158        }
159        if let Event::Key(key) = event {
160            let last = self.options.len() - 1;
161            let target = if key.is_plain(Key::Left) {
162                Some(if self.wrap && self.selected == 0 { last } else { self.selected.saturating_sub(1) })
163            } else if key.is_plain(Key::Right) {
164                Some(if self.wrap && self.selected >= last { 0 } else { self.selected + 1 })
165            } else if key.is_plain(Key::Home) {
166                Some(0)
167            } else if key.is_plain(Key::End) {
168                Some(last)
169            } else {
170                None
171            };
172            if let Some(index) = target {
173                self.choose(cx, index);
174                return true;
175            }
176        }
177        match press::read(cx, event) {
178            Press::Ignored | Press::Key => false,
179            Press::Used => true,
180            Press::Click(x, _) => {
181                let offset = x - cx.area().x;
182                let pad = padding(cx.env());
183                if let Some(index) = self
184                    .spans(pad)
185                    .iter()
186                    .position(|(start, width)| (i32::from(*start)..i32::from(start + width)).contains(&offset))
187                {
188                    self.choose(cx, index);
189                }
190                true
191            }
192        }
193    }
194
195    fn focusable(&self) -> bool {
196        self.active()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::runtime::{App, Command, Harness};
204    use crate::widget::View;
205
206    struct Demo {
207        chosen: usize,
208    }
209
210    impl App for Demo {
211        type Msg = usize;
212        fn update(&mut self, index: usize) -> Command<usize> {
213            self.chosen = index;
214            Command::none()
215        }
216        fn view(&self, ui: &mut View<'_, usize>) {
217            ui.add(Segmented::new(["List", "Grid", "Tree"]).selected(self.chosen).on_select(|i| i)).id("view");
218        }
219    }
220
221    #[test]
222    fn chosen_segment_is_filled_and_changes_by_keys_and_clicks() {
223        let mut h = Harness::new(Demo { chosen: 0 }, 30, 1);
224        assert_eq!(h.screen(), "  List    Grid    Tree\n");
225        let theme = h.env().theme();
226        // The chosen segment is a tint of the accent, calm enough to leave room for hover.
227        assert!(h.bg(2, 0) != theme.color("raised") && h.bg(2, 0) != theme.color("accent"));
228        assert_eq!(h.bg(10, 0), theme.color("raised"));
229        h.press("tab").press("right");
230        assert_eq!(h.app().chosen, 1);
231        h.press("end");
232        assert_eq!(h.app().chosen, 2);
233        h.click_text("List");
234        assert_eq!(h.app().chosen, 0);
235    }
236
237    #[test]
238    fn a_wrapping_control_goes_round_its_ends_and_stops_otherwise() {
239        struct Wrapping {
240            chosen: usize,
241            wrap: bool,
242        }
243        impl App for Wrapping {
244            type Msg = usize;
245            fn update(&mut self, index: usize) -> Command<usize> {
246                self.chosen = index;
247                Command::none()
248            }
249            fn view(&self, ui: &mut View<'_, usize>) {
250                ui.add(Segmented::new(["List", "Grid", "Tree"]).selected(self.chosen).wrap(self.wrap).on_select(|i| i));
251            }
252        }
253        let mut h = Harness::new(Wrapping { chosen: 2, wrap: true }, 30, 1);
254        let raised = h.env().theme().color("raised");
255        h.press("tab").press("right");
256        // The filled segment is where the choice went: List, at the far left.
257        assert_ne!(h.bg(2, 0), raised, "right on the last segment chooses the first");
258        assert_eq!(h.bg(18, 0), raised);
259        h.press("left");
260        assert_eq!(h.app().chosen, 2, "left on the first segment chooses the last");
261        h.press("end").press("right").press("home").press("home");
262        assert_eq!(h.app().chosen, 0, "home stops at the start");
263
264        let mut h = Harness::new(Wrapping { chosen: 2, wrap: false }, 30, 1);
265        h.press("tab").press("right");
266        assert_eq!(h.app().chosen, 2);
267    }
268
269    #[test]
270    fn every_cell_of_a_segment_including_its_padding_chooses_it() {
271        let mut h = Harness::new(Demo { chosen: 0 }, 30, 1);
272        for x in 8..16 {
273            h.click(x, 0);
274            assert_eq!(h.app().chosen, 1, "column {x} belongs to Grid");
275            h.click(0, 0);
276            assert_eq!(h.app().chosen, 0, "column 0 belongs to List");
277        }
278    }
279
280    #[test]
281    fn the_pillar_marks_the_hovered_segment_in_its_own_first_cell() {
282        let mut h = Harness::new(Demo { chosen: 0 }, 30, 1);
283        h.hover(12, 0);
284        assert_eq!(h.screen(), "  List  ▌ Grid    Tree\n");
285    }
286}