返回 CodeWhale
toggle.rs
1 //! Toggle: an on/off row with a label, a mark and a word for the state.
2 //!
3 //! On is `●` and "On", off is `○` and "Off" (`[x]` and `[ ]` in ASCII), so
4 //! the state is a shape and a word, never a color. Neither uses `Primary`,
5 //! which is reserved for actions, focus and links: on is `Foreground`, off
6 //! is `Muted`. The focused row carries the `▸` marker, bold and the
7 //! `Selected` ground, three cues that survive where grounds do not paint. A
8 //! disabled row is dimmed and says why.
9
10 use std::borrow::Cow;
11
12 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
13 use ratatui::{buffer::Buffer, layout::Rect, style::Modifier};
14
15 use crate::{Paint, Role, Theme, glyphs, text};
16
17 /// The words beside a toggle's mark. The kit owns no copy beyond this
18 /// English default.
19 #[derive(Clone, Debug, PartialEq, Eq)]
20 pub struct ToggleWords {
21 pub on: Cow<'static, str>,
22 pub off: Cow<'static, str>,
23 }
24
25 impl Default for ToggleWords {
26 fn default() -> Self {
27 Self {
28 on: Cow::Borrowed("On"),
29 off: Cow::Borrowed("Off"),
30 }
31 }
32 }
33
34 /// Whether the toggle is on.
35 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36 pub struct ToggleState {
37 pub on: bool,
38 }
39
40 impl From<bool> for ToggleState {
41 fn from(on: bool) -> Self {
42 Self { on }
43 }
44 }
45
46 /// What a key did to a [`ToggleState`].
47 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
48 pub enum ToggleOutcome {
49 /// The key means nothing to a toggle; the host may use it.
50 Ignored,
51 /// Space or Enter flipped it; the field is the new state.
52 Toggled(bool),
53 }
54
55 impl ToggleState {
56 /// Apply a key press: Space or Enter flips the state. Releases, other
57 /// keys and chords with Ctrl, Alt or Super are [`ToggleOutcome::Ignored`],
58 /// and so is everything while `enabled` is false.
59 pub fn handle_key(&mut self, key: KeyEvent, enabled: bool) -> ToggleOutcome {
60 let held = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
61 if !enabled || key.kind == KeyEventKind::Release || key.modifiers.intersects(held) {
62 return ToggleOutcome::Ignored;
63 }
64 match key.code {
65 KeyCode::Enter | KeyCode::Char(' ') => {
66 self.on = !self.on;
67 ToggleOutcome::Toggled(self.on)
68 }
69 _ => ToggleOutcome::Ignored,
70 }
71 }
72 }
73
74 /// One on/off row.
75 #[derive(Clone, Debug)]
76 pub struct Toggle<'a> {
77 pub label: Cow<'a, str>,
78 pub state: ToggleState,
79 pub words: ToggleWords,
80 /// This row has the keyboard.
81 pub focused: bool,
82 /// `Some(reason)` dims the row and shows why it cannot change.
83 pub disabled: Option<Cow<'a, str>>,
84 }
85
86 impl<'a> Toggle<'a> {
87 #[must_use]
88 pub fn new(label: impl Into<Cow<'a, str>>, state: impl Into<ToggleState>) -> Self {
89 Self {
90 label: label.into(),
91 state: state.into(),
92 words: ToggleWords::default(),
93 focused: false,
94 disabled: None,
95 }
96 }
97
98 #[must_use]
99 pub fn with_words(mut self, words: &ToggleWords) -> Self {
100 self.words = words.clone();
101 self
102 }
103
104 #[must_use]
105 pub fn focused(mut self, focused: bool) -> Self {
106 self.focused = focused;
107 self
108 }
109
110 #[must_use]
111 pub fn disabled(mut self, reason: impl Into<Cow<'a, str>>) -> Self {
112 self.disabled = Some(reason.into());
113 self
114 }
115
116 /// Apply a key to this row's state; a disabled row ignores every key.
117 pub fn handle_key(&mut self, key: KeyEvent) -> ToggleOutcome {
118 self.state.handle_key(key, self.disabled.is_none())
119 }
120
121 /// The mark: `●` on, `○` off; `[x]` and `[ ]` in ASCII.
122 fn mark(&self, ascii: bool) -> &'static str {
123 match (self.state.on, ascii) {
124 (true, false) => glyphs::CURRENT,
125 (false, false) => glyphs::AVAILABLE,
126 (true, true) => "[x]",
127 (false, true) => "[ ]",
128 }
129 }
130 }
131
132 impl Paint for Toggle<'_> {
133 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
134 let area = area.intersection(buf.area);
135 if area.is_empty() {
136 return;
137 }
138 let row = Rect { height: 1, ..area };
139 let width = usize::from(row.width);
140 let ascii = theme.ascii();
141 let disabled = self.disabled.is_some();
142 if self.focused {
143 buf.set_style(row, theme.bg(Role::Selected));
144 }
145
146 let word = text::display_safe(if self.state.on {
147 &self.words.on
148 } else {
149 &self.words.off
150 });
151 let state = format!("{} {word}", self.mark(ascii));
152 let state_w = text::width(&state).min(width);
153 let state_x = row.right() - u16::try_from(state_w).unwrap_or(row.width);
154 let state_style = match (disabled, self.state.on) {
155 (true, _) => theme.fg(Role::Muted).add_modifier(Modifier::DIM),
156 (false, true) => theme.fg(Role::Foreground),
157 (false, false) => theme.fg(Role::Muted),
158 };
159 buf.set_stringn(state_x, row.y, &state, state_w, state_style);
160
161 // The label and its marker take what the state leaves, with two
162 // cells between them.
163 let left_room = width.saturating_sub(state_w + 2);
164 let marker = glyphs::pick(glyphs::selection_marker(self.focused), ascii);
165 let marker_w = text::width(marker) + 1;
166 if left_room <= marker_w {
167 return;
168 }
169 buf.set_stringn(row.x, row.y, marker, marker_w, theme.fg(Role::Primary));
170 let label_style = if disabled {
171 theme.fg(Role::Muted).add_modifier(Modifier::DIM)
172 } else if self.focused {
173 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)
174 } else {
175 theme.fg(Role::Foreground)
176 };
177 let label = text::display_safe(&self.label);
178 let label = text::truncate(&label, left_room - marker_w, ascii);
179 let label_x = row.x + u16::try_from(marker_w).unwrap_or(0);
180 buf.set_stringn(label_x, row.y, &label, left_room - marker_w, label_style);
181
182 if let Some(reason) = &self.disabled {
183 let used = marker_w + text::width(&label) + 2;
184 let room = left_room.saturating_sub(used);
185 if room >= 4 {
186 let reason = text::display_safe(reason);
187 let reason = text::truncate_words(&reason, room, ascii);
188 let x = row.x + u16::try_from(used).unwrap_or(0);
189 buf.set_stringn(x, row.y, &reason, room, theme.fg(Role::Muted));
190 }
191 }
192 }
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198 use crate::testing::{Profile, render, text as rendered};
199
200 fn key(code: KeyCode) -> KeyEvent {
201 KeyEvent::new(code, KeyModifiers::NONE)
202 }
203
204 #[test]
205 fn space_and_enter_flip_and_report_the_new_state() {
206 let mut state = ToggleState::default();
207 assert_eq!(
208 state.handle_key(key(KeyCode::Char(' ')), true),
209 ToggleOutcome::Toggled(true)
210 );
211 assert!(state.on);
212 assert_eq!(
213 state.handle_key(key(KeyCode::Enter), true),
214 ToggleOutcome::Toggled(false)
215 );
216 assert!(!state.on);
217 }
218
219 #[test]
220 fn other_keys_releases_chords_and_disabled_rows_do_nothing() {
221 let mut state = ToggleState { on: true };
222 assert_eq!(
223 state.handle_key(key(KeyCode::Up), true),
224 ToggleOutcome::Ignored
225 );
226 assert_eq!(
227 state.handle_key(
228 KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL),
229 true
230 ),
231 ToggleOutcome::Ignored
232 );
233 let mut release = key(KeyCode::Char(' '));
234 release.kind = KeyEventKind::Release;
235 assert_eq!(state.handle_key(release, true), ToggleOutcome::Ignored);
236 assert_eq!(
237 state.handle_key(key(KeyCode::Char(' ')), false),
238 ToggleOutcome::Ignored
239 );
240 assert!(state.on, "nothing flipped it");
241 let mut row = Toggle::new("Show tool details", true).disabled("set by your admin");
242 assert_eq!(row.handle_key(key(KeyCode::Enter)), ToggleOutcome::Ignored);
243 }
244
245 #[test]
246 fn the_state_is_a_mark_and_a_word_in_every_profile() {
247 for profile in [Profile::DarkTrue, Profile::NoColor, Profile::Ascii] {
248 let theme = profile.theme();
249 for (on, mark, word) in [(true, "●", "On"), (false, "○", "Off")] {
250 let buf = render(40, 1, |area, buf| {
251 Toggle::new("Show tool details", on).paint(area, buf, &theme)
252 });
253 let line = rendered(&buf);
254 let mark = if profile == Profile::Ascii {
255 if on { "[x]" } else { "[ ]" }
256 } else {
257 mark
258 };
259 assert!(line.ends_with(&format!("{mark} {word}")), "{line:?}");
260 assert!(line.contains("Show tool details"), "{line:?}");
261 }
262 }
263 }
264
265 #[test]
266 fn a_narrow_row_keeps_the_state_and_cuts_the_label() {
267 let theme = Profile::DarkTrue.theme();
268 let buf = render(16, 1, |area, buf| {
269 Toggle::new("Show tool details", true).paint(area, buf, &theme)
270 });
271 let line = rendered(&buf);
272 assert!(line.ends_with("● On"), "{line:?}");
273 assert!(line.contains('…'), "{line:?}");
274 for w in 0..4 {
275 let _ = render(w, 1, |area, buf| {
276 Toggle::new("Show", false)
277 .focused(true)
278 .paint(area, buf, &theme)
279 });
280 }
281 }
282
283 #[test]
284 fn a_disabled_row_says_why() {
285 let theme = Profile::NoColor.theme();
286 let buf = render(60, 1, |area, buf| {
287 Toggle::new("Telemetry", false)
288 .disabled("set by your admin")
289 .paint(area, buf, &theme)
290 });
291 let line = rendered(&buf);
292 assert!(line.contains("set by your admin"), "{line:?}");
293 assert!(line.ends_with("○ Off"), "{line:?}");
294 }
295 }
296
296 lines RUST