返回 CodeWhale
segmented.rs
1 //! Segmented: two to five mutually exclusive options in one row.
2 //!
3 //! ```text
4 //! Full [Reduced] Still
5 //! ```
6 //!
7 //! The selected option is bracketed (`[Reduced]`), bold and underlined; the
8 //! others are `Muted`. The brackets are the cue that survives 16 colors,
9 //! `NO_COLOR` and ASCII; the focused control lights them in `Primary`. When
10 //! the options do not fit the row, it degrades to the current one and its
11 //! position: `Reduced (2 of 3)`. Past five options use a [`crate::Picker`].
12
13 use std::borrow::Cow;
14
15 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
16 use ratatui::{
17 buffer::Buffer,
18 layout::Rect,
19 style::{Modifier, Style},
20 };
21
22 use crate::{Paint, Role, Theme, text};
23
24 /// The most options a [`Segmented`] shows; any beyond are ignored.
25 pub const SEGMENTED_MAX: usize = 5;
26
27 /// The word in the compact form `Reduced (2 of 3)`. The kit owns no copy
28 /// beyond this English default.
29 #[derive(Clone, Debug, PartialEq, Eq)]
30 pub struct SegmentedWords {
31 pub of: Cow<'static, str>,
32 }
33
34 impl Default for SegmentedWords {
35 fn default() -> Self {
36 Self {
37 of: Cow::Borrowed("of"),
38 }
39 }
40 }
41
42 /// Which option is selected.
43 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44 pub struct SegmentedState {
45 pub selected: usize,
46 }
47
48 /// What a key did to a [`SegmentedState`].
49 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
50 pub enum SegmentedOutcome {
51 /// The key means nothing here, or changed nothing (an arrow at the end,
52 /// a number already selected); the host may use it.
53 Ignored,
54 /// Option `n` is now selected; repaint, and preview it if it previews.
55 Selected(usize),
56 }
57
58 impl SegmentedState {
59 #[must_use]
60 pub const fn new(selected: usize) -> Self {
61 Self { selected }
62 }
63
64 /// Apply a key press to a control of `len` options. Keys: `←`/`→` (they
65 /// stop at the ends, so a host can use the arrow there), `Home`, `End`
66 /// and `1`..`5`. Releases, chords with Ctrl, Alt or Super, other keys
67 /// and every key while `enabled` is false are
68 /// [`SegmentedOutcome::Ignored`].
69 pub fn handle_key(&mut self, key: KeyEvent, len: usize, enabled: bool) -> SegmentedOutcome {
70 let len = len.min(SEGMENTED_MAX);
71 let held = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
72 if !enabled
73 || len == 0
74 || key.kind == KeyEventKind::Release
75 || key.modifiers.intersects(held)
76 {
77 return SegmentedOutcome::Ignored;
78 }
79 let current = self.selected.min(len - 1);
80 let next = match key.code {
81 KeyCode::Left => current.saturating_sub(1),
82 KeyCode::Right => (current + 1).min(len - 1),
83 KeyCode::Home => 0,
84 KeyCode::End => len - 1,
85 KeyCode::Char(c @ '1'..='5') => usize::from(c as u8 - b'1'),
86 _ => return SegmentedOutcome::Ignored,
87 };
88 if next >= len || next == current {
89 return SegmentedOutcome::Ignored;
90 }
91 self.selected = next;
92 SegmentedOutcome::Selected(next)
93 }
94 }
95
96 /// A segmented control.
97 #[derive(Clone, Debug)]
98 pub struct Segmented<'a> {
99 options: Vec<Cow<'a, str>>,
100 pub state: SegmentedState,
101 pub words: SegmentedWords,
102 /// This control has the keyboard.
103 pub focused: bool,
104 /// `Some(reason)` dims the control and shows why it cannot change, when
105 /// the row has room.
106 pub disabled: Option<Cow<'a, str>>,
107 }
108
109 impl<'a> Segmented<'a> {
110 /// A control over the first [`SEGMENTED_MAX`] of `options`.
111 #[must_use]
112 pub fn new<I, S>(options: I, state: SegmentedState) -> Self
113 where
114 I: IntoIterator<Item = S>,
115 S: Into<Cow<'a, str>>,
116 {
117 Self {
118 options: options
119 .into_iter()
120 .take(SEGMENTED_MAX)
121 .map(Into::into)
122 .collect(),
123 state,
124 words: SegmentedWords::default(),
125 focused: false,
126 disabled: None,
127 }
128 }
129
130 #[must_use]
131 pub fn with_words(mut self, words: &SegmentedWords) -> Self {
132 self.words = words.clone();
133 self
134 }
135
136 #[must_use]
137 pub fn focused(mut self, focused: bool) -> Self {
138 self.focused = focused;
139 self
140 }
141
142 #[must_use]
143 pub fn disabled(mut self, reason: impl Into<Cow<'a, str>>) -> Self {
144 self.disabled = Some(reason.into());
145 self
146 }
147
148 #[must_use]
149 pub fn options(&self) -> &[Cow<'a, str>] {
150 &self.options
151 }
152
153 /// Apply a key to this control's state; a disabled control ignores
154 /// every key.
155 pub fn handle_key(&mut self, key: KeyEvent) -> SegmentedOutcome {
156 self.state
157 .handle_key(key, self.options.len(), self.disabled.is_none())
158 }
159
160 /// The cells the full form takes: each option with a cell of bracket or
161 /// padding either side, and one between options.
162 #[must_use]
163 pub fn full_width(&self) -> usize {
164 let labels: usize = self
165 .options
166 .iter()
167 .map(|o| text::width(&text::display_safe(o)) + 2)
168 .sum();
169 labels + self.options.len().saturating_sub(1)
170 }
171
172 fn selected(&self) -> usize {
173 self.state
174 .selected
175 .min(self.options.len().saturating_sub(1))
176 }
177
178 fn paint_full(&self, row: Rect, buf: &mut Buffer, theme: &Theme) {
179 let disabled = self.disabled.is_some();
180 let quiet = if disabled {
181 theme.fg(Role::Muted).add_modifier(Modifier::DIM)
182 } else {
183 theme.fg(Role::Muted)
184 };
185 let bracket = if self.focused && !disabled {
186 theme.fg(Role::Primary)
187 } else {
188 theme.fg(Role::Foreground)
189 };
190 let chosen = Style::default()
191 .patch(theme.fg(Role::Foreground))
192 .add_modifier(
193 Modifier::BOLD
194 | if disabled {
195 Modifier::DIM
196 } else {
197 Modifier::UNDERLINED
198 },
199 );
200 let selected = self.selected();
201 let mut x = row.x;
202 for (i, option) in self.options.iter().enumerate() {
203 let label = text::display_safe(option);
204 let w = u16::try_from(text::width(&label)).unwrap_or(0);
205 if i == selected {
206 buf.set_stringn(x, row.y, "[", 1, bracket);
207 buf.set_stringn(x + 1, row.y, &label, usize::from(w), chosen);
208 buf.set_stringn(x + 1 + w, row.y, "]", 1, bracket);
209 } else {
210 buf.set_stringn(x + 1, row.y, &label, usize::from(w), quiet);
211 }
212 x += w + 3;
213 }
214 if let Some(reason) = &self.disabled {
215 let room = usize::from(row.right().saturating_sub(x)).saturating_sub(1);
216 if room >= 4 {
217 let reason = text::display_safe(reason);
218 let reason = text::truncate_words(&reason, room, theme.ascii());
219 buf.set_stringn(x + 1, row.y, &reason, room, theme.fg(Role::Muted));
220 }
221 }
222 }
223
224 fn paint_compact(&self, row: Rect, buf: &mut Buffer, theme: &Theme) {
225 let disabled = self.disabled.is_some();
226 let current = text::display_safe(&self.options[self.selected()]);
227 let suffix = format!(
228 " ({} {} {})",
229 self.selected() + 1,
230 self.words.of,
231 self.options.len()
232 );
233 let width = usize::from(row.width);
234 let budget = width.saturating_sub(text::width(&suffix));
235 let (label, suffix_x) = if budget >= 3 {
236 let label = text::truncate(&current, budget, theme.ascii());
237 let at = text::width(&label);
238 (label, at)
239 } else {
240 (text::truncate(&current, width, theme.ascii()), width)
241 };
242 let label_style = theme.fg(Role::Foreground).add_modifier(if disabled {
243 Modifier::BOLD | Modifier::DIM
244 } else {
245 Modifier::BOLD
246 });
247 buf.set_stringn(row.x, row.y, &label, width, label_style);
248 if suffix_x < width {
249 let x = row.x + u16::try_from(suffix_x).unwrap_or(0);
250 buf.set_stringn(x, row.y, &suffix, width - suffix_x, theme.fg(Role::Muted));
251 }
252 }
253 }
254
255 impl Paint for Segmented<'_> {
256 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
257 let area = area.intersection(buf.area);
258 if area.is_empty() || self.options.is_empty() {
259 return;
260 }
261 let row = Rect { height: 1, ..area };
262 if self.full_width() <= usize::from(row.width) {
263 self.paint_full(row, buf, theme);
264 } else {
265 self.paint_compact(row, buf, theme);
266 }
267 }
268 }
269
270 #[cfg(test)]
271 mod tests {
272 use super::*;
273 use crate::testing::{Profile, render, text as rendered};
274
275 fn key(code: KeyCode) -> KeyEvent {
276 KeyEvent::new(code, KeyModifiers::NONE)
277 }
278
279 fn line(control: &Segmented<'_>, profile: Profile, width: u16) -> String {
280 let theme = profile.theme();
281 rendered(&render(width, 1, |area, buf| {
282 control.paint(area, buf, &theme)
283 }))
284 }
285
286 #[test]
287 fn arrows_stop_at_the_ends_and_numbers_jump() {
288 let mut state = SegmentedState::new(0);
289 assert_eq!(
290 state.handle_key(key(KeyCode::Left), 3, true),
291 SegmentedOutcome::Ignored
292 );
293 assert_eq!(
294 state.handle_key(key(KeyCode::Right), 3, true),
295 SegmentedOutcome::Selected(1)
296 );
297 assert_eq!(
298 state.handle_key(key(KeyCode::Right), 3, true),
299 SegmentedOutcome::Selected(2)
300 );
301 assert_eq!(
302 state.handle_key(key(KeyCode::Right), 3, true),
303 SegmentedOutcome::Ignored
304 );
305 assert_eq!(
306 state.handle_key(key(KeyCode::Char('1')), 3, true),
307 SegmentedOutcome::Selected(0)
308 );
309 assert_eq!(
310 state.handle_key(key(KeyCode::Char('1')), 3, true),
311 SegmentedOutcome::Ignored
312 );
313 assert_eq!(
314 state.handle_key(key(KeyCode::Char('4')), 3, true),
315 SegmentedOutcome::Ignored
316 );
317 assert_eq!(
318 state.handle_key(key(KeyCode::End), 3, true),
319 SegmentedOutcome::Selected(2)
320 );
321 assert_eq!(
322 state.handle_key(key(KeyCode::Home), 3, true),
323 SegmentedOutcome::Selected(0)
324 );
325 assert_eq!(state.selected, 0);
326 }
327
328 #[test]
329 fn disabled_released_chorded_and_empty_controls_ignore_keys() {
330 let mut state = SegmentedState::new(1);
331 assert_eq!(
332 state.handle_key(key(KeyCode::Right), 3, false),
333 SegmentedOutcome::Ignored
334 );
335 let mut release = key(KeyCode::Right);
336 release.kind = KeyEventKind::Release;
337 assert_eq!(
338 state.handle_key(release, 3, true),
339 SegmentedOutcome::Ignored
340 );
341 let chord = KeyEvent::new(KeyCode::Right, KeyModifiers::ALT);
342 assert_eq!(state.handle_key(chord, 3, true), SegmentedOutcome::Ignored);
343 assert_eq!(
344 state.handle_key(key(KeyCode::Right), 0, true),
345 SegmentedOutcome::Ignored
346 );
347 assert_eq!(state.selected, 1);
348 let mut control = Segmented::new(["A", "B"], SegmentedState::new(0)).disabled("locked");
349 assert_eq!(
350 control.handle_key(key(KeyCode::Right)),
351 SegmentedOutcome::Ignored
352 );
353 }
354
355 #[test]
356 fn more_than_five_options_keep_the_first_five() {
357 let control = Segmented::new(["a", "b", "c", "d", "e", "f"], SegmentedState::new(0));
358 assert_eq!(control.options().len(), SEGMENTED_MAX);
359 let mut state = SegmentedState::new(0);
360 assert_eq!(
361 state.handle_key(key(KeyCode::End), 9, true),
362 SegmentedOutcome::Selected(4)
363 );
364 }
365
366 #[test]
367 fn the_selection_is_bracketed_in_every_profile() {
368 let control = Segmented::new(["Full", "Reduced", "Still"], SegmentedState::new(1));
369 for profile in Profile::ALL {
370 assert_eq!(
371 line(&control, profile, 40),
372 " Full [Reduced] Still",
373 "{profile:?}"
374 );
375 }
376 }
377
378 #[test]
379 fn it_degrades_to_the_current_option_and_its_position() {
380 let control = Segmented::new(["Full", "Reduced", "Still"], SegmentedState::new(1));
381 assert_eq!(control.full_width(), 6 + 9 + 7 + 2);
382 assert_eq!(
383 line(&control, Profile::DarkTrue, 24),
384 " Full [Reduced] Still"
385 );
386 assert_eq!(line(&control, Profile::DarkTrue, 23), "Reduced (2 of 3)");
387 // Too narrow even for the position: the option's name, cut last.
388 assert_eq!(line(&control, Profile::DarkTrue, 10), "Reduced");
389 assert_eq!(line(&control, Profile::DarkTrue, 6), "Reduc…");
390 }
391
392 #[test]
393 fn tiny_areas_do_not_panic() {
394 let theme = Profile::DarkTrue.theme();
395 let control = Segmented::new(["Full", "Reduced"], SegmentedState::new(1)).focused(true);
396 for (w, h) in [(0, 0), (1, 1), (3, 1), (5, 1), (9, 0)] {
397 let _ = render(w, h, |area, buf| control.paint(area, buf, &theme));
398 }
399 }
400 }
401
401 lines RUST