feat(faces): Face, Theme, resolve and the shipped theme in improvise-protocol (improvise-nn4s.1)
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:
committed by
Edward Langley
parent
b639266b0d
commit
abff562360
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Axis {
|
||||
Row,
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
//! 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,
|
||||
Minibuffer,
|
||||
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.push(Face::Minibuffer);
|
||||
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 | Face::Minibuffer => 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(_) => Some(Face::ModeTextEntry),
|
||||
Face::MinibufferOf(_) => Some(Face::Minibuffer),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::Minibuffer => "minibuffer".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 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::Minibuffer,
|
||||
FaceSpec {
|
||||
background: Some(Colour::Indexed(235)),
|
||||
weight: Some(Weight::Bold),
|
||||
..FaceSpec::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::Edit),
|
||||
FaceSpec::fg(named(Green)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::ItemAdd),
|
||||
FaceSpec::fg(named(Green)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::CategoryAdd),
|
||||
FaceSpec::fg(named(Green)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::Formula),
|
||||
FaceSpec::fg(named(Cyan)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::Command),
|
||||
FaceSpec::fg(named(Yellow)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::Export),
|
||||
FaceSpec::fg(named(Yellow)),
|
||||
),
|
||||
(
|
||||
Face::MinibufferOf(TextEntry::Save),
|
||||
FaceSpec::fg(named(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());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||
|
||||
pub mod basis;
|
||||
pub mod client;
|
||||
pub mod face;
|
||||
pub mod frame;
|
||||
pub mod help;
|
||||
pub mod key;
|
||||
@@ -23,6 +24,7 @@ mod vocabulary_is_composed;
|
||||
|
||||
pub use basis::{Basis, BasisCache, Rejection};
|
||||
pub use client::{ClientState, Sent};
|
||||
pub use face::{Face, Theme};
|
||||
pub use frame::{ClientFrame, ServerFrame};
|
||||
pub use help::{HelpLine, Hint, hint_line, hints, mode_label};
|
||||
pub use key::{KeyCode, KeyEvent, KeyModifiers};
|
||||
@@ -103,7 +105,7 @@ pub struct MinibufferConfig {
|
||||
|
||||
/// One of the three side panels. Paired with [`AppMode::panel`], which names
|
||||
/// the panel a mode is focused in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Panel {
|
||||
Formula,
|
||||
Category,
|
||||
|
||||
@@ -927,7 +927,7 @@ pub enum WizardStepKind {
|
||||
|
||||
/// What kind of thing a proposed field is. The semantic half of a colour
|
||||
/// decision; the terminal mapping lives in `draw.rs` (gotcha 26).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum WizardFieldKind {
|
||||
Category,
|
||||
Measure,
|
||||
|
||||
Reference in New Issue
Block a user