| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
use ratatui::buffer::Buffer; |
| 11 |
use ratatui::layout::Rect; |
| 12 |
use ratatui::style::{Color, Style}; |
| 13 |
use ratatui::text::{Line, Span}; |
| 14 |
use ratatui::widgets::{Block, Borders, Paragraph, Widget}; |
| 15 |
|
| 16 |
use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style}; |
| 17 |
use crate::text; |
| 18 |
use crate::theme::Theme; |
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
pub struct AlloyBlock<'a> { |
| 30 |
theme: &'a Theme, |
| 31 |
focused: bool, |
| 32 |
} |
| 33 |
|
| 34 |
impl<'a> AlloyBlock<'a> { |
| 35 |
pub fn new(theme: &'a Theme) -> Self { |
| 36 |
Self { theme, focused: false } |
| 37 |
} |
| 38 |
|
| 39 |
pub fn focused(mut self, focused: bool) -> Self { |
| 40 |
self.focused = focused; |
| 41 |
self |
| 42 |
} |
| 43 |
|
| 44 |
pub fn build(self) -> Block<'a> { |
| 45 |
let border_color = if self.focused { |
| 46 |
self.theme.border_strong |
| 47 |
} else { |
| 48 |
self.theme.border_subtle |
| 49 |
}; |
| 50 |
Block::default() |
| 51 |
.borders(Borders::ALL) |
| 52 |
.border_style(Style::default().fg(border_color)) |
| 53 |
.style( |
| 54 |
Style::default() |
| 55 |
.bg(self.theme.surface_page) |
| 56 |
.fg(self.theme.content_primary), |
| 57 |
) |
| 58 |
} |
| 59 |
} |
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 65 |
pub enum Severity { |
| 66 |
Info, |
| 67 |
Healthy, |
| 68 |
Warn, |
| 69 |
Error, |
| 70 |
} |
| 71 |
|
| 72 |
impl Severity { |
| 73 |
pub const fn label(self) -> &'static str { |
| 74 |
match self { |
| 75 |
Severity::Info => "INFO", |
| 76 |
Severity::Healthy => "OK", |
| 77 |
Severity::Warn => "WARN", |
| 78 |
Severity::Error => "ERROR", |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
pub fn color(self, theme: &Theme) -> Color { |
| 83 |
match self { |
| 84 |
Severity::Info => theme.status_info, |
| 85 |
Severity::Healthy => theme.status_success, |
| 86 |
Severity::Warn => theme.status_warning, |
| 87 |
Severity::Error => theme.status_danger, |
| 88 |
} |
| 89 |
} |
| 90 |
|
| 91 |
pub fn style(self, theme: &Theme) -> Style { |
| 92 |
Style::default().fg(self.color(theme)) |
| 93 |
} |
| 94 |
} |
| 95 |
|
| 96 |
|
| 97 |
pub struct Hint { |
| 98 |
pub key: &'static str, |
| 99 |
pub label: &'static str, |
| 100 |
} |
| 101 |
|
| 102 |
|
| 103 |
|
| 104 |
pub const fn hint(key: &'static str, label: &'static str) -> Hint { |
| 105 |
Hint { key, label } |
| 106 |
} |
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
|
| 115 |
pub struct AlloyStatusBar<'a> { |
| 116 |
theme: &'a Theme, |
| 117 |
hints: Vec<Hint>, |
| 118 |
status: Option<(Severity, String)>, |
| 119 |
} |
| 120 |
|
| 121 |
impl<'a> AlloyStatusBar<'a> { |
| 122 |
pub fn new(theme: &'a Theme, hints: impl IntoIterator<Item = Hint>) -> Self { |
| 123 |
Self { |
| 124 |
theme, |
| 125 |
hints: hints.into_iter().collect(), |
| 126 |
status: None, |
| 127 |
} |
| 128 |
} |
| 129 |
|
| 130 |
|
| 131 |
pub fn status(mut self, severity: Severity, message: impl Into<String>) -> Self { |
| 132 |
self.status = Some((severity, message.into())); |
| 133 |
self |
| 134 |
} |
| 135 |
} |
| 136 |
|
| 137 |
impl Widget for AlloyStatusBar<'_> { |
| 138 |
fn render(self, area: Rect, buf: &mut Buffer) { |
| 139 |
let base = Style::default().bg(self.theme.surface_sunken); |
| 140 |
Paragraph::new("").style(base).render(area, buf); |
| 141 |
|
| 142 |
let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3); |
| 143 |
for (i, h) in self.hints.iter().enumerate() { |
| 144 |
if i > 0 { |
| 145 |
spans.push(Span::raw(" ")); |
| 146 |
} |
| 147 |
spans.push(text::action(self.theme, h.key)); |
| 148 |
spans.push(Span::raw(" ")); |
| 149 |
spans.push(text::muted(self.theme, h.label)); |
| 150 |
} |
| 151 |
Paragraph::new(Line::from(spans)) |
| 152 |
.style(base) |
| 153 |
.render(area, buf); |
| 154 |
|
| 155 |
|
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
if let Some((severity, message)) = self.status { |
| 160 |
let text_width = message.chars().count() as u16 + 1; |
| 161 |
let width = text_width.min(area.width); |
| 162 |
let slot = Rect { |
| 163 |
x: area.x + area.width - width, |
| 164 |
width, |
| 165 |
..area |
| 166 |
}; |
| 167 |
Paragraph::new(Line::from(Span::styled( |
| 168 |
message, |
| 169 |
severity.style(self.theme).patch(base), |
| 170 |
))) |
| 171 |
.style(base) |
| 172 |
.right_aligned() |
| 173 |
.render(slot, buf); |
| 174 |
} |
| 175 |
} |
| 176 |
} |
| 177 |
|
| 178 |
|
| 179 |
|
| 180 |
|
| 181 |
|
| 182 |
|
| 183 |
pub struct AlloyList<'a> { |
| 184 |
theme: &'a Theme, |
| 185 |
items: Vec<Line<'a>>, |
| 186 |
selected: Option<usize>, |
| 187 |
} |
| 188 |
|
| 189 |
impl<'a> AlloyList<'a> { |
| 190 |
pub fn new(theme: &'a Theme, items: impl IntoIterator<Item = Line<'a>>) -> Self { |
| 191 |
Self { |
| 192 |
theme, |
| 193 |
items: items.into_iter().collect(), |
| 194 |
selected: None, |
| 195 |
} |
| 196 |
} |
| 197 |
|
| 198 |
pub fn selected(mut self, selected: Option<usize>) -> Self { |
| 199 |
self.selected = selected; |
| 200 |
self |
| 201 |
} |
| 202 |
|
| 203 |
|
| 204 |
fn offset(&self, height: usize) -> usize { |
| 205 |
list_offset(self.items.len(), height, self.selected) |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
|
| 216 |
|
| 217 |
|
| 218 |
|
| 219 |
|
| 220 |
|
| 221 |
|
| 222 |
pub fn list_offset(len: usize, height: usize, selected: Option<usize>) -> usize { |
| 223 |
let (Some(selected), true) = (selected, len > height) else { |
| 224 |
return 0; |
| 225 |
}; |
| 226 |
let max_offset = len - height; |
| 227 |
selected.saturating_sub(height / 2).min(max_offset) |
| 228 |
} |
| 229 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
pub fn list_row_y(area: Rect, len: usize, selected: Option<usize>, index: usize) -> Option<u16> { |
| 235 |
if area.height == 0 || index >= len { |
| 236 |
return None; |
| 237 |
} |
| 238 |
let offset = list_offset(len, area.height as usize, selected); |
| 239 |
let row = index.checked_sub(offset)?; |
| 240 |
if row >= area.height as usize { |
| 241 |
return None; |
| 242 |
} |
| 243 |
Some(area.y + row as u16) |
| 244 |
} |
| 245 |
|
| 246 |
impl Widget for AlloyList<'_> { |
| 247 |
fn render(self, area: Rect, buf: &mut Buffer) { |
| 248 |
if area.height == 0 || area.width == 0 { |
| 249 |
return; |
| 250 |
} |
| 251 |
|
| 252 |
let height = area.height as usize; |
| 253 |
let offset = self.offset(height); |
| 254 |
|
| 255 |
for (row, (index, item)) in self |
| 256 |
.items |
| 257 |
.iter() |
| 258 |
.enumerate() |
| 259 |
.skip(offset) |
| 260 |
.take(height) |
| 261 |
.enumerate() |
| 262 |
{ |
| 263 |
let is_selected = self.selected == Some(index); |
| 264 |
let style = if is_selected { |
| 265 |
selected_style(self.theme) |
| 266 |
} else { |
| 267 |
unselected_style(self.theme) |
| 268 |
}; |
| 269 |
let marker = if is_selected { MARKER } else { MARKER_BLANK }; |
| 270 |
|
| 271 |
let mut spans = vec![Span::styled(format!("{marker} "), style)]; |
| 272 |
spans.extend(item.spans.iter().cloned()); |
| 273 |
|
| 274 |
let line_area = Rect { |
| 275 |
y: area.y + row as u16, |
| 276 |
height: 1, |
| 277 |
..area |
| 278 |
}; |
| 279 |
Paragraph::new(Line::from(spans)) |
| 280 |
.style(style) |
| 281 |
.render(line_area, buf); |
| 282 |
} |
| 283 |
} |
| 284 |
} |
| 285 |
|
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
|
| 295 |
|
| 296 |
|
| 297 |
|
| 298 |
|
| 299 |
pub struct AlloyTabs<'a> { |
| 300 |
theme: &'a Theme, |
| 301 |
labels: Vec<String>, |
| 302 |
selected: usize, |
| 303 |
} |
| 304 |
|
| 305 |
impl<'a> AlloyTabs<'a> { |
| 306 |
pub fn new(theme: &'a Theme, labels: impl IntoIterator<Item = impl Into<String>>) -> Self { |
| 307 |
Self { |
| 308 |
theme, |
| 309 |
labels: labels.into_iter().map(Into::into).collect(), |
| 310 |
selected: 0, |
| 311 |
} |
| 312 |
} |
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
|
| 317 |
pub fn selected(mut self, selected: usize) -> Self { |
| 318 |
self.selected = selected; |
| 319 |
self |
| 320 |
} |
| 321 |
} |
| 322 |
|
| 323 |
|
| 324 |
const TAB_GAP: &str = " "; |
| 325 |
|
| 326 |
impl Widget for AlloyTabs<'_> { |
| 327 |
fn render(self, area: Rect, buf: &mut Buffer) { |
| 328 |
if area.height == 0 || area.width == 0 { |
| 329 |
return; |
| 330 |
} |
| 331 |
|
| 332 |
let mut spans: Vec<Span> = Vec::with_capacity(self.labels.len() * 2); |
| 333 |
for (i, label) in self.labels.iter().enumerate() { |
| 334 |
if i > 0 { |
| 335 |
spans.push(Span::raw(TAB_GAP)); |
| 336 |
} |
| 337 |
|
| 338 |
|
| 339 |
|
| 340 |
|
| 341 |
let (open, close, style) = if i == self.selected { |
| 342 |
("[ ", " ]", selected_style(self.theme)) |
| 343 |
} else { |
| 344 |
(" ", " ", unselected_style(self.theme)) |
| 345 |
}; |
| 346 |
spans.push(Span::styled(format!("{open}{label}{close}"), style)); |
| 347 |
} |
| 348 |
|
| 349 |
let row = Rect { height: 1, ..area }; |
| 350 |
Paragraph::new(Line::from(spans)) |
| 351 |
.style(Style::default().bg(self.theme.surface_page)) |
| 352 |
.render(row, buf); |
| 353 |
} |
| 354 |
} |
| 355 |
|
| 356 |
|
| 357 |
|
| 358 |
|
| 359 |
|
| 360 |
|
| 361 |
|
| 362 |
|
| 363 |
|
| 364 |
|
| 365 |
|
| 366 |
pub struct AlloyModal<'a> { |
| 367 |
theme: &'a Theme, |
| 368 |
title: &'a str, |
| 369 |
message: &'a str, |
| 370 |
severity: Severity, |
| 371 |
} |
| 372 |
|
| 373 |
impl<'a> AlloyModal<'a> { |
| 374 |
pub fn new(theme: &'a Theme, title: &'a str, message: &'a str) -> Self { |
| 375 |
Self { |
| 376 |
theme, |
| 377 |
title, |
| 378 |
message, |
| 379 |
severity: Severity::Warn, |
| 380 |
} |
| 381 |
} |
| 382 |
|
| 383 |
pub fn severity(mut self, severity: Severity) -> Self { |
| 384 |
self.severity = severity; |
| 385 |
self |
| 386 |
} |
| 387 |
} |
| 388 |
|
| 389 |
impl Widget for AlloyModal<'_> { |
| 390 |
fn render(self, area: Rect, buf: &mut Buffer) { |
| 391 |
if area.height == 0 || area.width == 0 { |
| 392 |
return; |
| 393 |
} |
| 394 |
|
| 395 |
let base = Style::default() |
| 396 |
.bg(self.theme.surface_overlay) |
| 397 |
.fg(self.theme.content_primary); |
| 398 |
|
| 399 |
let block = Block::default() |
| 400 |
.borders(Borders::ALL) |
| 401 |
.border_style(Style::default().fg(self.theme.border_strong)) |
| 402 |
.style(base) |
| 403 |
.title(format!(" {} ", self.title)); |
| 404 |
let inner = block.inner(area); |
| 405 |
block.render(area, buf); |
| 406 |
|
| 407 |
if inner.height == 0 { |
| 408 |
return; |
| 409 |
} |
| 410 |
|
| 411 |
|
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
let keys = Line::from(vec![ |
| 416 |
text::action(self.theme, "Enter"), |
| 417 |
Span::styled(" confirm", Style::default().fg(self.theme.content_muted)), |
| 418 |
Span::raw(" "), |
| 419 |
text::action(self.theme, "Esc"), |
| 420 |
Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), |
| 421 |
]); |
| 422 |
|
| 423 |
let message_height = inner.height.saturating_sub(1); |
| 424 |
if message_height > 0 { |
| 425 |
Paragraph::new(Line::from(Span::styled( |
| 426 |
self.message, |
| 427 |
self.severity.style(self.theme).patch(base), |
| 428 |
))) |
| 429 |
.style(base) |
| 430 |
.wrap(ratatui::widgets::Wrap { trim: true }) |
| 431 |
.render(Rect { height: message_height, ..inner }, buf); |
| 432 |
} |
| 433 |
|
| 434 |
Paragraph::new(keys) |
| 435 |
.style(base) |
| 436 |
.render( |
| 437 |
Rect { |
| 438 |
y: inner.y + inner.height - 1, |
| 439 |
height: 1, |
| 440 |
..inner |
| 441 |
}, |
| 442 |
buf, |
| 443 |
); |
| 444 |
} |
| 445 |
} |
| 446 |
|
| 447 |
|
| 448 |
|
| 449 |
|
| 450 |
|
| 451 |
|
| 452 |
|
| 453 |
#[derive(Debug, Clone)] |
| 454 |
pub struct LogEntry { |
| 455 |
pub command: String, |
| 456 |
pub outcome: Severity, |
| 457 |
} |
| 458 |
|
| 459 |
impl LogEntry { |
| 460 |
pub fn new(command: impl Into<String>, outcome: Severity) -> Self { |
| 461 |
Self { |
| 462 |
command: command.into(), |
| 463 |
outcome, |
| 464 |
} |
| 465 |
} |
| 466 |
} |
| 467 |
|
| 468 |
|
| 469 |
|
| 470 |
|
| 471 |
|
| 472 |
|
| 473 |
pub struct AlloyLog<'a> { |
| 474 |
theme: &'a Theme, |
| 475 |
entries: &'a [LogEntry], |
| 476 |
} |
| 477 |
|
| 478 |
impl<'a> AlloyLog<'a> { |
| 479 |
pub fn new(theme: &'a Theme, entries: &'a [LogEntry]) -> Self { |
| 480 |
Self { theme, entries } |
| 481 |
} |
| 482 |
} |
| 483 |
|
| 484 |
impl Widget for AlloyLog<'_> { |
| 485 |
fn render(self, area: Rect, buf: &mut Buffer) { |
| 486 |
if area.height == 0 || area.width == 0 { |
| 487 |
return; |
| 488 |
} |
| 489 |
|
| 490 |
let block = AlloyBlock::new(self.theme).build().title(" commands "); |
| 491 |
let inner = block.inner(area); |
| 492 |
block.render(area, buf); |
| 493 |
|
| 494 |
if inner.height == 0 { |
| 495 |
return; |
| 496 |
} |
| 497 |
|
| 498 |
let visible = inner.height as usize; |
| 499 |
let tail = self.entries.len().saturating_sub(visible); |
| 500 |
let lines: Vec<Line> = self.entries[tail..] |
| 501 |
.iter() |
| 502 |
.map(|entry| { |
| 503 |
Line::from(vec![ |
| 504 |
Span::styled("$ ", entry.outcome.style(self.theme)), |
| 505 |
text::secondary(self.theme, entry.command.clone()), |
| 506 |
]) |
| 507 |
}) |
| 508 |
.collect(); |
| 509 |
|
| 510 |
Paragraph::new(lines) |
| 511 |
.style(Style::default().bg(self.theme.surface_page)) |
| 512 |
.render(inner, buf); |
| 513 |
} |
| 514 |
} |
| 515 |
|
| 516 |
#[cfg(test)] |
| 517 |
mod tests { |
| 518 |
use super::*; |
| 519 |
use ratatui::style::Color; |
| 520 |
|
| 521 |
fn theme() -> Theme { |
| 522 |
Theme { |
| 523 |
mode: crate::theme::Mode::Dark, |
| 524 |
surface_page: Color::Rgb(0, 0, 0), |
| 525 |
surface_raised: Color::Rgb(1, 1, 1), |
| 526 |
surface_sunken: Color::Rgb(2, 2, 2), |
| 527 |
surface_overlay: Color::Rgb(3, 3, 3), |
| 528 |
content_primary: Color::Rgb(4, 4, 4), |
| 529 |
content_secondary: Color::Rgb(5, 5, 5), |
| 530 |
content_muted: Color::Rgb(6, 6, 6), |
| 531 |
action_primary: Color::Rgb(7, 7, 7), |
| 532 |
status_danger: Color::Rgb(8, 8, 8), |
| 533 |
status_success: Color::Rgb(9, 9, 9), |
| 534 |
status_warning: Color::Rgb(10, 10, 10), |
| 535 |
status_info: Color::Rgb(11, 11, 11), |
| 536 |
line_border: Color::Rgb(12, 12, 12), |
| 537 |
border_subtle: Color::Rgb(13, 13, 13), |
| 538 |
border_strong: Color::Rgb(14, 14, 14), |
| 539 |
category: [Color::Rgb(15, 15, 15); 6], |
| 540 |
} |
| 541 |
} |
| 542 |
|
| 543 |
fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> { |
| 544 |
|
| 545 |
|
| 546 |
let theme: &'static Theme = Box::leak(Box::new(theme())); |
| 547 |
let items: Vec<Line<'static>> = (0..n).map(|i| Line::from(format!("row {i}"))).collect(); |
| 548 |
AlloyList::new(theme, items).selected(selected) |
| 549 |
} |
| 550 |
|
| 551 |
#[test] |
| 552 |
fn short_list_never_scrolls() { |
| 553 |
assert_eq!(list_of(3, Some(2)).offset(10), 0); |
| 554 |
} |
| 555 |
|
| 556 |
|
| 557 |
|
| 558 |
#[test] |
| 559 |
fn offset_clamps_at_the_top() { |
| 560 |
assert_eq!(list_of(50, Some(0)).offset(10), 0); |
| 561 |
assert_eq!(list_of(50, Some(2)).offset(10), 0); |
| 562 |
} |
| 563 |
|
| 564 |
|
| 565 |
|
| 566 |
#[test] |
| 567 |
fn offset_clamps_at_the_bottom() { |
| 568 |
assert_eq!(list_of(50, Some(49)).offset(10), 40); |
| 569 |
} |
| 570 |
|
| 571 |
#[test] |
| 572 |
fn offset_centers_a_midlist_selection() { |
| 573 |
assert_eq!(list_of(50, Some(25)).offset(10), 20); |
| 574 |
} |
| 575 |
|
| 576 |
#[test] |
| 577 |
fn row_y_maps_visible_items_to_screen_rows() { |
| 578 |
let area = Rect::new(0, 5, 20, 10); |
| 579 |
assert_eq!(list_row_y(area, 3, Some(0), 0), Some(5)); |
| 580 |
assert_eq!(list_row_y(area, 3, Some(0), 2), Some(7)); |
| 581 |
} |
| 582 |
|
| 583 |
|
| 584 |
|
| 585 |
#[test] |
| 586 |
fn row_y_accounts_for_scrolling() { |
| 587 |
let area = Rect::new(0, 0, 20, 10); |
| 588 |
|
| 589 |
assert_eq!(list_row_y(area, 50, Some(25), 20), Some(0)); |
| 590 |
assert_eq!(list_row_y(area, 50, Some(25), 25), Some(5)); |
| 591 |
} |
| 592 |
|
| 593 |
#[test] |
| 594 |
fn row_y_is_none_for_rows_scrolled_out_of_view() { |
| 595 |
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"); |
| 599 |
} |
| 600 |
|
| 601 |
fn render_tabs(selected: usize, width: u16) -> String { |
| 602 |
let theme = theme(); |
| 603 |
let mut buf = Buffer::empty(Rect::new(0, 0, width, 1)); |
| 604 |
AlloyTabs::new(&theme, ["installed", "boxes", "system"]) |
| 605 |
.selected(selected) |
| 606 |
.render(Rect::new(0, 0, width, 1), &mut buf); |
| 607 |
buf.content().iter().map(|cell| cell.symbol()).collect() |
| 608 |
} |
| 609 |
|
| 610 |
#[test] |
| 611 |
fn selected_tab_is_bracketed_and_others_are_not() { |
| 612 |
let rendered = render_tabs(0, 60); |
| 613 |
assert!(rendered.contains("[ installed ]"), "selected tab is bracketed"); |
| 614 |
assert!(!rendered.contains("[ boxes ]"), "unselected tabs are not"); |
| 615 |
assert!(rendered.contains("boxes"), "unselected labels still render"); |
| 616 |
} |
| 617 |
|
| 618 |
|
| 619 |
|
| 620 |
|
| 621 |
#[test] |
| 622 |
fn labels_hold_their_columns_across_selections() { |
| 623 |
let first = render_tabs(0, 60); |
| 624 |
let last = render_tabs(2, 60); |
| 625 |
assert_eq!( |
| 626 |
first.find("system"), |
| 627 |
last.find("system"), |
| 628 |
"a label sits in the same columns whichever tab is selected" |
| 629 |
); |
| 630 |
} |
| 631 |
|
| 632 |
|
| 633 |
|
| 634 |
|
| 635 |
#[test] |
| 636 |
fn out_of_range_selection_brackets_nothing() { |
| 637 |
let rendered = render_tabs(9, 60); |
| 638 |
assert!(!rendered.contains('['), "no tab is marked current"); |
| 639 |
assert!(rendered.contains("installed"), "labels still render"); |
| 640 |
} |
| 641 |
|
| 642 |
#[test] |
| 643 |
fn zero_height_area_renders_nothing_rather_than_panicking() { |
| 644 |
let theme = theme(); |
| 645 |
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 1)); |
| 646 |
AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 40, 0), &mut buf); |
| 647 |
AlloyTabs::new(&theme, ["installed"]).render(Rect::new(0, 0, 0, 1), &mut buf); |
| 648 |
} |
| 649 |
|
| 650 |
fn render_modal(area: Rect) -> Vec<String> { |
| 651 |
let theme = theme(); |
| 652 |
let mut buf = Buffer::empty(area); |
| 653 |
AlloyModal::new(&theme, "remove", "Remove tailscale?").render(area, &mut buf); |
| 654 |
(0..area.height) |
| 655 |
.map(|y| { |
| 656 |
(0..area.width) |
| 657 |
.map(|x| buf[(x, y)].symbol()) |
| 658 |
.collect::<String>() |
| 659 |
}) |
| 660 |
.collect() |
| 661 |
} |
| 662 |
|
| 663 |
#[test] |
| 664 |
fn modal_shows_its_message_and_both_keys() { |
| 665 |
let rows = render_modal(Rect::new(0, 0, 40, 7)).join("\n"); |
| 666 |
assert!(rows.contains("Remove tailscale?"), "message renders"); |
| 667 |
assert!(rows.contains("remove"), "title renders"); |
| 668 |
assert!(rows.contains("Enter"), "confirm key renders"); |
| 669 |
assert!(rows.contains("Esc"), "cancel key renders"); |
| 670 |
} |
| 671 |
|
| 672 |
|
| 673 |
|
| 674 |
|
| 675 |
#[test] |
| 676 |
fn keys_sit_on_the_last_row_whatever_the_message_length() { |
| 677 |
for height in [5, 7, 12] { |
| 678 |
let rows = render_modal(Rect::new(0, 0, 40, height)); |
| 679 |
let last_inner = &rows[height as usize - 2]; |
| 680 |
assert!( |
| 681 |
last_inner.contains("Enter") && last_inner.contains("Esc"), |
| 682 |
"height {height}: keys belong on the last inner row, got {last_inner:?}" |
| 683 |
); |
| 684 |
} |
| 685 |
} |
| 686 |
|
| 687 |
#[test] |
| 688 |
fn modal_survives_an_area_too_small_to_draw_in() { |
| 689 |
let theme = theme(); |
| 690 |
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 7)); |
| 691 |
AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 40, 0), &mut buf); |
| 692 |
AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 0, 7), &mut buf); |
| 693 |
AlloyModal::new(&theme, "t", "m").render(Rect::new(0, 0, 2, 2), &mut buf); |
| 694 |
} |
| 695 |
|
| 696 |
|
| 697 |
|
| 698 |
|
| 699 |
#[test] |
| 700 |
fn log_renders_the_newest_entries() { |
| 701 |
let theme = theme(); |
| 702 |
let entries: Vec<LogEntry> = (0..10) |
| 703 |
.map(|i| LogEntry::new(format!("nmcli run {i}"), Severity::Healthy)) |
| 704 |
.collect(); |
| 705 |
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4)); |
| 706 |
AlloyLog::new(&theme, &entries).render(Rect::new(0, 0, 40, 4), &mut buf); |
| 707 |
|
| 708 |
let rendered = buf |
| 709 |
.content() |
| 710 |
.iter() |
| 711 |
.map(|cell| cell.symbol()) |
| 712 |
.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"); |
| 715 |
} |
| 716 |
} |
| 717 |
|