| 283 |
283 |
|
}
|
| 284 |
284 |
|
}
|
| 285 |
285 |
|
|
|
286 |
+ |
/// A one-row tab bar.
|
|
287 |
+ |
///
|
|
288 |
+ |
/// Holds no state: the selected index comes from the caller's
|
|
289 |
+ |
/// [`FocusRing`](crate::FocusRing), which is already a wrapping cursor over N
|
|
290 |
+ |
/// slots with the `focus(slot)` a verb needs to open the view on a given tab.
|
|
291 |
+ |
/// Same split as [`AlloyList`] and [`Cursor`](crate::Cursor) — widget shared,
|
|
292 |
+ |
/// state owned by the view.
|
|
293 |
+ |
///
|
|
294 |
+ |
/// Selection reads as brackets plus weight rather than color. Per
|
|
295 |
+ |
/// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as
|
|
296 |
+ |
/// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a
|
|
297 |
+ |
/// console with no theme and no patched font — the TTY before the session
|
|
298 |
+ |
/// starts, `alloy` over SSH.
|
|
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 |
+ |
/// Select a tab. Out-of-range indices select nothing, matching
|
|
315 |
+ |
/// [`FocusRing::focus`](crate::FocusRing::focus): landing on a neighbouring
|
|
316 |
+ |
/// tab is worse than showing none as current.
|
|
317 |
+ |
pub fn selected(mut self, selected: usize) -> Self {
|
|
318 |
+ |
self.selected = selected;
|
|
319 |
+ |
self
|
|
320 |
+ |
}
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
/// Gap between tabs. Wide enough that two short labels do not read as one.
|
|
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 |
+ |
// Unselected labels carry spaces where the selected one carries
|
|
338 |
+ |
// brackets, so a label occupies the same cells either way and the
|
|
339 |
+ |
// bar does not shift horizontally as selection moves. Same reason
|
|
340 |
+ |
// MARKER_BLANK exists for list rows.
|
|
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 |
+ |
/// A centered confirmation modal, drawn over the view that raised it.
|
|
357 |
+ |
///
|
|
358 |
+ |
/// Confirmation is design-system chrome rather than per-view furniture: every
|
|
359 |
+ |
/// destructive action in every Alloy TUI asks the same way, with the same
|
|
360 |
+ |
/// keys. That is the reason this lives here and the shell owns the state,
|
|
361 |
+ |
/// instead of each view drawing its own prompt.
|
|
362 |
+ |
///
|
|
363 |
+ |
/// Sits on `surface.overlay`, the one theme surface reserved for content
|
|
364 |
+ |
/// floating above the page, and borrows `Severity` for the accent so a
|
|
365 |
+ |
/// destructive confirm reads red and a benign one does not.
|
|
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 |
+ |
// Message on top, keys on the last row. The keys are pinned to the
|
|
412 |
+ |
// bottom rather than following the message so their position does not
|
|
413 |
+ |
// move with message length: a confirm the user cannot dismiss is the
|
|
414 |
+ |
// one failure this widget must not have.
|
|
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 |
+ |
|
| 286 |
447 |
|
/// One line of the command log: the CLI invocation that was run, and how it
|
| 287 |
448 |
|
/// went.
|
| 288 |
449 |
|
///
|
| 437 |
598 |
|
assert_eq!(list_row_y(area, 3, Some(0), 9), None, "past the end of the list");
|
| 438 |
599 |
|
}
|
| 439 |
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 |
+ |
// The bar must not shift horizontally as selection moves, or every tab
|
|
619 |
+ |
// change reads as the whole row twitching. Unselected labels pad to the
|
|
620 |
+ |
// bracket width for exactly this reason.
|
|
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 |
+ |
// FocusRing::focus ignores out-of-range slots rather than clamping, and the
|
|
633 |
+ |
// bar has to agree: showing a neighbouring tab as current would misreport
|
|
634 |
+ |
// which screen the user is looking at.
|
|
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 |
+ |
// The keys are pinned to the last inner row rather than flowing after the
|
|
673 |
+ |
// message. A prompt whose dismiss keys move with message length, or fall
|
|
674 |
+ |
// off a short box, is a modal the user cannot get out of.
|
|
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 |
+ |
|
| 440 |
696 |
|
// A log longer than its pane shows the newest entries. Showing the head
|
| 441 |
697 |
|
// instead would freeze the pane on startup noise and never display the
|
| 442 |
698 |
|
// command the user just triggered.
|