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:
Claude (agent-faces)
2026-09-24 21:54:11 -07:00
committed by Edward Langley
parent b639266b0d
commit abff562360
4 changed files with 677 additions and 3 deletions
+1 -1
View File
@@ -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,
+672
View File
@@ -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:?}"
);
}
}
}
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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,