3583359a9c
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
683 lines
23 KiB
Rust
683 lines
23 KiB
Rust
//! 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:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|