From abff5623609131d6bcf938b41524e6ba07e0f074 Mon Sep 17 00:00:00 2001 From: "Claude (agent-faces)" Date: Thu, 24 Sep 2026 21:54:11 -0700 Subject: [PATCH] feat(faces): Face, Theme, resolve and the shipped theme in improvise-protocol (improvise-nn4s.1) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-core/src/view/axis.rs | 2 +- crates/improvise-protocol/src/face.rs | 672 ++++++++++++++++++++++++ crates/improvise-protocol/src/lib.rs | 4 +- crates/improvise-protocol/src/render.rs | 2 +- 4 files changed, 677 insertions(+), 3 deletions(-) create mode 100644 crates/improvise-protocol/src/face.rs diff --git a/crates/improvise-core/src/view/axis.rs b/crates/improvise-core/src/view/axis.rs index ae55a145..d81295f6 100644 --- a/crates/improvise-core/src/view/axis.rs +++ b/crates/improvise-core/src/view/axis.rs @@ -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, diff --git a/crates/improvise-protocol/src/face.rs b/crates/improvise-protocol/src/face.rs new file mode 100644 index 00000000..83ccc6d3 --- /dev/null +++ b/crates/improvise-protocol/src/face.rs @@ -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::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(&self, s: S) -> Result { + 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(self, n: u64) -> Result { + u8::try_from(n) + .map(Colour::Indexed) + .map_err(|_| E::custom(format!("colour index {n} is outside 0-255"))) + } + + fn visit_str(self, s: &str) -> Result { + parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self)) + } +} + +fn parse_colour(s: &str) -> Option { + 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: D) -> Result { + 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 { + 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 { + 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 { + 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::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 { + TextEntry::of(mode).map(Face::MinibufferOf) + } +} + +impl Serialize for Face { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.name()) + } +} + +impl<'de> Deserialize<'de> for Face { + fn deserialize>(d: D) -> Result { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weight: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub underline: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse: Option, +} + +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, +} + +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 = 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::(&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::(json).unwrap(), colour); + } + for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] { + assert!(serde_json::from_str::(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::(face).is_err()); + let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#; + assert!(serde_json::from_str::(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:?}" + ); + } + } +} diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 6e535f44..e763c3cf 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -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, diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 8c8ba510..30ba0e6f 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -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,