feat(faces): the TUI draws axis, wizard-field, mode and minibuffer colours through face_style (improvise-nn4s.2)

axis_fg, minibuf_fg, field_kind_fg and mode_style are gone, and AxisColor
leaves improvise-core. The category-add prompt turns green, ADR 004
clause 8's one declared exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (agent-faces)
2026-09-24 21:58:01 -07:00
committed by Edward Langley
parent abff562360
commit 12d976b432
9 changed files with 206 additions and 107 deletions
-33
View File
@@ -12,17 +12,6 @@ pub enum Axis {
None,
}
/// Accent color for an axis. Semantic palette only; `draw.rs::axis_fg` maps
/// each variant to a terminal color, so `Axis` names no ratatui type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisColor {
Row,
Column,
Page,
Filter,
Inactive,
}
impl Axis {
/// The next axis in the Row → Column → Page → None → Row rotation.
/// `Filter` is outside it and cycling one is a no-op (improvise-dz8).
@@ -48,17 +37,6 @@ impl Axis {
!matches!(self, Axis::Filter)
}
/// Accent color. The single source for axis coloring: the tile bar and the
/// category panel each carried a copy before (improvise-rml).
pub fn color(self) -> AxisColor {
match self {
Axis::Row => AxisColor::Row,
Axis::Column => AxisColor::Column,
Axis::Page => AxisColor::Page,
Axis::Filter => AxisColor::Filter,
Axis::None => AxisColor::Inactive,
}
}
/// Name with no decoration: "Row", "Col", "Page", "None".
pub fn plain_label(self) -> &'static str {
@@ -176,15 +154,4 @@ mod tests {
assert!(!Axis::Column.filters_data());
assert!(!Axis::None.filters_data());
}
/// A collision defeats the only reason `AxisColor` exists.
#[test]
fn every_axis_gets_its_own_color() {
let colors = ALL.map(Axis::color);
for (i, a) in colors.iter().enumerate() {
for b in &colors[i + 1..] {
assert_ne!(a, b, "two axes share an accent color");
}
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ pub mod axis;
pub mod layout;
pub mod types;
pub use axis::{Axis, AxisColor};
pub use axis::Axis;
pub use layout::{
AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow,
ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine,
+12
View File
@@ -575,6 +575,18 @@ mod tests {
}
}
/// A collision defeats the reason the axis faces exist (improvise-rml).
#[test]
fn every_axis_gets_its_own_accent_in_the_shipped_theme() {
let theme = Theme::shipped();
let accents = AXES.map(|axis| theme.resolve(Face::Axis(axis)));
for (i, a) in accents.iter().enumerate() {
for b in &accents[i + 1..] {
assert_ne!(a, b, "two axes share an accent");
}
}
}
/// A child overrides what it states and inherits what it does not, from
/// the nearest ancestor that states it — `context/test-evidence/protocol.md`.
#[test]
+1 -1
View File
@@ -396,7 +396,7 @@ impl AppMode {
minibuf: MinibufferConfig {
buffer_key: "category".into(),
prompt: "new category: ".into(),
color: MinibufColor::Yellow,
color: MinibufColor::Green,
},
}
}
+2 -2
View File
@@ -75,8 +75,8 @@ pub struct RenderModel {
pub struct RenderMinibuffer {
pub prompt: String,
pub value: String,
/// Semantic, as `MinibufColor` is: the last translation to a CSS class is
/// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26).
/// A colour, not a role: the TUI now draws a face per text-entry mode, and
/// improvise-nn4s.4 moves the page onto the same faces (ADR 004).
pub color: improvise_protocol::MinibufColor,
}
+176 -48
View File
@@ -1,5 +1,6 @@
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::Result;
@@ -17,8 +18,9 @@ use ratatui::{
};
use crate::command::key;
use crate::protocol::face::{AnsiSlot, Colour, Face, Slant, Theme, Weight};
use crate::protocol::EditorSession;
use crate::ui::app::{App, AppMode, MinibufColor};
use crate::ui::app::{App, AppMode};
use crate::ui::category_panel::CategoryContent;
use crate::ui::formula_panel::FormulaContent;
use crate::ui::grid::GridWidget;
@@ -29,7 +31,6 @@ use crate::ui::text::truncate;
use crate::ui::tile_bar::TileBar;
use crate::ui::view_panel::ViewContent;
use crate::ui::which_key::WhichKeyWidget;
use crate::view::AxisColor;
use improvise_command::ui::project;
use unicode_width::UnicodeWidthStr;
@@ -165,24 +166,54 @@ fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers {
// ── Drawing ──────────────────────────────────────────────────────────────────
/// The one place `AxisColor` becomes a terminal color (improvise-rml); the
/// accent itself is decided by `Axis::color`. See `context/code/tui.md`.
pub(crate) fn axis_fg(color: AxisColor) -> Color {
match color {
AxisColor::Row => Color::Green,
AxisColor::Column => Color::Blue,
AxisColor::Page => Color::Magenta,
AxisColor::Filter => Color::Yellow,
AxisColor::Inactive => Color::DarkGray,
/// The one place a face becomes a terminal style (ADR 004): the theme resolves
/// it, and only this last translation is the TUI's. `context/code/tui.md`.
pub(crate) fn face_style(face: Face) -> Style {
static SHIPPED: OnceLock<Theme> = OnceLock::new();
let face = SHIPPED.get_or_init(Theme::shipped).resolve(face);
let mut style = Style::default();
if let Some(fg) = terminal_colour(face.foreground) {
style = style.fg(fg);
}
if let Some(bg) = terminal_colour(face.background) {
style = style.bg(bg);
}
let modifiers = [
(face.weight == Weight::Bold, Modifier::BOLD),
(face.slant == Slant::Italic, Modifier::ITALIC),
(face.underline, Modifier::UNDERLINED),
(face.reverse, Modifier::REVERSED),
];
modifiers
.into_iter()
.filter(|(on, _)| *on)
.fold(style, |style, (_, m)| style.add_modifier(m))
}
/// Map the semantic minibuffer accent to a concrete terminal color.
fn minibuf_fg(color: MinibufColor) -> Color {
match color {
MinibufColor::Green => Color::Green,
MinibufColor::Cyan => Color::Cyan,
MinibufColor::Yellow => Color::Yellow,
/// `None` is "state nothing", so the terminal's own colour shows through.
fn terminal_colour(colour: Colour) -> Option<Color> {
match colour {
Colour::Default => None,
Colour::Named(slot) => Some(match slot {
AnsiSlot::Black => Color::Black,
AnsiSlot::Red => Color::Red,
AnsiSlot::Green => Color::Green,
AnsiSlot::Yellow => Color::Yellow,
AnsiSlot::Blue => Color::Blue,
AnsiSlot::Magenta => Color::Magenta,
AnsiSlot::Cyan => Color::Cyan,
AnsiSlot::White => Color::Gray,
AnsiSlot::BrightBlack => Color::DarkGray,
AnsiSlot::BrightRed => Color::LightRed,
AnsiSlot::BrightGreen => Color::LightGreen,
AnsiSlot::BrightYellow => Color::LightYellow,
AnsiSlot::BrightBlue => Color::LightBlue,
AnsiSlot::BrightMagenta => Color::LightMagenta,
AnsiSlot::BrightCyan => Color::LightCyan,
AnsiSlot::BrightWhite => Color::White,
}),
Colour::Indexed(n) => Some(Color::Indexed(n)),
Colour::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
}
}
@@ -231,30 +262,6 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color
inner
}
/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new
/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`.
fn mode_style(mode: &AppMode) -> Style {
let bg = match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
AppMode::CommandMode { .. } => Color::Yellow,
AppMode::TileSelect => Color::Magenta,
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaEdit { .. }
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ViewPanel
| AppMode::ImportWizard
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. }
| AppMode::Help
| AppMode::Quit => Color::DarkGray,
};
Style::default().fg(Color::Black).bg(bg)
}
fn draw(f: &mut Frame, app: &App) {
let size = f.area();
@@ -442,12 +449,9 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
.unwrap_or("");
let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width);
f.render_widget(
Paragraph::new(text).style(
Style::default()
.fg(minibuf_fg(mb.color))
.bg(Color::Indexed(235))
.add_modifier(Modifier::BOLD),
),
Paragraph::new(text).style(face_style(
Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::Minibuffer),
)),
area,
);
} else {
@@ -484,7 +488,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let line = fill_line(left, &view_badge, area.width, Keeps::Left);
f.render_widget(
Paragraph::new(line).style(mode_style(&app.view_state.mode)),
Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))),
area,
);
}
@@ -639,6 +643,39 @@ mod bottom_bar_tests {
);
}
/// The first cell of the bottom row, where the prompt's first glyph lands.
fn prompt_cell(app: &App) -> ratatui::buffer::Cell {
let mut terminal = Terminal::new(TestBackend::new(40, 1)).expect("test backend");
terminal
.draw(|f| draw_bottom_bar(f, Rect::new(0, 0, 40, 1), app))
.expect("draw");
terminal.backend().buffer().content()[0].clone()
}
/// ADR 004 clause 8's one declared exception (the user, Q1): the
/// category-add prompt draws as item-add does. `context/test-evidence/tui.md`.
#[test]
fn the_category_add_prompt_draws_as_the_item_add_prompt_does() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::item_add("Region".into());
let item = prompt_cell(&app);
app.view_state.mode = AppMode::category_add();
let category = prompt_cell(&app);
assert_eq!(category.symbol(), "n", "the prompt is not where it was looked for");
assert_eq!(item.fg, ratatui::style::Color::Green);
assert_eq!(category.style(), item.style());
}
/// The status line wears the mode's face, not the minibuffer's.
#[test]
fn the_status_line_is_drawn_in_the_mode_face() {
let mut app = App::new(Workbook::new("T"), None);
app.view_state.mode = AppMode::TileSelect;
let cell = prompt_cell(&app);
assert_eq!(cell.bg, ratatui::style::Color::Magenta);
assert_eq!(cell.fg, ratatui::style::Color::Black);
}
/// A refusal that keeps the user in a minibuffer stands while they fix it, and
/// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`.
#[test]
@@ -1187,3 +1224,94 @@ mod content_tests {
}
}
}
#[cfg(test)]
mod face_tests {
use super::*;
use crate::protocol::face::TextEntry;
use crate::protocol::render::WizardFieldKind;
use crate::view::Axis;
/// `mode_style`'s table as it stood before faces (improvise-zx8t).
fn mode_bg_before_faces(mode: &AppMode) -> Color {
match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green,
AppMode::CommandMode { .. } => Color::Yellow,
AppMode::TileSelect => Color::Magenta,
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaEdit { .. }
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ViewPanel
| AppMode::ImportWizard
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. }
| AppMode::Help
| AppMode::Quit => Color::DarkGray,
}
}
/// ADR 004 clause 8: the shipped theme draws every migrated site exactly as
/// its literal did. `context/test-evidence/tui.md`.
#[test]
fn the_shipped_theme_reproduces_every_migrated_literal() {
let fg = |c| Style::default().fg(c);
let axes = [
(Axis::Row, Color::Green),
(Axis::Column, Color::Blue),
(Axis::Page, Color::Magenta),
(Axis::Filter, Color::Yellow),
(Axis::None, Color::DarkGray),
];
for (axis, color) in axes {
assert_eq!(face_style(Face::Axis(axis)), fg(color), "{axis:?}");
}
let kinds = [
(WizardFieldKind::Category, Color::Green),
(WizardFieldKind::Measure, Color::Cyan),
(WizardFieldKind::TimeCategory, Color::Magenta),
(WizardFieldKind::Label, Color::DarkGray),
];
for (kind, color) in kinds {
assert_eq!(face_style(Face::WizardField(kind)), fg(color), "{kind:?}");
}
for mode in AppMode::all() {
let before = fg(Color::Black).bg(mode_bg_before_faces(&mode));
assert_eq!(face_style(Face::of_mode(&mode)), before, "{mode:?}");
}
let prompts = [
(TextEntry::Edit, Color::Green),
(TextEntry::ItemAdd, Color::Green),
(TextEntry::Formula, Color::Cyan),
(TextEntry::Command, Color::Yellow),
(TextEntry::Export, Color::Yellow),
(TextEntry::Save, Color::Yellow),
];
for (entry, color) in prompts {
let before = fg(color)
.bg(Color::Indexed(235))
.add_modifier(Modifier::BOLD);
assert_eq!(face_style(Face::MinibufferOf(entry)), before, "{entry:?}");
}
}
#[test]
fn every_face_reaches_the_terminal_with_every_attribute_it_states() {
let resolved = |face| Theme::shipped().resolve(face);
for face in Face::all() {
let r = resolved(face);
let style = face_style(face);
assert_eq!(style.fg, terminal_colour(r.foreground), "{}", face.name());
assert_eq!(style.bg, terminal_colour(r.background), "{}", face.name());
assert_eq!(
style.add_modifier.contains(Modifier::BOLD),
r.weight == Weight::Bold,
"{}",
face.name()
);
}
}
}
+5 -4
View File
@@ -6,13 +6,14 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::face::Face;
use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree};
use crate::ui::app::Panel as PanelKind;
use crate::ui::panel::PanelContent;
use crate::view::Axis;
fn axis_display(axis: Axis) -> (String, Color) {
(axis.to_string(), crate::draw::axis_fg(axis.color()))
fn axis_display(axis: Axis) -> (String, Style) {
(axis.to_string(), crate::draw::face_style(Face::Axis(axis)))
}
/// The category panel draws one flattened tree and nothing else: the axis
@@ -78,7 +79,7 @@ impl PanelContent for CategoryContent {
axis,
} => {
let indicator = if *expanded { "▼" } else { "▶" };
let (axis_str, axis_color) = axis_display(*axis);
let (axis_str, axis_style) = axis_display(*axis);
let name_part = format!("{indicator} {name} ({item_count})");
let axis_part = format!(" [{axis_str}]");
@@ -91,7 +92,7 @@ impl PanelContent for CategoryContent {
if is_selected {
base_style
} else {
Style::default().fg(axis_color)
axis_style
},
);
}
+4 -14
View File
@@ -7,7 +7,8 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind};
use crate::protocol::face::Face;
use crate::protocol::{WizardCache, WizardStepKind};
use crate::ui::text::wrap;
/// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from
@@ -22,17 +23,6 @@ impl<'a> ImportWizardWidget<'a> {
}
}
/// Map a proposed field's kind to its accent, as `axis_fg` does for
/// `AxisColor` (gotcha 26). The decision is the protocol's; the color is not.
fn field_kind_fg(kind: WizardFieldKind) -> Color {
match kind {
WizardFieldKind::Category => Color::Green,
WizardFieldKind::Measure => Color::Cyan,
WizardFieldKind::TimeCategory => Color::Magenta,
WizardFieldKind::Label => Color::DarkGray,
}
}
impl<'a> Widget for ImportWizardWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let popup_w = area.width.min(80);
@@ -139,7 +129,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
}
let is_sel = i == self.wizard.cursor;
let kind_color = field_kind_fg(proposal.kind);
let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind));
let accept_str = if proposal.accepted {
"[\u{2713}]"
@@ -159,7 +149,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if proposal.accepted {
Style::default().fg(kind_color)
kind_style
} else {
Style::default().fg(Color::DarkGray)
};
+5 -4
View File
@@ -7,6 +7,7 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::protocol::TileBarCache;
use crate::protocol::face::Face;
use crate::ui::app::AppMode;
use crate::view::Axis;
@@ -44,8 +45,8 @@ impl<'a> TileBar<'a> {
tile_cat_idx,
}
}
fn axis_display(axis: Axis) -> (&'static str, Color) {
(axis.short_label(), crate::draw::axis_fg(axis.color()))
fn axis_display(axis: Axis) -> (&'static str, Style) {
(axis.short_label(), crate::draw::face_style(Face::Axis(axis)))
}
}
@@ -101,7 +102,7 @@ impl<'a> Widget for TileBar<'a> {
break;
}
let (_, axis_color) = TileBar::axis_display(tiles[i].axis);
let (_, axis_style) = TileBar::axis_display(tiles[i].axis);
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default()
@@ -109,7 +110,7 @@ impl<'a> Widget for TileBar<'a> {
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(axis_color)
axis_style
};
buf.set_string(x, area.y, &labels[i], style);