| 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(¤t, budget, theme.ascii()); |
| 237 | let at = text::width(&label); |
| 238 | (label, at) |
| 239 | } else { |
| 240 | (text::truncate(¤t, 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 |