Files
improvise/crates/improvise-protocol/src/face.rs
T
Claude (agent-faces) 3583359a9c fix(faces): minibuffer faces inherit mode.text-entry, as ADR 004 says (improvise-nn4s.2)
Answers ChatGPT Codex's P2 on PR #319.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-24 23:02:55 -07:00

683 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Faces: appearance named by role, and a theme that binds roles to values
//! (ADR 004). The reasoning is `context/code/protocol.md` §10.
use std::collections::BTreeMap;
use std::fmt;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize, Serializer};
use crate::render::WizardFieldKind;
use crate::{AppMode, Panel};
use improvise_core::view::Axis;
/// One of the sixteen palette slots: "whatever THIS display calls that slot".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AnsiSlot {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
impl AnsiSlot {
pub const ALL: [AnsiSlot; 16] = [
AnsiSlot::Black,
AnsiSlot::Red,
AnsiSlot::Green,
AnsiSlot::Yellow,
AnsiSlot::Blue,
AnsiSlot::Magenta,
AnsiSlot::Cyan,
AnsiSlot::White,
AnsiSlot::BrightBlack,
AnsiSlot::BrightRed,
AnsiSlot::BrightGreen,
AnsiSlot::BrightYellow,
AnsiSlot::BrightBlue,
AnsiSlot::BrightMagenta,
AnsiSlot::BrightCyan,
AnsiSlot::BrightWhite,
];
/// The slot's number in the 256-colour palette, 0–15.
pub fn index(self) -> u8 {
self as u8
}
/// The spelling a theme file uses.
pub fn name(self) -> &'static str {
match self {
AnsiSlot::Black => "black",
AnsiSlot::Red => "red",
AnsiSlot::Green => "green",
AnsiSlot::Yellow => "yellow",
AnsiSlot::Blue => "blue",
AnsiSlot::Magenta => "magenta",
AnsiSlot::Cyan => "cyan",
AnsiSlot::White => "white",
AnsiSlot::BrightBlack => "bright-black",
AnsiSlot::BrightRed => "bright-red",
AnsiSlot::BrightGreen => "bright-green",
AnsiSlot::BrightYellow => "bright-yellow",
AnsiSlot::BrightBlue => "bright-blue",
AnsiSlot::BrightMagenta => "bright-magenta",
AnsiSlot::BrightCyan => "bright-cyan",
AnsiSlot::BrightWhite => "bright-white",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|slot| slot.name() == name)
}
}
/// A colour attribute. `Default`, `Named` and `Indexed` are palette-relative;
/// `Rgb` is absolute, and the shipped theme never uses it (ADR 004 clause 4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Colour {
Default,
Named(AnsiSlot),
Indexed(u8),
Rgb(u8, u8, u8),
}
impl Colour {
/// True for the one arm that opts a viewer out of their own palette.
pub fn is_absolute(self) -> bool {
match self {
Colour::Rgb(..) => true,
Colour::Default | Colour::Named(_) | Colour::Indexed(_) => false,
}
}
}
impl Serialize for Colour {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match *self {
Colour::Default => s.serialize_str("default"),
Colour::Named(slot) => s.serialize_str(slot.name()),
Colour::Indexed(n) => s.serialize_u8(n),
Colour::Rgb(r, g, b) => s.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
}
}
}
struct ColourVisitor;
impl Visitor<'_> for ColourVisitor {
type Value = Colour;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("\"default\", a slot name such as \"green\", 0-255, or \"#rrggbb\"")
}
fn visit_u64<E: de::Error>(self, n: u64) -> Result<Colour, E> {
u8::try_from(n)
.map(Colour::Indexed)
.map_err(|_| E::custom(format!("colour index {n} is outside 0-255")))
}
fn visit_str<E: de::Error>(self, s: &str) -> Result<Colour, E> {
parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self))
}
}
fn parse_colour(s: &str) -> Option<Colour> {
match s.strip_prefix('#') {
Some(hex) if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) => {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some(Colour::Rgb(byte(0)?, byte(2)?, byte(4)?))
}
Some(_) => None,
None if s == "default" => Some(Colour::Default),
None => AnsiSlot::from_name(s).map(Colour::Named),
}
}
impl<'de> Deserialize<'de> for Colour {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(ColourVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Weight {
Normal,
Bold,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Slant {
Normal,
Italic,
}
/// The text-entry modes, one per `MinibufferConfig`-carrying `AppMode` arm
/// except that cell editing is one role over two layouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum TextEntry {
Edit,
Formula,
Command,
CategoryAdd,
ItemAdd,
Export,
Save,
}
impl TextEntry {
pub const ALL: [TextEntry; 7] = [
TextEntry::Edit,
TextEntry::Formula,
TextEntry::Command,
TextEntry::CategoryAdd,
TextEntry::ItemAdd,
TextEntry::Export,
TextEntry::Save,
];
/// The text-entry role of `mode`, or `None` for a mode that takes no text.
pub fn of(mode: &AppMode) -> Option<Self> {
match mode {
AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Some(TextEntry::Edit),
AppMode::FormulaEdit { .. } => Some(TextEntry::Formula),
AppMode::CommandMode { .. } => Some(TextEntry::Command),
AppMode::CategoryAdd { .. } => Some(TextEntry::CategoryAdd),
AppMode::ItemAdd { .. } => Some(TextEntry::ItemAdd),
AppMode::ExportPrompt { .. } => Some(TextEntry::Export),
AppMode::SavePrompt { .. } => Some(TextEntry::Save),
AppMode::Normal
| AppMode::RecordsNormal
| AppMode::FormulaPanel
| AppMode::CategoryPanel
| AppMode::ViewPanel
| AppMode::TileSelect
| AppMode::ImportWizard
| AppMode::Help
| AppMode::Quit => None,
}
}
fn name(self) -> &'static str {
match self {
TextEntry::Edit => "edit",
TextEntry::Formula => "formula",
TextEntry::Command => "command",
TextEntry::CategoryAdd => "category-add",
TextEntry::ItemAdd => "item-add",
TextEntry::Export => "export",
TextEntry::Save => "save",
}
}
}
const AXES: [Axis; 5] = [
Axis::Row,
Axis::Column,
Axis::Page,
Axis::Filter,
Axis::None,
];
const FIELD_KINDS: [WizardFieldKind; 4] = [
WizardFieldKind::Category,
WizardFieldKind::Measure,
WizardFieldKind::TimeCategory,
WizardFieldKind::Label,
];
fn axis_name(axis: Axis) -> &'static str {
match axis {
Axis::Row => "row",
Axis::Column => "column",
Axis::Page => "page",
Axis::Filter => "filter",
Axis::None => "none",
}
}
fn field_kind_name(kind: WizardFieldKind) -> &'static str {
match kind {
WizardFieldKind::Category => "category",
WizardFieldKind::Measure => "measure",
WizardFieldKind::TimeCategory => "time",
WizardFieldKind::Label => "label",
}
}
/// A role something is drawn in. Its `name` is a compatibility surface once a
/// theme file carries it (ADR 004 clause 9), so never rename one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Face {
Default,
Dim,
Axis(Axis),
WizardField(WizardFieldKind),
Mode,
ModeResting,
ModeTextEntry,
ModeTextEntryOf(TextEntry),
ModePanel(Panel),
ModeOverlay,
ModeTransient,
ModeTileSelect,
MinibufferOf(TextEntry),
}
impl Face {
/// Every face, parents before children.
pub fn all() -> Vec<Face> {
let mut out = vec![Face::Default, Face::Dim];
out.extend(AXES.map(Face::Axis));
out.extend(FIELD_KINDS.map(Face::WizardField));
out.extend([Face::Mode, Face::ModeResting, Face::ModeTextEntry]);
out.extend(TextEntry::ALL.map(Face::ModeTextEntryOf));
out.extend(Panel::ALL.map(Face::ModePanel));
out.extend([Face::ModeOverlay, Face::ModeTransient, Face::ModeTileSelect]);
out.extend(TextEntry::ALL.map(Face::MinibufferOf));
out
}
/// The face this one inherits every attribute it does not state from.
pub fn parent(self) -> Option<Face> {
match self {
Face::Default => None,
Face::Dim | Face::Mode => Some(Face::Default),
Face::Axis(Axis::None) | Face::WizardField(WizardFieldKind::Label) => Some(Face::Dim),
Face::Axis(_) | Face::WizardField(_) => Some(Face::Default),
Face::ModeResting
| Face::ModeTextEntry
| Face::ModePanel(_)
| Face::ModeOverlay
| Face::ModeTransient
| Face::ModeTileSelect => Some(Face::Mode),
Face::ModeTextEntryOf(_) | Face::MinibufferOf(_) => Some(Face::ModeTextEntry),
}
}
/// The spelling a theme file keys this face by.
pub fn name(self) -> String {
match self {
Face::Default => "default".into(),
Face::Dim => "dim".into(),
Face::Axis(axis) => format!("axis.{}", axis_name(axis)),
Face::WizardField(kind) => format!("wizard.field.{}", field_kind_name(kind)),
Face::Mode => "mode".into(),
Face::ModeResting => "mode.resting".into(),
Face::ModeTextEntry => "mode.text-entry".into(),
Face::ModeTextEntryOf(entry) => format!("mode.text-entry.{}", entry.name()),
Face::ModePanel(panel) => format!("mode.panel.{}", panel.name()),
Face::ModeOverlay => "mode.overlay".into(),
Face::ModeTransient => "mode.transient".into(),
Face::ModeTileSelect => "mode.tile-select".into(),
Face::MinibufferOf(entry) => format!("minibuffer.{}", entry.name()),
}
}
pub fn from_name(name: &str) -> Option<Face> {
Face::all().into_iter().find(|face| face.name() == name)
}
/// The face the mode indicator is drawn in.
pub fn of_mode(mode: &AppMode) -> Face {
match mode {
AppMode::Normal | AppMode::RecordsNormal => Face::ModeResting,
AppMode::FormulaPanel => Face::ModePanel(Panel::Formula),
AppMode::CategoryPanel => Face::ModePanel(Panel::Category),
AppMode::ViewPanel => Face::ModePanel(Panel::View),
AppMode::ImportWizard | AppMode::Help => Face::ModeOverlay,
AppMode::Quit => Face::ModeTransient,
AppMode::TileSelect => Face::ModeTileSelect,
AppMode::Editing { .. }
| AppMode::RecordsEditing { .. }
| AppMode::FormulaEdit { .. }
| AppMode::CommandMode { .. }
| AppMode::CategoryAdd { .. }
| AppMode::ItemAdd { .. }
| AppMode::ExportPrompt { .. }
| AppMode::SavePrompt { .. } => TextEntry::of(mode)
.map(Face::ModeTextEntryOf)
.unwrap_or(Face::ModeTextEntry),
}
}
/// The face a text-entry mode's minibuffer is drawn in.
pub fn of_minibuffer(mode: &AppMode) -> Option<Face> {
TextEntry::of(mode).map(Face::MinibufferOf)
}
}
impl Serialize for Face {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.name())
}
}
impl<'de> Deserialize<'de> for Face {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let name = String::deserialize(d)?;
Face::from_name(&name).ok_or_else(|| de::Error::custom(format!("unknown face {name:?}")))
}
}
/// What a theme states about one face. `None` inherits from the parent;
/// `Some(Colour::Default)` states "the surface" and does not inherit.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FaceSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foreground: Option<Colour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<Colour>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<Weight>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slant: Option<Slant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reverse: Option<bool>,
}
impl FaceSpec {
fn or(self, parent: FaceSpec) -> FaceSpec {
FaceSpec {
foreground: self.foreground.or(parent.foreground),
background: self.background.or(parent.background),
weight: self.weight.or(parent.weight),
slant: self.slant.or(parent.slant),
underline: self.underline.or(parent.underline),
reverse: self.reverse.or(parent.reverse),
}
}
fn fg(colour: Colour) -> FaceSpec {
FaceSpec {
foreground: Some(colour),
..FaceSpec::default()
}
}
fn bg(colour: Colour) -> FaceSpec {
FaceSpec {
background: Some(colour),
..FaceSpec::default()
}
}
}
/// A face with every attribute concrete: what a front end translates.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedFace {
pub foreground: Colour,
pub background: Colour,
pub weight: Weight,
pub slant: Slant,
pub underline: bool,
pub reverse: bool,
}
/// Faces bound to values. Client-local: never a `Command`, never in
/// `ViewState`, never projected, never in `.improv` (ADR 004 clause 7).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Theme {
pub faces: BTreeMap<Face, FaceSpec>,
}
impl Theme {
/// The theme improvise ships: today's terminal rendering, palette-relative
/// throughout, so the user's terminal theme still applies.
pub fn shipped() -> Theme {
use AnsiSlot::*;
let named = Colour::Named;
let minibuffer = |accent| FaceSpec {
foreground: Some(named(accent)),
background: Some(Colour::Indexed(235)),
weight: Some(Weight::Bold),
..FaceSpec::default()
};
let faces = [
(Face::Dim, FaceSpec::fg(named(BrightBlack))),
(Face::Axis(Axis::Row), FaceSpec::fg(named(Green))),
(Face::Axis(Axis::Column), FaceSpec::fg(named(Blue))),
(Face::Axis(Axis::Page), FaceSpec::fg(named(Magenta))),
(Face::Axis(Axis::Filter), FaceSpec::fg(named(Yellow))),
(
Face::WizardField(WizardFieldKind::Category),
FaceSpec::fg(named(Green)),
),
(
Face::WizardField(WizardFieldKind::Measure),
FaceSpec::fg(named(Cyan)),
),
(
Face::WizardField(WizardFieldKind::TimeCategory),
FaceSpec::fg(named(Magenta)),
),
(
Face::Mode,
FaceSpec {
foreground: Some(named(Black)),
background: Some(named(BrightBlack)),
..FaceSpec::default()
},
),
(
Face::ModeTextEntryOf(TextEntry::Edit),
FaceSpec::bg(named(Green)),
),
(
Face::ModeTextEntryOf(TextEntry::Command),
FaceSpec::bg(named(Yellow)),
),
(Face::ModeTileSelect, FaceSpec::bg(named(Magenta))),
(Face::MinibufferOf(TextEntry::Edit), minibuffer(Green)),
(Face::MinibufferOf(TextEntry::ItemAdd), minibuffer(Green)),
(
Face::MinibufferOf(TextEntry::CategoryAdd),
minibuffer(Green),
),
(Face::MinibufferOf(TextEntry::Formula), minibuffer(Cyan)),
(Face::MinibufferOf(TextEntry::Command), minibuffer(Yellow)),
(Face::MinibufferOf(TextEntry::Export), minibuffer(Yellow)),
(Face::MinibufferOf(TextEntry::Save), minibuffer(Yellow)),
];
Theme {
faces: faces.into_iter().collect(),
}
}
/// Walk `face`'s inheritance chain once, so no front end ever does.
pub fn resolve(&self, face: Face) -> ResolvedFace {
let stated = std::iter::successors(Some(face), |f| f.parent())
.filter_map(|f| self.faces.get(&f).copied())
.fold(FaceSpec::default(), FaceSpec::or);
ResolvedFace {
foreground: stated.foreground.unwrap_or(Colour::Default),
background: stated.background.unwrap_or(Colour::Default),
weight: stated.weight.unwrap_or(Weight::Normal),
slant: stated.slant.unwrap_or(Slant::Normal),
underline: stated.underline.unwrap_or(false),
reverse: stated.reverse.unwrap_or(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_face_name_is_distinct_and_reads_back_as_that_face() {
let all = Face::all();
for face in &all {
assert_eq!(Face::from_name(&face.name()), Some(*face), "{face:?}");
}
let mut names: Vec<String> = all.iter().map(|f| f.name()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), all.len(), "two faces share a name");
}
#[test]
fn every_parent_is_a_face_listed_before_its_child() {
let all = Face::all();
for (i, face) in all.iter().enumerate() {
if let Some(parent) = face.parent() {
let at = all.iter().position(|f| *f == parent);
assert!(at.is_some_and(|p| p < i), "{face:?}'s parent {parent:?}");
}
}
}
/// ADR 004 clause 4: the shipped theme keeps the user's terminal palette.
#[test]
fn the_shipped_theme_states_no_absolute_colour() {
let theme = Theme::shipped();
for face in Face::all() {
let resolved = theme.resolve(face);
assert!(!resolved.foreground.is_absolute(), "{}", face.name());
assert!(!resolved.background.is_absolute(), "{}", face.name());
}
}
/// ADR 004: `minibuffer.*` inherits `mode.text-entry` directly, so a theme
/// that restyles text entry reaches every prompt. `context/test-evidence/protocol.md`.
#[test]
fn a_theme_that_restyles_text_entry_reaches_every_minibuffer() {
let mut theme = Theme::shipped();
let italic = FaceSpec {
slant: Some(Slant::Italic),
..FaceSpec::default()
};
theme.faces.insert(Face::ModeTextEntry, italic);
for entry in TextEntry::ALL {
let got = theme.resolve(Face::MinibufferOf(entry));
assert_eq!(got.slant, Slant::Italic, "{entry:?}");
}
}
/// 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]
fn resolution_takes_each_attribute_from_the_nearest_face_that_states_it() {
let named = Colour::Named;
let theme = Theme {
faces: [
(
Face::Default,
FaceSpec {
foreground: Some(named(AnsiSlot::White)),
slant: Some(Slant::Italic),
..FaceSpec::default()
},
),
(
Face::Mode,
FaceSpec {
foreground: Some(named(AnsiSlot::Black)),
background: Some(named(AnsiSlot::Blue)),
..FaceSpec::default()
},
),
(
Face::ModeTextEntryOf(TextEntry::Save),
FaceSpec {
background: Some(Colour::Default),
underline: Some(true),
..FaceSpec::default()
},
),
]
.into_iter()
.collect(),
};
let got = theme.resolve(Face::ModeTextEntryOf(TextEntry::Save));
assert_eq!(
got,
ResolvedFace {
foreground: named(AnsiSlot::Black),
background: Colour::Default,
weight: Weight::Normal,
slant: Slant::Italic,
underline: true,
reverse: false,
}
);
let sibling = theme.resolve(Face::ModeTextEntryOf(TextEntry::Export));
assert_eq!(sibling.background, named(AnsiSlot::Blue));
}
#[test]
fn a_theme_survives_its_own_json() {
let theme = Theme::shipped();
let text = serde_json::to_string_pretty(&theme).unwrap();
assert!(text.contains("\"minibuffer.category-add\""), "{text}");
assert_eq!(serde_json::from_str::<Theme>(&text).unwrap(), theme);
}
#[test]
fn a_colour_is_spelled_for_a_person_editing_the_file() {
let cases = [
(Colour::Default, "\"default\""),
(Colour::Named(AnsiSlot::BrightBlack), "\"bright-black\""),
(Colour::Indexed(235), "235"),
(Colour::Rgb(0x3f, 0x7a, 0x4d), "\"#3f7a4d\""),
];
for (colour, json) in cases {
assert_eq!(serde_json::to_string(&colour).unwrap(), json);
assert_eq!(serde_json::from_str::<Colour>(json).unwrap(), colour);
}
for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] {
assert!(serde_json::from_str::<Colour>(bad).is_err(), "{bad}");
}
}
#[test]
fn a_misspelt_face_or_attribute_is_refused_rather_than_ignored() {
let face = r#"{"faces":{"axis.rwo":{"foreground":"green"}}}"#;
assert!(serde_json::from_str::<Theme>(face).is_err());
let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#;
assert!(serde_json::from_str::<Theme>(attr).is_err());
}
#[test]
fn every_text_entry_mode_has_a_text_entry_role_and_no_other_mode_does() {
for mode in AppMode::all() {
assert_eq!(
TextEntry::of(&mode).is_some(),
mode.minibuffer().is_some(),
"{mode:?}"
);
}
}
}