Skip to main content

max / alloy_tui

1.2.0: the config form, and TextField promoted into the crate AlloyForm over AlloyField rows, plus a display-only AlloyTable. One field widget carrying a FieldKind value cell rather than five: the widgets own no value and do no validation, so the five differed only in how the cell paints, and a form's row list forces an enum regardless. Two decisions the roster did not settle. Rows are one line each without exception, which is what keeps the stateless list_offset scroll valid, so the focused row's help and diagnostic go on a reserved line at the foot of the form rather than under the row; the line is reserved whether or not it has content, so rows do not reflow as focus moves. And sections are header rows plus an indent rather than nested boxes, which would spend two columns a side per level to say what the fold marker already says. AlloyField gains `unset`, which is not in the roster: without it a form cannot show the difference between a key set to 12 and a key absent from a file that defaults to 12, and keeping those distinguishable is why the console reads and defaults through separate calls. TextField moves out of the console binary unchanged. It was written there for the installer's hostname step because a widget here is a release, and this is that release.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 18:17 UTC
Commit: c01e27a84f1dbef0000ae00538ae5bef43294b7d
Parent: 1faeae0
5 files changed, +735 insertions, -5 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "alloy_tui"
3 - version = "1.1.0"
3 + version = "1.2.0"
4 4 description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 5 edition = "2024"
6 6 rust-version = "1.86"
M README.md +5
@@ -14,6 +14,11 @@ rather than each re-deriving colors from a palette.
14 14 locally so theme files stay minimal and cross-app compatible.
15 15 - **Widgets** — `AlloyBlock`, `AlloyList`, `AlloyTabs`, `AlloyStatusBar`,
16 16 `AlloyModal`, `AlloyLog`, each taking a `&Theme`.
17 + - **Forms** — `AlloyForm` over `AlloyField` rows, plus a display-only
18 + `AlloyTable`. One field widget carrying a `FieldKind` value cell rather than
19 + one widget per value type: the widgets own no value and do no validation, so
20 + they differ only in how the cell paints. `TextField` is the caret buffer a
21 + field shows in edit mode.
17 22 - **`keys`** — the reserved keymap every Alloy TUI agrees on, so the same keys
18 23 mean the same thing across tools.
19 24 - **`focus`** — a focus ring, the piece ratatui deliberately leaves to the app.
A src/input.rs +249
@@ -0,0 +1,249 @@
1 + //! A single-line text input.
2 + //!
3 + //! The caret buffer [`AlloyField`](crate::AlloyField) shows in edit mode, and
4 + //! the one piece of the form architecture that owns state rather than
5 + //! rendering it. Written for `alloy install`'s hostname and username steps,
6 + //! which were the first time an Alloy TUI accepted typing at all, and kept in
7 + //! the console binary until now for the reason a widget lands here at all: the
8 + //! crate is a separate published repo, so putting something in it is a
9 + //! release. This is that release.
10 + //!
11 + //! Indices are in `char`s throughout, never bytes. A hostname is ASCII by
12 + //! specification but a username need not be, and `String::insert` at a byte
13 + //! offset that lands mid-codepoint panics.
14 +
15 + /// A line of text with a caret in it.
16 + #[derive(Debug, Default, Clone)]
17 + pub struct TextField {
18 + value: String,
19 + /// Caret position, in `char`s from the start. Equal to the char count when
20 + /// the caret is past the last character, which is where typing appends.
21 + caret: usize,
22 + }
23 +
24 + impl TextField {
25 + pub fn new() -> Self {
26 + Self::default()
27 + }
28 +
29 + pub fn value(&self) -> &str {
30 + &self.value
31 + }
32 +
33 + /// Caret position, in `char`s.
34 + ///
35 + /// [`split`](Self::split) is what a renderer wants; this is the position
36 + /// itself, which is what a caller moving the caret and a test asserting
37 + /// where it landed both need.
38 + pub fn caret(&self) -> usize {
39 + self.caret
40 + }
41 +
42 + fn chars(&self) -> usize {
43 + self.value.chars().count()
44 + }
45 +
46 + /// Byte offset of char index `index`, for the `String` operations.
47 + ///
48 + /// `char_indices` stops at the last character, so an index one past the end
49 + /// (the caret appending at the tail) falls through to the string length
50 + /// rather than off it.
51 + fn byte_of(&self, index: usize) -> usize {
52 + self.value
53 + .char_indices()
54 + .nth(index)
55 + .map_or(self.value.len(), |(byte, _)| byte)
56 + }
57 +
58 + /// Type a character at the caret.
59 + pub fn insert(&mut self, c: char) {
60 + let at = self.byte_of(self.caret);
61 + self.value.insert(at, c);
62 + self.caret += 1;
63 + }
64 +
65 + /// Delete the character before the caret.
66 + pub fn backspace(&mut self) {
67 + if self.caret == 0 {
68 + return;
69 + }
70 + self.caret -= 1;
71 + let at = self.byte_of(self.caret);
72 + self.value.remove(at);
73 + }
74 +
75 + /// Delete the character under the caret.
76 + pub fn delete(&mut self) {
77 + if self.caret >= self.chars() {
78 + return;
79 + }
80 + let at = self.byte_of(self.caret);
81 + self.value.remove(at);
82 + }
83 +
84 + /// Clamped rather than wrapping: a caret that jumps to the far end of the
85 + /// line when you press Left once too often is the kind of thing that gets
86 + /// a character typed into the wrong place.
87 + pub fn left(&mut self) {
88 + self.caret = self.caret.saturating_sub(1);
89 + }
90 +
91 + pub fn right(&mut self) {
92 + if self.caret < self.chars() {
93 + self.caret += 1;
94 + }
95 + }
96 +
97 + pub fn home(&mut self) {
98 + self.caret = 0;
99 + }
100 +
101 + pub fn end(&mut self) {
102 + self.caret = self.chars();
103 + }
104 +
105 + /// Replace the contents, caret to the end.
106 + ///
107 + /// For seeding a field with a default the user is expected to edit rather
108 + /// than retype, which is what the hostname step does.
109 + pub fn set(&mut self, value: impl Into<String>) {
110 + self.value = value.into();
111 + self.caret = self.chars();
112 + }
113 +
114 + /// The line split at the caret: what is before it, the character under it,
115 + /// and what follows.
116 + ///
117 + /// Returned as three pieces rather than rendered here because drawing needs
118 + /// the theme, and this type deliberately knows nothing about one. The
119 + /// middle is `None` when the caret is past the end, where a renderer draws
120 + /// a block on empty space.
121 + pub fn split(&self) -> (&str, Option<char>, &str) {
122 + let at = self.byte_of(self.caret);
123 + let (before, rest) = self.value.split_at(at);
124 + let mut chars = rest.chars();
125 + match chars.next() {
126 + Some(under) => (before, Some(under), chars.as_str()),
127 + None => (before, None, ""),
128 + }
129 + }
130 + }
131 +
132 + #[cfg(test)]
133 + mod tests {
134 + use super::*;
135 +
136 + fn typed(text: &str) -> TextField {
137 + let mut field = TextField::new();
138 + for c in text.chars() {
139 + field.insert(c);
140 + }
141 + field
142 + }
143 +
144 + #[test]
145 + fn typing_appends_and_moves_the_caret() {
146 + let field = typed("alloy");
147 + assert_eq!(field.value(), "alloy");
148 + assert_eq!(field.caret(), 5);
149 + }
150 +
151 + #[test]
152 + fn insert_lands_at_the_caret_not_the_end() {
153 + let mut field = typed("aloy");
154 + field.home();
155 + field.right();
156 + field.insert('l');
157 + assert_eq!(field.value(), "alloy");
158 + assert_eq!(field.caret(), 2);
159 + }
160 +
161 + #[test]
162 + fn backspace_takes_the_character_before_the_caret() {
163 + let mut field = typed("alloyy");
164 + field.backspace();
165 + assert_eq!(field.value(), "alloy");
166 +
167 + field.home();
168 + field.backspace();
169 + assert_eq!(field.value(), "alloy", "backspace at the start is inert");
170 + assert_eq!(field.caret(), 0);
171 + }
172 +
173 + #[test]
174 + fn delete_takes_the_character_under_the_caret() {
175 + let mut field = typed("xalloy");
176 + field.home();
177 + field.delete();
178 + assert_eq!(field.value(), "alloy");
179 +
180 + field.end();
181 + field.delete();
182 + assert_eq!(field.value(), "alloy", "delete at the end is inert");
183 + }
184 +
185 + // Clamped, not wrapping. A caret that leaps to the opposite end on one
186 + // keypress too many puts the next character somewhere the user did not look.
187 + #[test]
188 + fn the_caret_stops_at_both_ends() {
189 + let mut field = typed("ab");
190 + field.home();
191 + field.left();
192 + assert_eq!(field.caret(), 0);
193 + field.end();
194 + field.right();
195 + assert_eq!(field.caret(), 2);
196 + }
197 +
198 + // The reason indices are chars. Byte offsets that land mid-codepoint panic
199 + // in `String::insert` and `String::remove`, and a username is not
200 + // guaranteed ASCII.
201 + #[test]
202 + fn multibyte_text_is_edited_without_panicking() {
203 + let mut field = typed("héllo");
204 + assert_eq!(field.caret(), 5);
205 +
206 + field.home();
207 + field.right();
208 + field.delete();
209 + assert_eq!(field.value(), "hllo", "the two-byte char came out whole");
210 +
211 + field.insert('é');
212 + assert_eq!(field.value(), "héllo");
213 +
214 + field.end();
215 + field.backspace();
216 + assert_eq!(field.value(), "héll");
217 + }
218 +
219 + #[test]
220 + fn split_reports_the_character_under_the_caret() {
221 + let mut field = typed("alloy");
222 + field.home();
223 + assert_eq!(field.split(), ("", Some('a'), "lloy"));
224 +
225 + field.right();
226 + assert_eq!(field.split(), ("a", Some('l'), "loy"));
227 +
228 + field.end();
229 + assert_eq!(
230 + field.split(),
231 + ("alloy", None, ""),
232 + "past the end there is nothing under the caret"
233 + );
234 + }
235 +
236 + #[test]
237 + fn split_handles_an_empty_field() {
238 + assert_eq!(TextField::new().split(), ("", None, ""));
239 + }
240 +
241 + #[test]
242 + fn set_replaces_the_value_and_parks_the_caret_at_the_end() {
243 + let mut field = typed("old");
244 + field.home();
245 + field.set("alloy");
246 + assert_eq!(field.value(), "alloy");
247 + assert_eq!(field.caret(), 5, "ready to edit the tail, not retype it");
248 + }
249 + }
M src/lib.rs +2
@@ -19,6 +19,7 @@
19 19 pub mod connector;
20 20 pub mod cursor;
21 21 pub mod focus;
22 + pub mod input;
22 23 pub mod keys;
23 24 pub mod layout;
24 25 pub mod selection;
@@ -29,6 +30,7 @@ pub mod widgets;
29 30 pub use connector::AlloyConnector;
30 31 pub use cursor::Cursor;
31 32 pub use focus::FocusRing;
33 + pub use input::TextField;
32 34 pub use keys::{Action, classify};
33 35 pub use layout::{ConsoleAreas, PaneAreas, console, panes};
34 36 pub use selection::{MARKER, selected_style};
M src/widgets.rs +478 -4
@@ -2,10 +2,11 @@
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,6 +14,7 @@ use ratatui::style::{Color, Style};
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,6 +527,478 @@ impl Widget for AlloyLog<'_> {
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::*;
@@ -723,6 +1197,288 @@ mod tests {
Lines truncated