max / alloy_tui
8 files changed,
+188 insertions,
-56 deletions
| @@ -25,7 +25,11 @@ pub struct AlloyConnector<'a> { | |||
| 25 | 25 | ||
| 26 | 26 | impl<'a> AlloyConnector<'a> { | |
| 27 | 27 | pub fn new(theme: &'a Theme, from_y: u16, to_y: u16) -> Self { | |
| 28 | - | Self { theme, from_y, to_y } | |
| 28 | + | Self { | |
| 29 | + | theme, | |
| 30 | + | from_y, | |
| 31 | + | to_y, | |
| 32 | + | } | |
| 29 | 33 | } | |
| 30 | 34 | } | |
| 31 | 35 | ||
| @@ -129,11 +133,7 @@ mod tests { | |||
| 129 | 133 | AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf); | |
| 130 | 134 | ||
| 131 | 135 | (0..height) | |
| 132 | - | .map(|y| { | |
| 133 | - | (0..width) | |
| 134 | - | .map(|x| buf[(x, y)].symbol()) | |
| 135 | - | .collect::<String>() | |
| 136 | - | }) | |
| 136 | + | .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>()) | |
| 137 | 137 | .collect() | |
| 138 | 138 | } | |
| 139 | 139 |
| @@ -32,7 +32,11 @@ impl Cursor { | |||
| 32 | 32 | /// from reporting row 0 as selected, which renders as a selection marker | |
| 33 | 33 | /// on a row that does not exist. | |
| 34 | 34 | pub const fn selected(&self) -> Option<usize> { | |
| 35 | - | if self.len == 0 { None } else { Some(self.index) } | |
| 35 | + | if self.len == 0 { | |
| 36 | + | None | |
| 37 | + | } else { | |
| 38 | + | Some(self.index) | |
| 39 | + | } | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 38 | 42 | pub const fn len(&self) -> usize { | |
| @@ -91,7 +95,11 @@ mod tests { | |||
| 91 | 95 | assert_eq!(cursor.selected(), None); | |
| 92 | 96 | cursor.next(); | |
| 93 | 97 | cursor.prev(); | |
| 94 | - | assert_eq!(cursor.selected(), None, "movement on an empty list is inert"); | |
| 98 | + | assert_eq!( | |
| 99 | + | cursor.selected(), | |
| 100 | + | None, | |
| 101 | + | "movement on an empty list is inert" | |
| 102 | + | ); | |
| 95 | 103 | } | |
| 96 | 104 | ||
| 97 | 105 | #[test] | |
| @@ -99,9 +107,17 @@ mod tests { | |||
| 99 | 107 | let mut cursor = Cursor::new(); | |
| 100 | 108 | cursor.resize(3); | |
| 101 | 109 | cursor.prev(); | |
| 102 | - | assert_eq!(cursor.selected(), Some(0), "no wrap to the end from the top"); | |
| 110 | + | assert_eq!( | |
| 111 | + | cursor.selected(), | |
| 112 | + | Some(0), | |
| 113 | + | "no wrap to the end from the top" | |
| 114 | + | ); | |
| 103 | 115 | cursor.move_by(99); | |
| 104 | - | assert_eq!(cursor.selected(), Some(2), "no wrap to the top from the end"); | |
| 116 | + | assert_eq!( | |
| 117 | + | cursor.selected(), | |
| 118 | + | Some(2), | |
| 119 | + | "no wrap to the top from the end" | |
| 120 | + | ); | |
| 105 | 121 | } | |
| 106 | 122 | ||
| 107 | 123 | // The case this type exists for: a refresh returns fewer rows than the |
| @@ -70,9 +70,17 @@ mod tests { | |||
| 70 | 70 | fn wraps_in_both_directions() { | |
| 71 | 71 | let mut ring = FocusRing::new(3); | |
| 72 | 72 | ring.prev(); | |
| 73 | - | assert_eq!(ring.current(), 2, "prev from the first slot wraps to the last"); | |
| 73 | + | assert_eq!( | |
| 74 | + | ring.current(), | |
| 75 | + | 2, | |
| 76 | + | "prev from the first slot wraps to the last" | |
| 77 | + | ); | |
| 74 | 78 | ring.next(); | |
| 75 | - | assert_eq!(ring.current(), 0, "next from the last slot wraps to the first"); | |
| 79 | + | assert_eq!( | |
| 80 | + | ring.current(), | |
| 81 | + | 0, | |
| 82 | + | "next from the last slot wraps to the first" | |
| 83 | + | ); | |
| 76 | 84 | } | |
| 77 | 85 | ||
| 78 | 86 | // An empty ring is what a view has before its panes load. The movers must be | |
| @@ -83,7 +91,10 @@ mod tests { | |||
| 83 | 91 | ring.next(); | |
| 84 | 92 | ring.prev(); | |
| 85 | 93 | assert_eq!(ring.current(), 0); | |
| 86 | - | assert!(!ring.is_focused(0), "nothing is focused when there are no slots"); | |
| 94 | + | assert!( | |
| 95 | + | !ring.is_focused(0), | |
| 96 | + | "nothing is focused when there are no slots" | |
| 97 | + | ); | |
| 87 | 98 | } | |
| 88 | 99 | ||
| 89 | 100 | #[test] | |
| @@ -98,6 +109,10 @@ mod tests { | |||
| 98 | 109 | fn focus_ignores_out_of_range_slots() { | |
| 99 | 110 | let mut ring = FocusRing::new(2); | |
| 100 | 111 | ring.focus(5); | |
| 101 | - | assert_eq!(ring.current(), 0, "an out-of-range focus leaves the ring where it was"); | |
| 112 | + | assert_eq!( | |
| 113 | + | ring.current(), | |
| 114 | + | 0, | |
| 115 | + | "an out-of-range focus leaves the ring where it was" | |
| 116 | + | ); | |
| 102 | 117 | } | |
| 103 | 118 | } |
| @@ -85,8 +85,14 @@ mod tests { | |||
| 85 | 85 | ||
| 86 | 86 | #[test] | |
| 87 | 87 | fn ctrl_s_saves_but_bare_s_does_not() { | |
| 88 | - | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save); | |
| 89 | - | assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough); | |
| 88 | + | assert_eq!( | |
| 89 | + | classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), | |
| 90 | + | Action::Save | |
| 91 | + | ); | |
| 92 | + | assert_eq!( | |
| 93 | + | classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), | |
| 94 | + | Action::Passthrough | |
| 95 | + | ); | |
| 90 | 96 | } | |
| 91 | 97 | ||
| 92 | 98 | // Terminals disagree on how they report Shift-Tab: some send BackTab with no | |
| @@ -95,22 +101,46 @@ mod tests { | |||
| 95 | 101 | // in the stack. | |
| 96 | 102 | #[test] | |
| 97 | 103 | fn both_shift_tab_encodings_move_focus_backward() { | |
| 98 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus); | |
| 99 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 100 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus); | |
| 101 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus); | |
| 104 | + | assert_eq!( | |
| 105 | + | classify(key(KeyCode::BackTab, KeyModifiers::NONE)), | |
| 106 | + | Action::PrevFocus | |
| 107 | + | ); | |
| 108 | + | assert_eq!( | |
| 109 | + | classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), | |
| 110 | + | Action::PrevFocus | |
| 111 | + | ); | |
| 112 | + | assert_eq!( | |
| 113 | + | classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), | |
| 114 | + | Action::PrevFocus | |
| 115 | + | ); | |
| 116 | + | assert_eq!( | |
| 117 | + | classify(key(KeyCode::Tab, KeyModifiers::NONE)), | |
| 118 | + | Action::NextFocus | |
| 119 | + | ); | |
| 102 | 120 | } | |
| 103 | 121 | ||
| 104 | 122 | #[test] | |
| 105 | 123 | fn unreserved_keys_pass_through() { | |
| 106 | - | assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough); | |
| 107 | - | assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough); | |
| 124 | + | assert_eq!( | |
| 125 | + | classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), | |
| 126 | + | Action::Passthrough | |
| 127 | + | ); | |
| 128 | + | assert_eq!( | |
| 129 | + | classify(key(KeyCode::Down, KeyModifiers::NONE)), | |
| 130 | + | Action::Passthrough | |
| 131 | + | ); | |
| 108 | 132 | } | |
| 109 | 133 | ||
| 110 | 134 | #[test] | |
| 111 | 135 | fn h_and_l_move_between_tabs() { | |
| 112 | - | assert_eq!(classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab); | |
| 113 | - | assert_eq!(classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab); | |
| 136 | + | assert_eq!( | |
| 137 | + | classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), | |
| 138 | + | Action::NextTab | |
| 139 | + | ); | |
| 140 | + | assert_eq!( | |
| 141 | + | classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), | |
| 142 | + | Action::PrevTab | |
| 143 | + | ); | |
| 114 | 144 | } | |
| 115 | 145 | ||
| 116 | 146 | // Tabs and focus are two navigation axes and must stay on separate keys. | |
| @@ -118,15 +148,27 @@ mod tests { | |||
| 118 | 148 | // view, so a tab bar claiming Tab would silently redefine it everywhere. | |
| 119 | 149 | #[test] | |
| 120 | 150 | fn tab_key_still_means_focus_not_tabs() { | |
| 121 | - | assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus); | |
| 122 | - | assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus); | |
| 151 | + | assert_eq!( | |
| 152 | + | classify(key(KeyCode::Tab, KeyModifiers::NONE)), | |
| 153 | + | Action::NextFocus | |
| 154 | + | ); | |
| 155 | + | assert_eq!( | |
| 156 | + | classify(key(KeyCode::BackTab, KeyModifiers::NONE)), | |
| 157 | + | Action::PrevFocus | |
| 158 | + | ); | |
| 123 | 159 | } | |
| 124 | 160 | ||
| 125 | 161 | // j/k stay unreserved so a list cursor keeps them. Only the horizontal half | |
| 126 | 162 | // of the vim pair is spoken for. | |
| 127 | 163 | #[test] | |
| 128 | 164 | fn vertical_vim_keys_are_not_claimed_by_tabs() { | |
| 129 | - | assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough); | |
| 130 | - | assert_eq!(classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough); | |
| 165 | + | assert_eq!( | |
| 166 | + | classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), | |
| 167 | + | Action::Passthrough | |
| 168 | + | ); | |
| 169 | + | assert_eq!( | |
| 170 | + | classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), | |
| 171 | + | Action::Passthrough | |
| 172 | + | ); | |
| 131 | 173 | } | |
| 132 | 174 | } |
| @@ -110,7 +110,10 @@ pub fn panes(area: Rect) -> PaneAreas { | |||
| 110 | 110 | let left_width = usable - usable / 2; | |
| 111 | 111 | ||
| 112 | 112 | PaneAreas { | |
| 113 | - | left: Rect { width: left_width, ..area }, | |
| 113 | + | left: Rect { | |
| 114 | + | width: left_width, | |
| 115 | + | ..area | |
| 116 | + | }, | |
| 114 | 117 | gutter: Rect { | |
| 115 | 118 | x: area.x + left_width, | |
| 116 | 119 | width: GUTTER_WIDTH, | |
| @@ -149,7 +152,10 @@ mod tests { | |||
| 149 | 152 | let areas = panes(Rect::new(0, 0, 81, 20)); | |
| 150 | 153 | assert_eq!(areas.left.width, 39); | |
| 151 | 154 | assert_eq!(areas.right.width, 39); | |
| 152 | - | assert_eq!(areas.left.width + areas.gutter.width + areas.right.width, 81); | |
| 155 | + | assert_eq!( | |
| 156 | + | areas.left.width + areas.gutter.width + areas.right.width, | |
| 157 | + | 81 | |
| 158 | + | ); | |
| 153 | 159 | } | |
| 154 | 160 | ||
| 155 | 161 | // A narrow terminal collapses to one pane rather than two unusable slivers. | |
| @@ -179,7 +185,10 @@ mod tests { | |||
| 179 | 185 | #[test] | |
| 180 | 186 | fn short_terminal_drops_the_log_pane() { | |
| 181 | 187 | let areas = console(Rect::new(0, 0, 80, 10)); | |
| 182 | - | assert_eq!(areas.log.height, 0, "log is dropped below the minimum height"); | |
| 188 | + | assert_eq!( | |
| 189 | + | areas.log.height, 0, | |
| 190 | + | "log is dropped below the minimum height" | |
| 191 | + | ); | |
| 183 | 192 | assert_eq!(areas.body.height, 9); | |
| 184 | 193 | assert_eq!(areas.footer.height, 1); | |
| 185 | 194 | } |
| @@ -30,7 +30,9 @@ pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |||
| 30 | 30 | pub fn bold(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 31 | 31 | Span::styled( | |
| 32 | 32 | s.into(), | |
| 33 | - | Style::default().fg(theme.content_primary).add_modifier(Modifier::BOLD), | |
| 33 | + | Style::default() | |
| 34 | + | .fg(theme.content_primary) | |
| 35 | + | .add_modifier(Modifier::BOLD), | |
| 34 | 36 | ) | |
| 35 | 37 | } | |
| 36 | 38 |
| @@ -15,8 +15,8 @@ | |||
| 15 | 15 | //! and pull colors from it. Apps build one `Theme` per theme load (via | |
| 16 | 16 | //! `makeover::load_theme` + `Theme::from_theme`) and thread it through. | |
| 17 | 17 | ||
| 18 | - | use ratatui::style::Color; | |
| 19 | 18 | use makeover::{Rgb, ThemeColors}; | |
| 19 | + | use ratatui::style::Color; | |
| 20 | 20 | ||
| 21 | 21 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 22 | 22 | pub enum Mode { | |
| @@ -178,11 +178,17 @@ fn indexed(c: Color) -> Color { | |||
| 178 | 178 | /// As [`indexed`], but guaranteed to stay legible against `on`. | |
| 179 | 179 | fn indexed_against(c: Color, on: Color) -> Color { | |
| 180 | 180 | match (c, on) { | |
| 181 | - | (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(makeover::quantize_against( | |
| 182 | - | Rgb { r, g, b }, | |
| 183 | - | Rgb { r: br, g: bg, b: bb }, | |
| 184 | - | &makeover::ANSI_16, | |
| 185 | - | ) as u8), | |
| 181 | + | (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => { | |
| 182 | + | Color::Indexed(makeover::quantize_against( | |
| 183 | + | Rgb { r, g, b }, | |
| 184 | + | Rgb { | |
| 185 | + | r: br, | |
| 186 | + | g: bg, | |
| 187 | + | b: bb, | |
| 188 | + | }, | |
| 189 | + | &makeover::ANSI_16, | |
| 190 | + | ) as u8) | |
| 191 | + | } | |
| 186 | 192 | _ => indexed(c), | |
| 187 | 193 | } | |
| 188 | 194 | } | |
| @@ -253,7 +259,11 @@ fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb { | |||
| 253 | 259 | } | |
| 254 | 260 | ||
| 255 | 261 | fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) { | |
| 256 | - | (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b)) | |
| 262 | + | ( | |
| 263 | + | channel_to_linear(c.r), | |
| 264 | + | channel_to_linear(c.g), | |
| 265 | + | channel_to_linear(c.b), | |
| 266 | + | ) | |
| 257 | 267 | } | |
| 258 | 268 | ||
| 259 | 269 | fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb { | |
| @@ -266,11 +276,19 @@ fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb { | |||
| 266 | 276 | ||
| 267 | 277 | fn channel_to_linear(c: u8) -> f32 { | |
| 268 | 278 | let c = c as f32 / 255.0; | |
| 269 | - | if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } | |
| 279 | + | if c <= 0.04045 { | |
| 280 | + | c / 12.92 | |
| 281 | + | } else { | |
| 282 | + | ((c + 0.055) / 1.055).powf(2.4) | |
| 283 | + | } | |
| 270 | 284 | } | |
| 271 | 285 | ||
| 272 | 286 | fn channel_to_srgb(c: f32) -> u8 { | |
| 273 | - | let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }; | |
| 287 | + | let v = if c <= 0.0031308 { | |
| 288 | + | c * 12.92 | |
| 289 | + | } else { | |
| 290 | + | 1.055 * c.powf(1.0 / 2.4) - 0.055 | |
| 291 | + | }; | |
| 274 | 292 | (v * 255.0).round().clamp(0.0, 255.0) as u8 | |
| 275 | 293 | } | |
| 276 | 294 | ||
| @@ -291,7 +309,9 @@ mod tests { | |||
| 291 | 309 | (got.r, got.g, got.b), | |
| 292 | 310 | (0x7f, 0x78, 0x6d), | |
| 293 | 311 | "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d", | |
| 294 | - | got.r, got.g, got.b | |
| 312 | + | got.r, | |
| 313 | + | got.g, | |
| 314 | + | got.b | |
| 295 | 315 | ); | |
| 296 | 316 | } | |
| 297 | 317 |
| @@ -33,7 +33,10 @@ pub struct AlloyBlock<'a> { | |||
| 33 | 33 | ||
| 34 | 34 | impl<'a> AlloyBlock<'a> { | |
| 35 | 35 | pub fn new(theme: &'a Theme) -> Self { | |
| 36 | - | Self { theme, focused: false } | |
| 36 | + | Self { | |
| 37 | + | theme, | |
| 38 | + | focused: false, | |
| 39 | + | } | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | pub fn focused(mut self, focused: bool) -> Self { | |
| @@ -428,19 +431,23 @@ impl Widget for AlloyModal<'_> { | |||
| 428 | 431 | ))) | |
| 429 | 432 | .style(base) | |
| 430 | 433 | .wrap(ratatui::widgets::Wrap { trim: true }) | |
| 431 | - | .render(Rect { height: message_height, ..inner }, buf); | |
| 432 | - | } | |
| 433 | - | ||
| 434 | - | Paragraph::new(keys) | |
| 435 | - | .style(base) | |
| 436 | 434 | .render( | |
| 437 | 435 | Rect { | |
| 438 | - | y: inner.y + inner.height - 1, | |
| 439 | - | height: 1, | |
| 436 | + | height: message_height, | |
| 440 | 437 | ..inner | |
| 441 | 438 | }, | |
| 442 | 439 | buf, | |
| 443 | 440 | ); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | Paragraph::new(keys).style(base).render( | |
| 444 | + | Rect { | |
| 445 | + | y: inner.y + inner.height - 1, | |
| 446 | + | height: 1, | |
| 447 | + | ..inner | |
| 448 | + | }, | |
| 449 | + | buf, | |
| 450 | + | ); | |
| 444 | 451 | } | |
| 445 | 452 | } | |
| 446 | 453 | ||
| @@ -593,9 +600,21 @@ mod tests { | |||
| 593 | 600 | #[test] | |
| 594 | 601 | fn row_y_is_none_for_rows_scrolled_out_of_view() { | |
| 595 | 602 | let area = Rect::new(0, 0, 20, 10); | |
| 596 | - | assert_eq!(list_row_y(area, 50, Some(25), 0), None, "above the viewport"); | |
| 597 | - | assert_eq!(list_row_y(area, 50, Some(25), 49), None, "below the viewport"); | |
| 598 | - | assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list"); | |
| 603 | + | assert_eq!( | |
| 604 | + | list_row_y(area, 50, Some(25), 0), | |
| 605 | + | None, | |
| 606 | + | "above the viewport" | |
| 607 | + | ); | |
| 608 | + | assert_eq!( | |
| 609 | + | list_row_y(area, 50, Some(25), 49), | |
| 610 | + | None, | |
| 611 | + | "below the viewport" | |
| 612 | + | ); | |
| 613 | + | assert_eq!( | |
| 614 | + | list_row_y(area, 3, Some(0), 9), | |
| 615 | + | None, | |
| 616 | + | "past the end of the list" | |
| 617 | + | ); | |
| 599 | 618 | } | |
| 600 | 619 | ||
| 601 | 620 | fn render_tabs(selected: usize, width: u16) -> String { | |
| @@ -610,7 +629,10 @@ mod tests { | |||
| 610 | 629 | #[test] | |
| 611 | 630 | fn selected_tab_is_bracketed_and_others_are_not() { | |
| 612 | 631 | let rendered = render_tabs(0, 60); | |
| 613 | - | assert!(rendered.contains("[ installed ]"), "selected tab is bracketed"); | |
| 632 | + | assert!( | |
| 633 | + | rendered.contains("[ installed ]"), | |
| 634 | + | "selected tab is bracketed" | |
| 635 | + | ); | |
| 614 | 636 | assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); | |
| 615 | 637 | assert!(rendered.contains("boxes"), "unselected labels still render"); | |
| 616 | 638 | } | |
| @@ -710,7 +732,13 @@ mod tests { | |||
| 710 | 732 | .iter() | |
| 711 | 733 | .map(|cell| cell.symbol()) | |
| 712 | 734 | .collect::<String>(); | |
| 713 | - | assert!(rendered.contains("nmcli run 9"), "newest entry must be visible"); | |
| 714 | - | assert!(!rendered.contains("nmcli run 0"), "oldest entry must have scrolled off"); | |
| 735 | + | assert!( | |
| 736 | + | rendered.contains("nmcli run 9"), | |
| 737 | + | "newest entry must be visible" | |
| 738 | + | ); | |
| 739 | + | assert!( | |
| 740 | + | !rendered.contains("nmcli run 0"), | |
| 741 | + | "oldest entry must have scrolled off" | |
| 742 | + | ); | |
| 715 | 743 | } | |
| 716 | 744 | } |