| 2 |
2 |
|
//!
|
| 3 |
3 |
|
//! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`,
|
| 4 |
4 |
|
//! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven
|
| 5 |
|
- |
//! by the config schema. This module carries the four the console shell needs
|
| 6 |
|
- |
//! to render a screen end to end — block, list, log pane, status bar — plus
|
| 7 |
|
- |
//! the `Severity` accent they all compose with. `AlloyForm`, `AlloyTable`, and
|
| 8 |
|
- |
//! the schema-driven fields land with `alloy config`.
|
|
5 |
+ |
//! by the config schema. The four the console shell needs to render a screen
|
|
6 |
+ |
//! end to end — block, list, log pane, status bar — plus the `Severity` accent
|
|
7 |
+ |
//! they all compose with, and, as of 1.2, the form: `AlloyForm`, a single
|
|
8 |
+ |
//! `AlloyField` carrying a `FieldKind` value cell, and a display-only
|
|
9 |
+ |
//! `AlloyTable`.
|
| 9 |
10 |
|
|
| 10 |
11 |
|
use ratatui::buffer::Buffer;
|
| 11 |
12 |
|
use ratatui::layout::Rect;
|
| 13 |
14 |
|
use ratatui::text::{Line, Span};
|
| 14 |
15 |
|
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
|
| 15 |
16 |
|
|
|
17 |
+ |
use crate::input::TextField;
|
| 16 |
18 |
|
use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
|
| 17 |
19 |
|
use crate::text;
|
| 18 |
20 |
|
use crate::theme::Theme;
|
| 525 |
527 |
|
}
|
| 526 |
528 |
|
}
|
| 527 |
529 |
|
|
|
530 |
+ |
/// The value half of a form row: what the value cell paints, and nothing else.
|
|
531 |
+ |
///
|
|
532 |
+ |
/// One enum rather than five widgets. Which kind a row is comes off a schema
|
|
533 |
+ |
/// file read at startup, so it is runtime data and five static types would buy
|
|
534 |
+ |
/// no safety at the one call site that builds rows. See
|
|
535 |
+ |
/// docs/COMPONENT-LIBRARY.md in the Alloy repo for the full rationale.
|
|
536 |
+ |
///
|
|
537 |
+ |
/// Every variant that carries text takes it pre-formatted: rounding a float,
|
|
538 |
+ |
/// picking which of an enum's labels is current, and deciding how a path is
|
|
539 |
+ |
/// abbreviated are all the caller's business, and a widget that formatted
|
|
540 |
+ |
/// values would need to know the schema.
|
|
541 |
+ |
pub enum FieldKind<'a> {
|
|
542 |
+ |
Toggle(bool),
|
|
543 |
+ |
Text(&'a str),
|
|
544 |
+ |
Number(&'a str),
|
|
545 |
+ |
/// The current selection's label, not its raw value.
|
|
546 |
+ |
Enum {
|
|
547 |
+ |
label: &'a str,
|
|
548 |
+ |
},
|
|
549 |
+ |
/// Draws a swatch alongside the text. The one place a widget here paints
|
|
550 |
+ |
/// an arbitrary color: DESIGN-LANGUAGE.md keeps color off chrome, and this
|
|
551 |
+ |
/// is not chrome, it is the value.
|
|
552 |
+ |
Color {
|
|
553 |
+ |
hex: &'a str,
|
|
554 |
+ |
},
|
|
555 |
+ |
}
|
|
556 |
+ |
|
|
557 |
+ |
/// A single form row: a label, a value cell, and the chrome around focus.
|
|
558 |
+ |
///
|
|
559 |
+ |
/// Pure render, like every widget here. It owns no value, does no validation,
|
|
560 |
+ |
/// and does not know what a schema is; the console binary holds the document,
|
|
561 |
+ |
/// the constraints, and the edit buffer, and rebuilds these each frame.
|
|
562 |
+ |
pub struct AlloyField<'a> {
|
|
563 |
+ |
theme: &'a Theme,
|
|
564 |
+ |
label: &'a str,
|
|
565 |
+ |
kind: FieldKind<'a>,
|
|
566 |
+ |
focused: bool,
|
|
567 |
+ |
indent: bool,
|
|
568 |
+ |
unset: bool,
|
|
569 |
+ |
label_width: usize,
|
|
570 |
+ |
edit: Option<&'a TextField>,
|
|
571 |
+ |
diagnostic: Option<(Severity, &'a str)>,
|
|
572 |
+ |
help: Option<&'a str>,
|
|
573 |
+ |
}
|
|
574 |
+ |
|
|
575 |
+ |
impl<'a> AlloyField<'a> {
|
|
576 |
+ |
pub fn new(theme: &'a Theme, label: &'a str, kind: FieldKind<'a>) -> Self {
|
|
577 |
+ |
Self {
|
|
578 |
+ |
theme,
|
|
579 |
+ |
label,
|
|
580 |
+ |
kind,
|
|
581 |
+ |
focused: false,
|
|
582 |
+ |
indent: false,
|
|
583 |
+ |
unset: false,
|
|
584 |
+ |
label_width: 0,
|
|
585 |
+ |
edit: None,
|
|
586 |
+ |
diagnostic: None,
|
|
587 |
+ |
help: None,
|
|
588 |
+ |
}
|
|
589 |
+ |
}
|
|
590 |
+ |
|
|
591 |
+ |
#[must_use]
|
|
592 |
+ |
pub fn focused(mut self, focused: bool) -> Self {
|
|
593 |
+ |
self.focused = focused;
|
|
594 |
+ |
self
|
|
595 |
+ |
}
|
|
596 |
+ |
|
|
597 |
+ |
/// Sit the row under a section header. [`AlloyForm`] sets this; a field
|
|
598 |
+ |
/// rendered on its own is not inside anything.
|
|
599 |
+ |
#[must_use]
|
|
600 |
+ |
pub fn indent(mut self, indent: bool) -> Self {
|
|
601 |
+ |
self.indent = indent;
|
|
602 |
+ |
self
|
|
603 |
+ |
}
|
|
604 |
+ |
|
|
605 |
+ |
/// Mark the value as the schema's default rather than something the source
|
|
606 |
+ |
/// of truth holds, and paint it muted.
|
|
607 |
+ |
///
|
|
608 |
+ |
/// Without this a form cannot show the difference between a key set to 12
|
|
609 |
+ |
/// and a key absent from a file that defaults to 12. Keeping the two
|
|
610 |
+ |
/// distinguishable is the whole point of the console reading and
|
|
611 |
+ |
/// defaulting through separate calls, and this is where that reaches the
|
|
612 |
+ |
/// screen.
|
|
613 |
+ |
#[must_use]
|
|
614 |
+ |
pub fn unset(mut self, unset: bool) -> Self {
|
|
615 |
+ |
self.unset = unset;
|
|
616 |
+ |
self
|
|
617 |
+ |
}
|
|
618 |
+ |
|
|
619 |
+ |
/// Pad the label column to this width, so value cells line up down the
|
|
620 |
+ |
/// form. [`AlloyForm`] sets it from the widest label it holds.
|
|
621 |
+ |
#[must_use]
|
|
622 |
+ |
pub fn label_width(mut self, width: usize) -> Self {
|
|
623 |
+ |
self.label_width = width;
|
|
624 |
+ |
self
|
|
625 |
+ |
}
|
|
626 |
+ |
|
|
627 |
+ |
/// Draw the caret buffer in place of the value cell. `Some` means this row
|
|
628 |
+ |
/// is being edited.
|
|
629 |
+ |
#[must_use]
|
|
630 |
+ |
pub fn edit(mut self, edit: Option<&'a TextField>) -> Self {
|
|
631 |
+ |
self.edit = edit;
|
|
632 |
+ |
self
|
|
633 |
+ |
}
|
|
634 |
+ |
|
|
635 |
+ |
#[must_use]
|
|
636 |
+ |
pub fn diagnostic(mut self, diagnostic: Option<(Severity, &'a str)>) -> Self {
|
|
637 |
+ |
self.diagnostic = diagnostic;
|
|
638 |
+ |
self
|
|
639 |
+ |
}
|
|
640 |
+ |
|
|
641 |
+ |
#[must_use]
|
|
642 |
+ |
pub fn help(mut self, help: Option<&'a str>) -> Self {
|
|
643 |
+ |
self.help = help;
|
|
644 |
+ |
self
|
|
645 |
+ |
}
|
|
646 |
+ |
|
|
647 |
+ |
/// The value cell's spans, under the row's base style.
|
|
648 |
+ |
fn value_spans(&self, base: Style) -> Vec<Span<'a>> {
|
|
649 |
+ |
if let Some(buffer) = self.edit {
|
|
650 |
+ |
return caret_spans(self.theme, buffer, base);
|
|
651 |
+ |
}
|
|
652 |
+ |
|
|
653 |
+ |
let style = if self.unset {
|
|
654 |
+ |
base.fg(self.theme.content_muted)
|
|
655 |
+ |
} else {
|
|
656 |
+ |
base
|
|
657 |
+ |
};
|
|
658 |
+ |
|
|
659 |
+ |
match &self.kind {
|
|
660 |
+ |
// Brackets and not a color or a Nerd Font glyph, for the reason
|
|
661 |
+ |
// MARKER is a plain triangle: a toggle has to be readable on the
|
|
662 |
+ |
// TTY before the session starts, and state must not be carried by
|
|
663 |
+ |
// color alone.
|
|
664 |
+ |
FieldKind::Toggle(on) => {
|
|
665 |
+ |
vec![Span::styled(if *on { "[x]" } else { "[ ]" }, style)]
|
|
666 |
+ |
}
|
|
667 |
+ |
FieldKind::Text(value) | FieldKind::Number(value) => {
|
|
668 |
+ |
vec![Span::styled(*value, style)]
|
|
669 |
+ |
}
|
|
670 |
+ |
FieldKind::Enum { label } => vec![Span::styled(*label, style)],
|
|
671 |
+ |
FieldKind::Color { hex } => {
|
|
672 |
+ |
let mut spans = Vec::with_capacity(2);
|
|
673 |
+ |
// A hex string the caller could not parse still renders its
|
|
674 |
+ |
// text: a swatch is an aid to reading the value, not the value.
|
|
675 |
+ |
if let Some(color) = swatch(hex) {
|
|
676 |
+ |
spans.push(Span::styled(SWATCH, base.fg(color)));
|
|
677 |
+ |
spans.push(Span::styled(" ", base));
|
|
678 |
+ |
}
|
|
679 |
+ |
spans.push(Span::styled(*hex, style));
|
|
680 |
+ |
spans
|
|
681 |
+ |
}
|
|
682 |
+ |
}
|
|
683 |
+ |
}
|
|
684 |
+ |
}
|
|
685 |
+ |
|
|
686 |
+ |
/// The swatch a color field paints beside its hex.
|
|
687 |
+ |
const SWATCH: &str = "██";
|
|
688 |
+ |
|
|
689 |
+ |
/// Parse `#rrggbb` or `#rrggbbaa` into a color. Alpha is dropped: a terminal
|
|
690 |
+ |
/// cell has no alpha to blend against.
|
|
691 |
+ |
fn swatch(hex: &str) -> Option<Color> {
|
|
692 |
+ |
let body = hex.strip_prefix('#')?;
|
|
693 |
+ |
if body.len() != 6 && body.len() != 8 {
|
|
694 |
+ |
return None;
|
|
695 |
+ |
}
|
|
696 |
+ |
let channel = |at: usize| u8::from_str_radix(body.get(at..at + 2)?, 16).ok();
|
|
697 |
+ |
Some(Color::Rgb(channel(0)?, channel(2)?, channel(4)?))
|
|
698 |
+ |
}
|
|
699 |
+ |
|
|
700 |
+ |
/// A caret buffer as spans: the text either side of the caret, and the cell
|
|
701 |
+ |
/// under it drawn as a block.
|
|
702 |
+ |
///
|
|
703 |
+ |
/// Colors are named rather than reversed. `Modifier::REVERSED` swaps against
|
|
704 |
+ |
/// whatever the row's background happens to be, so a caret on a focused row
|
|
705 |
+ |
/// and a caret on an unfocused one would not match.
|
|
706 |
+ |
fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec<Span<'s>> {
|
|
707 |
+ |
let (before, under, after) = buffer.split();
|
|
708 |
+ |
let caret = Style::default()
|
|
709 |
+ |
.bg(theme.content_primary)
|
|
710 |
+ |
.fg(theme.surface_page);
|
|
711 |
+ |
vec![
|
|
712 |
+ |
Span::styled(before.to_string(), base),
|
|
713 |
+ |
// A caret past the end of the line has no character to sit on, so it
|
|
714 |
+ |
// draws on a space. Without this the buffer loses its caret exactly
|
|
715 |
+ |
// when the user is appending, which is most of the time.
|
|
716 |
+ |
Span::styled(under.map_or(" ".to_string(), String::from), caret),
|
|
717 |
+ |
Span::styled(after.to_string(), base),
|
|
718 |
+ |
]
|
|
719 |
+ |
}
|
|
720 |
+ |
|
|
721 |
+ |
impl Widget for AlloyField<'_> {
|
|
722 |
+ |
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
723 |
+ |
if area.height == 0 || area.width == 0 {
|
|
724 |
+ |
return;
|
|
725 |
+ |
}
|
|
726 |
+ |
|
|
727 |
+ |
let base = if self.focused {
|
|
728 |
+ |
selected_style(self.theme)
|
|
729 |
+ |
} else {
|
|
730 |
+ |
unselected_style(self.theme)
|
|
731 |
+ |
};
|
|
732 |
+ |
let marker = if self.focused { MARKER } else { MARKER_BLANK };
|
|
733 |
+ |
|
|
734 |
+ |
let mut spans = vec![Span::styled(format!("{marker} "), base)];
|
|
735 |
+ |
if self.indent {
|
|
736 |
+ |
spans.push(Span::styled(" ", base));
|
|
737 |
+ |
}
|
|
738 |
+ |
|
|
739 |
+ |
let width = self.label_width.max(self.label.chars().count());
|
|
740 |
+ |
spans.push(Span::styled(
|
|
741 |
+ |
format!("{:width$} ", self.label, width = width),
|
|
742 |
+ |
base.fg(self.theme.content_secondary),
|
|
743 |
+ |
));
|
|
744 |
+ |
spans.extend(self.value_spans(base));
|
|
745 |
+ |
|
|
746 |
+ |
Paragraph::new(Line::from(spans))
|
|
747 |
+ |
.style(base)
|
|
748 |
+ |
.render(Rect { height: 1, ..area }, buf);
|
|
749 |
+ |
}
|
|
750 |
+ |
}
|
|
751 |
+ |
|
|
752 |
+ |
/// One row of a form: a section header, or a field.
|
|
753 |
+ |
///
|
|
754 |
+ |
/// The binary rebuilds this list every frame from the schema and the fold
|
|
755 |
+ |
/// state — headers, plus the fields of open sections — and a `Cursor` rides
|
|
756 |
+ |
/// it. Same split as [`AlloyList`]: widget shared, state owned by the view.
|
|
757 |
+ |
pub enum FormRow<'a> {
|
|
758 |
+ |
Section { label: &'a str, open: bool },
|
|
759 |
+ |
Field(AlloyField<'a>),
|
|
760 |
+ |
}
|
|
761 |
+ |
|
|
762 |
+ |
/// The chrome around a sequence of form rows.
|
|
763 |
+ |
///
|
|
764 |
+ |
/// Sections are header rows and an indent, not nested boxes. A box inside a
|
|
765 |
+ |
/// pane spends two columns a side on every level, and the fold marker already
|
|
766 |
+ |
/// says everything a border would; the console's panes are narrow enough that
|
|
767 |
+ |
/// the columns matter more than the outline.
|
|
768 |
+ |
///
|
|
769 |
+ |
/// Rows are one line each, without exception. That is what lets the form scroll
|
|
770 |
+ |
/// through [`list_offset`] rather than carrying its own state, the same trade
|
|
771 |
+ |
/// [`AlloyList`] makes, and it is why the focused row's help and diagnostic
|
|
772 |
+ |
/// render on a reserved line at the foot of the form instead of under the row.
|
|
773 |
+ |
/// The line is reserved whether or not there is anything to put in it, so the
|
|
774 |
+ |
/// rows above do not reflow as focus moves.
|
|
775 |
+ |
pub struct AlloyForm<'a> {
|
|
776 |
+ |
theme: &'a Theme,
|
|
777 |
+ |
rows: Vec<FormRow<'a>>,
|
|
778 |
+ |
selected: Option<usize>,
|
|
779 |
+ |
}
|
|
780 |
+ |
|
|
781 |
+ |
impl<'a> AlloyForm<'a> {
|
|
782 |
+ |
pub fn new(theme: &'a Theme, rows: impl IntoIterator<Item = FormRow<'a>>) -> Self {
|
|
783 |
+ |
Self {
|
|
784 |
+ |
theme,
|
|
785 |
+ |
rows: rows.into_iter().collect(),
|
|
786 |
+ |
selected: None,
|
|
787 |
+ |
}
|
|
788 |
+ |
}
|
|
789 |
+ |
|
|
790 |
+ |
#[must_use]
|
|
791 |
+ |
pub fn selected(mut self, selected: usize) -> Self {
|
|
792 |
+ |
self.selected = Some(selected);
|
|
793 |
+ |
self
|
|
794 |
+ |
}
|
|
795 |
+ |
|
|
796 |
+ |
/// The label column every field pads to: the widest label in the form.
|
|
797 |
+ |
fn label_width(&self) -> usize {
|
|
798 |
+ |
self.rows
|
|
799 |
+ |
.iter()
|
|
800 |
+ |
.filter_map(|row| match row {
|
|
801 |
+ |
FormRow::Field(field) => Some(field.label.chars().count()),
|
|
802 |
+ |
FormRow::Section { .. } => None,
|
|
803 |
+ |
})
|
|
804 |
+ |
.max()
|
|
805 |
+ |
.unwrap_or(0)
|
|
806 |
+ |
}
|
|
807 |
+ |
|
|
808 |
+ |
/// The footer line for the focused row: its diagnostic if it has one, its
|
|
809 |
+ |
/// help otherwise.
|
|
810 |
+ |
///
|
|
811 |
+ |
/// A diagnostic wins because it is the reason an edit did not commit, and
|
|
812 |
+ |
/// help the user has already read is not worth the row it would cost them.
|
|
813 |
+ |
fn footer(&self) -> Option<Line<'a>> {
|
|
814 |
+ |
let FormRow::Field(field) = self.rows.get(self.selected?)? else {
|
|
815 |
+ |
return None;
|
|
816 |
+ |
};
|
|
817 |
+ |
if let Some((severity, message)) = field.diagnostic {
|
|
818 |
+ |
return Some(Line::from(Span::styled(
|
|
819 |
+ |
message,
|
|
820 |
+ |
severity.style(self.theme),
|
|
821 |
+ |
)));
|
|
822 |
+ |
}
|
|
823 |
+ |
field
|
|
824 |
+ |
.help
|
|
825 |
+ |
.map(|help| Line::from(text::muted(self.theme, help)))
|
|
826 |
+ |
}
|
|
827 |
+ |
}
|
|
828 |
+ |
|
|
829 |
+ |
/// Fold markers for a section header. Plain geometric shapes rather than Nerd
|
|
830 |
+ |
/// Font glyphs, per docs/ICONOGRAPHY.md.
|
|
831 |
+ |
const SECTION_OPEN: &str = "▾";
|
|
832 |
+ |
const SECTION_CLOSED: &str = "▸";
|
|
833 |
+ |
|
|
834 |
+ |
impl Widget for AlloyForm<'_> {
|
|
835 |
+ |
fn render(mut self, area: Rect, buf: &mut Buffer) {
|
|
836 |
+ |
if area.height == 0 || area.width == 0 {
|
|
837 |
+ |
return;
|
|
838 |
+ |
}
|
|
839 |
+ |
|
|
840 |
+ |
// The footer is computed before the rows are consumed, and its line is
|
|
841 |
+ |
// taken off the top of the budget so a form one row tall still shows
|
|
842 |
+ |
// the row rather than only its help.
|
|
843 |
+ |
let footer = self.footer();
|
|
844 |
+ |
let rows_height = if area.height > 1 {
|
|
845 |
+ |
area.height - 1
|
|
846 |
+ |
} else {
|
|
847 |
+ |
area.height
|
|
848 |
+ |
};
|
|
849 |
+ |
|
|
850 |
+ |
let label_width = self.label_width();
|
|
851 |
+ |
let len = self.rows.len();
|
|
852 |
+ |
let offset = list_offset(len, rows_height as usize, self.selected);
|
|
853 |
+ |
|
|
854 |
+ |
for (row, (index, item)) in self
|
|
855 |
+ |
.rows
|
|
856 |
+ |
.drain(..)
|
|
857 |
+ |
.enumerate()
|
|
858 |
+ |
.skip(offset)
|
|
859 |
+ |
.take(rows_height as usize)
|
|
860 |
+ |
.enumerate()
|
|
861 |
+ |
{
|
|
862 |
+ |
let line_area = Rect {
|
|
863 |
+ |
y: area.y + row as u16,
|
|
864 |
+ |
height: 1,
|
|
865 |
+ |
..area
|
|
866 |
+ |
};
|
|
867 |
+ |
let focused = self.selected == Some(index);
|
|
868 |
+ |
|
|
869 |
+ |
match item {
|
|
870 |
+ |
FormRow::Section { label, open } => {
|
|
871 |
+ |
let style = if focused {
|
|
872 |
+ |
selected_style(self.theme)
|
|
873 |
+ |
} else {
|
|
874 |
+ |
unselected_style(self.theme)
|
|
875 |
+ |
};
|
|
876 |
+ |
let marker = if focused { MARKER } else { MARKER_BLANK };
|
|
877 |
+ |
let fold = if open { SECTION_OPEN } else { SECTION_CLOSED };
|
|
878 |
+ |
Paragraph::new(Line::from(vec![
|
|
879 |
+ |
Span::styled(format!("{marker} "), style),
|
|
880 |
+ |
Span::styled(format!("{fold} "), style),
|
|
881 |
+ |
Span::styled(
|
|
882 |
+ |
label,
|
|
883 |
+ |
style.patch(Style::default().fg(self.theme.content_primary)),
|
|
884 |
+ |
),
|
|
885 |
+ |
]))
|
|
886 |
+ |
.style(style)
|
|
887 |
+ |
.render(line_area, buf);
|
|
888 |
+ |
}
|
|
889 |
+ |
FormRow::Field(field) => field
|
|
890 |
+ |
.focused(focused)
|
|
891 |
+ |
.indent(true)
|
|
892 |
+ |
.label_width(label_width)
|
|
893 |
+ |
.render(line_area, buf),
|
|
894 |
+ |
}
|
|
895 |
+ |
}
|
|
896 |
+ |
|
|
897 |
+ |
if area.height > 1 {
|
|
898 |
+ |
let line_area = Rect {
|
|
899 |
+ |
y: area.y + area.height - 1,
|
|
900 |
+ |
height: 1,
|
|
901 |
+ |
..area
|
|
902 |
+ |
};
|
|
903 |
+ |
let base = Style::default().bg(self.theme.surface_page);
|
|
904 |
+ |
Paragraph::new(footer.unwrap_or_default())
|
|
905 |
+ |
.style(base)
|
|
906 |
+ |
.render(line_area, buf);
|
|
907 |
+ |
}
|
|
908 |
+ |
}
|
|
909 |
+ |
}
|
|
910 |
+ |
|
|
911 |
+ |
/// A display-only table.
|
|
912 |
+ |
///
|
|
913 |
+ |
/// v1 renders schema list-of-tables records (rio's `bindings.keys`) read-only;
|
|
914 |
+ |
/// add, remove, and cell edit route to the text-edit fallback until v1.1. It is
|
|
915 |
+ |
/// display-only rather than half-editable on purpose: a table that accepts some
|
|
916 |
+ |
/// edits and silently refuses others is worse than one that clearly accepts
|
|
917 |
+ |
/// none.
|
|
918 |
+ |
pub struct AlloyTable<'a> {
|
|
919 |
+ |
theme: &'a Theme,
|
|
920 |
+ |
headers: Vec<&'a str>,
|
|
921 |
+ |
rows: Vec<Vec<String>>,
|
|
922 |
+ |
}
|
|
923 |
+ |
|
|
924 |
+ |
impl<'a> AlloyTable<'a> {
|
|
925 |
+ |
pub fn new(
|
|
926 |
+ |
theme: &'a Theme,
|
|
927 |
+ |
headers: impl IntoIterator<Item = &'a str>,
|
|
928 |
+ |
rows: impl IntoIterator<Item = Vec<String>>,
|
|
929 |
+ |
) -> Self {
|
|
930 |
+ |
Self {
|
|
931 |
+ |
theme,
|
|
932 |
+ |
headers: headers.into_iter().collect(),
|
|
933 |
+ |
rows: rows.into_iter().collect(),
|
|
934 |
+ |
}
|
|
935 |
+ |
}
|
|
936 |
+ |
|
|
937 |
+ |
/// Column widths: the widest cell in each column, header included.
|
|
938 |
+ |
fn widths(&self) -> Vec<usize> {
|
|
939 |
+ |
let mut widths: Vec<usize> = self
|
|
940 |
+ |
.headers
|
|
941 |
+ |
.iter()
|
|
942 |
+ |
.map(|header| header.chars().count())
|
|
943 |
+ |
.collect();
|
|
944 |
+ |
for row in &self.rows {
|
|
945 |
+ |
for (column, cell) in row.iter().enumerate() {
|
|
946 |
+ |
let width = cell.chars().count();
|
|
947 |
+ |
match widths.get_mut(column) {
|
|
948 |
+ |
Some(current) => *current = (*current).max(width),
|
|
949 |
+ |
// A row wider than the header list still renders. The
|
|
950 |
+ |
// schema and the file it describes can disagree, and
|
|
951 |
+ |
// dropping a cell would hide exactly that.
|
|
952 |
+ |
None => widths.push(width),
|
|
953 |
+ |
}
|
|
954 |
+ |
}
|
|
955 |
+ |
}
|
|
956 |
+ |
widths
|
|
957 |
+ |
}
|
|
958 |
+ |
}
|
|
959 |
+ |
|
|
960 |
+ |
/// Gap between table columns.
|
|
961 |
+ |
const COLUMN_GAP: &str = " ";
|
|
962 |
+ |
|
|
963 |
+ |
impl Widget for AlloyTable<'_> {
|
|
964 |
+ |
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
965 |
+ |
if area.height == 0 || area.width == 0 {
|
|
966 |
+ |
return;
|
|
967 |
+ |
}
|
|
968 |
+ |
|
|
969 |
+ |
let widths = self.widths();
|
|
970 |
+ |
let pad = |cell: &str, column: usize| {
|
|
971 |
+ |
let width = widths.get(column).copied().unwrap_or(0);
|
|
972 |
+ |
format!("{cell:width$}")
|
|
973 |
+ |
};
|
|
974 |
+ |
|
|
975 |
+ |
let mut lines = Vec::with_capacity(self.rows.len() + 1);
|
|
976 |
+ |
lines.push(Line::from(
|
|
977 |
+ |
self.headers
|
|
978 |
+ |
.iter()
|
|
979 |
+ |
.enumerate()
|
|
980 |
+ |
.map(|(column, header)| {
|
|
981 |
+ |
text::muted(self.theme, format!("{}{COLUMN_GAP}", pad(header, column)))
|
|
982 |
+ |
})
|
|
983 |
+ |
.collect::<Vec<_>>(),
|
|
984 |
+ |
));
|
|
985 |
+ |
for row in &self.rows {
|
|
986 |
+ |
lines.push(Line::from(
|
|
987 |
+ |
row.iter()
|
|
988 |
+ |
.enumerate()
|
|
989 |
+ |
.map(|(column, cell)| {
|
|
990 |
+ |
text::secondary(self.theme, format!("{}{COLUMN_GAP}", pad(cell, column)))
|
|
991 |
+ |
})
|
|
992 |
+ |
.collect::<Vec<_>>(),
|
|
993 |
+ |
));
|
|
994 |
+ |
}
|
|
995 |
+ |
|
|
996 |
+ |
Paragraph::new(lines)
|
|
997 |
+ |
.style(Style::default().bg(self.theme.surface_page))
|
|
998 |
+ |
.render(area, buf);
|
|
999 |
+ |
}
|
|
1000 |
+ |
}
|
|
1001 |
+ |
|
| 528 |
1002 |
|
#[cfg(test)]
|
| 529 |
1003 |
|
mod tests {
|
| 530 |
1004 |
|
use super::*;
|