Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8157793282 | ||
|
|
a1816a0838 | ||
|
|
e01b0616b1 | ||
|
|
74d8d3c37a | ||
|
|
10a25e5b83 |
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
||||
pub const DIR_NAME: &str = "maestro";
|
||||
@@ -146,11 +147,14 @@ pub struct WebSection {
|
||||
|
||||
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
||||
///
|
||||
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
||||
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
||||
/// applied **over** the defaults, so a partial override is enough.
|
||||
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
|
||||
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
|
||||
/// are applied **over** the defaults, so a partial override is enough.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct TuiSection {
|
||||
/// Named palette preset. Defaults to the dark preset.
|
||||
#[serde(default, deserialize_with = "deserialize_theme")]
|
||||
pub theme: Option<TuiTheme>,
|
||||
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
||||
#[serde(default)]
|
||||
pub leader: Option<String>,
|
||||
@@ -167,6 +171,33 @@ pub struct TuiSection {
|
||||
pub keymap_insert: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TuiTheme {
|
||||
/// The terminal-native dark palette and the default.
|
||||
Dark,
|
||||
/// A high-contrast light palette.
|
||||
Light,
|
||||
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||
Ansi,
|
||||
}
|
||||
|
||||
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<String>::deserialize(deserializer)?;
|
||||
raw.map(|name| match name.as_str() {
|
||||
"dark" => Ok(TuiTheme::Dark),
|
||||
"light" => Ok(TuiTheme::Light),
|
||||
"ansi" => Ok(TuiTheme::Ansi),
|
||||
_ => Err(D::Error::custom(format!(
|
||||
"invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\""
|
||||
))),
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
||||
///
|
||||
/// Defined in shared config so every binary sees the same list: the web reads
|
||||
@@ -596,6 +627,30 @@ bind_addr = "127.0.0.1:9090"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
||||
assert_eq!(TuiSection::default().theme, None);
|
||||
let cfg = file_with("[tui]\ntheme = \"light\"\n");
|
||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Light));
|
||||
let cfg = file_with("[tui]\ntheme = \"ansi\"\n");
|
||||
assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
||||
let err =
|
||||
toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"solarized\"\n").unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("[tui].theme"),
|
||||
"diagnostic should name the field: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("solarized"),
|
||||
"diagnostic should name the invalid value: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn file_with(body: &str) -> MaestroConfigFile {
|
||||
toml::from_str(body).expect("parse")
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use maestro_config::TuiSection;
|
||||
use maestro_config::{TuiSection, TuiTheme};
|
||||
|
||||
use crate::input::InputKey;
|
||||
use crate::keymap::{Keymap, KeymapError, Mode};
|
||||
use crate::palette::Palette;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TuiConfig {
|
||||
pub palette: Palette,
|
||||
pub leader: InputKey,
|
||||
pub chord_timeout: Duration,
|
||||
pub keymap: Keymap,
|
||||
@@ -76,6 +78,11 @@ impl TuiConfig {
|
||||
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
||||
/// applied first, then user overrides on top.
|
||||
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
||||
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
||||
TuiTheme::Dark => Palette::DARK,
|
||||
TuiTheme::Light => Palette::LIGHT,
|
||||
TuiTheme::Ansi => Palette::ANSI,
|
||||
};
|
||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||
let leader = InputKey::parse(leader_str)
|
||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||
@@ -131,6 +138,7 @@ impl TuiConfig {
|
||||
}
|
||||
|
||||
Ok(TuiConfig {
|
||||
palette,
|
||||
leader,
|
||||
chord_timeout,
|
||||
keymap,
|
||||
@@ -143,6 +151,28 @@ impl TuiConfig {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::action::Action;
|
||||
use maestro_config::{TuiSection, TuiTheme};
|
||||
|
||||
#[test]
|
||||
fn absent_theme_selects_the_dark_default() {
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
assert_eq!(cfg.palette, Palette::DARK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_themes_select_the_matching_complete_palette() {
|
||||
for (theme, expected) in [
|
||||
(TuiTheme::Dark, Palette::DARK),
|
||||
(TuiTheme::Light, Palette::LIGHT),
|
||||
(TuiTheme::Ansi, Palette::ANSI),
|
||||
] {
|
||||
let section = TuiSection {
|
||||
theme: Some(theme),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_cover_core_actions() {
|
||||
|
||||
@@ -90,6 +90,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
||||
Err(_) => maestro_config::MaestroConfigFile::default(),
|
||||
};
|
||||
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
||||
palette::set_active(tui_cfg.palette);
|
||||
|
||||
let catalog = CatalogStore::default();
|
||||
let ws_url = daemon
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
//! the selected card uses the selection surface's own state colours; the dot
|
||||
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||
//!
|
||||
//! One palette ships today, reached through [`active`]. Selecting between
|
||||
//! presets from config is a separate issue; [`active`] is the seam it
|
||||
//! replaces.
|
||||
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||
//! [`Palette::ANSI`] uses terminal-provided ANSI colours.
|
||||
//!
|
||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||
//! pane and pass through `pane::translate_color` verbatim.
|
||||
@@ -130,40 +129,162 @@ pub struct Palette {
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The one palette that ships today.
|
||||
pub const DEFAULT: Palette = Palette {
|
||||
/// The dark terminal-native palette and the default preset.
|
||||
pub const DARK: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0x14, 0x16, 0x1b),
|
||||
fg: Color::Rgb(0xd6, 0xdc, 0xe5),
|
||||
muted: Color::Rgb(0x9a, 0xa3, 0xb2),
|
||||
dim: Color::Rgb(0x6b, 0x72, 0x80),
|
||||
active: Color::Rgb(0x3f, 0xb9, 0x50),
|
||||
idle: Color::Rgb(0x8b, 0x94, 0x9e),
|
||||
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
|
||||
error: Color::Rgb(0xf8, 0x51, 0x49),
|
||||
bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
|
||||
muted: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||
dim: Color::Rgb(0x85, 0x8f, 0x9e),
|
||||
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
idle: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0x2b, 0x3f, 0x6e),
|
||||
fg: Color::Rgb(0xf0, 0xf4, 0xfa),
|
||||
muted: Color::Rgb(0xc0, 0xc9, 0xda),
|
||||
dim: Color::Rgb(0x93, 0xa0, 0xbc),
|
||||
active: Color::Rgb(0x7e, 0xe7, 0x87),
|
||||
idle: Color::Rgb(0xb6, 0xc0, 0xd4),
|
||||
awaiting: Color::Rgb(0xf2, 0xcc, 0x60),
|
||||
error: Color::Rgb(0xff, 0x94, 0x92),
|
||||
bg: Color::Rgb(0x3d, 0x51, 0x44),
|
||||
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
|
||||
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
|
||||
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
|
||||
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
|
||||
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0x14, 0x16, 0x1b),
|
||||
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
border_unfocused: Color::Rgb(0x6b, 0x72, 0x80),
|
||||
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
group_row: Color::Rgb(0x8f, 0xa3, 0xc8),
|
||||
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
|
||||
mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11),
|
||||
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
|
||||
mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11),
|
||||
error_text: Color::Rgb(0xf8, 0x51, 0x49),
|
||||
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
|
||||
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
|
||||
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||
};
|
||||
|
||||
/// A high-contrast light peer to [`Self::DARK`].
|
||||
pub const LIGHT: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
fg: Color::Rgb(0x27, 0x31, 0x3d),
|
||||
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||
dim: Color::Rgb(0x68, 0x78, 0x8a),
|
||||
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
|
||||
error: Color::Rgb(0xb4, 0x23, 0x18),
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Rgb(0xb5, 0xcf, 0xba),
|
||||
fg: Color::Rgb(0x1d, 0x2a, 0x24),
|
||||
muted: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
dim: Color::Rgb(0x52, 0x68, 0x5a),
|
||||
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
||||
idle: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
||||
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||
},
|
||||
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
group_row: Color::Rgb(0x3f, 0x55, 0x45),
|
||||
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
|
||||
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
|
||||
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
||||
};
|
||||
|
||||
/// A palette of named ANSI colours for terminals without truecolour.
|
||||
pub const ANSI: Palette = Palette {
|
||||
card: Surface {
|
||||
bg: Color::Black,
|
||||
fg: Color::White,
|
||||
muted: Color::Gray,
|
||||
dim: Color::DarkGray,
|
||||
active: Color::Green,
|
||||
idle: Color::Gray,
|
||||
awaiting: Color::Yellow,
|
||||
error: Color::Red,
|
||||
},
|
||||
selection: Surface {
|
||||
bg: Color::Blue,
|
||||
fg: Color::White,
|
||||
muted: Color::White,
|
||||
dim: Color::LightBlue,
|
||||
active: Color::LightGreen,
|
||||
idle: Color::White,
|
||||
awaiting: Color::LightYellow,
|
||||
error: Color::LightRed,
|
||||
},
|
||||
chrome_bg: Color::Black,
|
||||
border_focused: Color::LightGreen,
|
||||
border_unfocused: Color::DarkGray,
|
||||
focus_accent: Color::LightGreen,
|
||||
group_row: Color::LightCyan,
|
||||
mode_normal_bg: Color::Green,
|
||||
mode_normal_fg: Color::Black,
|
||||
mode_insert_bg: Color::Yellow,
|
||||
mode_insert_fg: Color::Black,
|
||||
error_text: Color::LightRed,
|
||||
};
|
||||
|
||||
/// The default preset, retained as a named compatibility constant.
|
||||
pub const DEFAULT: Palette = Self::DARK;
|
||||
|
||||
/// All preset names and their complete palettes.
|
||||
pub fn presets() -> [(&'static str, Palette); 3] {
|
||||
[
|
||||
("dark", Self::DARK),
|
||||
("light", Self::LIGHT),
|
||||
("ansi", Self::ANSI),
|
||||
]
|
||||
}
|
||||
|
||||
/// Resolve a config name to a palette.
|
||||
pub fn named(name: &str) -> Option<Palette> {
|
||||
match name {
|
||||
"dark" => Some(Self::DARK),
|
||||
"light" => Some(Self::LIGHT),
|
||||
"ansi" => Some(Self::ANSI),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every role, including the backgrounds it is painted against.
|
||||
pub fn roles(&self) -> [(&'static str, Color); 26] {
|
||||
[
|
||||
("card.bg", self.card.bg),
|
||||
("card.fg", self.card.fg),
|
||||
("card.muted", self.card.muted),
|
||||
("card.dim", self.card.dim),
|
||||
("card.active", self.card.active),
|
||||
("card.idle", self.card.idle),
|
||||
("card.awaiting", self.card.awaiting),
|
||||
("card.error", self.card.error),
|
||||
("selection.bg", self.selection.bg),
|
||||
("selection.fg", self.selection.fg),
|
||||
("selection.muted", self.selection.muted),
|
||||
("selection.dim", self.selection.dim),
|
||||
("selection.active", self.selection.active),
|
||||
("selection.idle", self.selection.idle),
|
||||
("selection.awaiting", self.selection.awaiting),
|
||||
("selection.error", self.selection.error),
|
||||
("chrome_bg", self.chrome_bg),
|
||||
("border_focused", self.border_focused),
|
||||
("border_unfocused", self.border_unfocused),
|
||||
("focus_accent", self.focus_accent),
|
||||
("group_row", self.group_row),
|
||||
("mode_normal_bg", self.mode_normal_bg),
|
||||
("mode_normal_fg", self.mode_normal_fg),
|
||||
("mode_insert_bg", self.mode_insert_bg),
|
||||
("mode_insert_fg", self.mode_insert_fg),
|
||||
("error_text", self.error_text),
|
||||
]
|
||||
}
|
||||
|
||||
/// The surface a card sits on: the selection surface when it is the
|
||||
/// highlighted card, the card surface otherwise.
|
||||
pub fn surface(&self, selected: bool) -> &Surface {
|
||||
@@ -218,9 +339,20 @@ impl Default for Palette {
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette in force. Config-driven preset selection lands here.
|
||||
fn active_slot() -> &'static std::sync::RwLock<Palette> {
|
||||
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
|
||||
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
|
||||
}
|
||||
|
||||
/// Set the palette used by the running TUI.
|
||||
pub(crate) fn set_active(palette: Palette) {
|
||||
*active_slot().write().expect("active palette lock poisoned") = palette;
|
||||
}
|
||||
|
||||
/// The palette in force. Config-driven preset selection lands here at TUI
|
||||
/// startup, before the first frame is drawn.
|
||||
pub fn active() -> Palette {
|
||||
Palette::DEFAULT
|
||||
*active_slot().read().expect("active palette lock poisoned")
|
||||
}
|
||||
|
||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||
@@ -264,30 +396,43 @@ mod tests {
|
||||
/// decoration and only has to clear the 3:1 non-text threshold.
|
||||
#[test]
|
||||
fn every_role_is_legible_on_its_own_surface() {
|
||||
let p = active();
|
||||
for (name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||
for (role, color) in surface.roles() {
|
||||
let ratio = contrast_ratio(surface.bg, color)
|
||||
.unwrap_or_else(|| panic!("{name}.{role} must be an RGB value"));
|
||||
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{name}.{role} contrast {ratio:.2} is below {floor}"
|
||||
);
|
||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||
for (role, color) in surface.roles() {
|
||||
let ratio = contrast_ratio(surface.bg, color)
|
||||
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
|
||||
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_supplies_every_role() {
|
||||
for (name, p) in Palette::presets() {
|
||||
assert_eq!(p.roles().len(), 26, "{name} role set is incomplete");
|
||||
}
|
||||
assert_eq!(Palette::named("dark"), Some(Palette::DARK));
|
||||
assert_eq!(Palette::named("light"), Some(Palette::LIGHT));
|
||||
assert_eq!(Palette::named("ansi"), Some(Palette::ANSI));
|
||||
assert_eq!(Palette::named("unknown"), None);
|
||||
}
|
||||
|
||||
/// The selection background exists to be seen as a different surface, not
|
||||
/// merely a slightly different shade of the card.
|
||||
#[test]
|
||||
fn selection_background_is_distinguishable_from_the_card_background() {
|
||||
let p = active();
|
||||
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||
assert!(
|
||||
ratio >= 1.6,
|
||||
"card and selection backgrounds differ by only {ratio:.2}"
|
||||
);
|
||||
for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||
assert!(
|
||||
ratio >= 1.6,
|
||||
"{name} card and selection backgrounds differ by only {ratio:.2}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bug this palette fixes: a state colour chosen for the dark card
|
||||
@@ -295,13 +440,14 @@ mod tests {
|
||||
/// selection surface carries its own — distinct — state colours.
|
||||
#[test]
|
||||
fn selection_state_colours_are_not_the_card_state_colours() {
|
||||
let p = active();
|
||||
for state in STATES {
|
||||
assert_ne!(
|
||||
p.card.activity(state),
|
||||
p.selection.activity(state),
|
||||
"{state:?} must have a selection-specific colour"
|
||||
);
|
||||
for (name, p) in Palette::presets() {
|
||||
for state in STATES {
|
||||
assert_ne!(
|
||||
p.card.activity(state),
|
||||
p.selection.activity(state),
|
||||
"{name} {state:?} must have a selection-specific colour"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,44 +456,37 @@ mod tests {
|
||||
/// actually painted on; they must never rely on the terminal default.
|
||||
#[test]
|
||||
fn chrome_roles_are_rgb_values() {
|
||||
let p = active();
|
||||
for (name, color) in [
|
||||
("chrome_bg", p.chrome_bg),
|
||||
("border_focused", p.border_focused),
|
||||
("border_unfocused", p.border_unfocused),
|
||||
("focus_accent", p.focus_accent),
|
||||
("group_row", p.group_row),
|
||||
("mode_normal_bg", p.mode_normal_bg),
|
||||
("mode_normal_fg", p.mode_normal_fg),
|
||||
("mode_insert_bg", p.mode_insert_bg),
|
||||
("mode_insert_fg", p.mode_insert_fg),
|
||||
("error_text", p.error_text),
|
||||
] {
|
||||
assert!(
|
||||
relative_luminance(color).is_some(),
|
||||
"{name} must be an RGB value"
|
||||
);
|
||||
}
|
||||
// The mode indicator paints fg on bg; both pairs must stay readable.
|
||||
for (name, fg, bg) in [
|
||||
("normal", p.mode_normal_fg, p.mode_normal_bg),
|
||||
("insert", p.mode_insert_fg, p.mode_insert_bg),
|
||||
] {
|
||||
let ratio = contrast_ratio(fg, bg).unwrap();
|
||||
assert!(ratio >= 4.5, "mode {name} contrast {ratio:.2} is below 4.5");
|
||||
}
|
||||
for (name, fg, floor) in [
|
||||
("border_focused", p.border_focused, 3.0),
|
||||
("border_unfocused", p.border_unfocused, 3.0),
|
||||
("focus_accent", p.focus_accent, 3.0),
|
||||
("group_row", p.group_row, 4.5),
|
||||
("error_text", p.error_text, 4.5),
|
||||
] {
|
||||
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||
);
|
||||
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||
for (name, color) in p.roles().into_iter().skip(16) {
|
||||
assert!(
|
||||
relative_luminance(color).is_some(),
|
||||
"{preset}.{name} must be an RGB value"
|
||||
);
|
||||
}
|
||||
// The mode indicator paints fg on bg; both pairs must stay readable.
|
||||
for (name, fg, bg) in [
|
||||
("normal", p.mode_normal_fg, p.mode_normal_bg),
|
||||
("insert", p.mode_insert_fg, p.mode_insert_bg),
|
||||
] {
|
||||
let ratio = contrast_ratio(fg, bg).unwrap();
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
|
||||
);
|
||||
}
|
||||
for (name, fg, floor) in [
|
||||
("border_focused", p.border_focused, 3.0),
|
||||
("border_unfocused", p.border_unfocused, 3.0),
|
||||
("focus_accent", p.focus_accent, 3.0),
|
||||
("group_row", p.group_row, 4.5),
|
||||
("error_text", p.error_text, 4.5),
|
||||
] {
|
||||
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
|
||||
assert!(
|
||||
ratio >= floor,
|
||||
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,26 +151,27 @@ impl Sidebar {
|
||||
}
|
||||
let command = Span::styled(format!(" {}", s.command), muted);
|
||||
|
||||
// Reserve the trailing fragments so detail truncates before them;
|
||||
// the command is itself the final truncation target on narrow cards.
|
||||
// Reserve the triggered status fragments before detail and command.
|
||||
// If the row is exceptionally narrow, keep one column for the
|
||||
// unconditional command and truncate the status tail first. Detail
|
||||
// then receives only space left after the full command reservation;
|
||||
// when the full command cannot fit, it is the final truncation target.
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let command_w = command.width();
|
||||
let available = width.saturating_sub(head_w);
|
||||
if tail_w + command_w > available {
|
||||
spans.extend(fit_to_width(vec![command], available));
|
||||
return spans;
|
||||
}
|
||||
let command_floor = usize::from(available > 0);
|
||||
let tail = fit_to_width(tail, available.saturating_sub(command_floor));
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let command_w = command.width().min(available.saturating_sub(tail_w));
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
let budget = available.saturating_sub(tail_w + command_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
spans.push(command);
|
||||
fit_to_width(spans, width)
|
||||
spans.extend(fit_to_width(vec![command], command_w));
|
||||
spans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +784,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_warning_survives_before_a_truncated_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.command = "a command that cannot fit".into();
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 29, 6));
|
||||
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
|
||||
let meta = content(&all[2]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_detail_yields_to_status_and_command() {
|
||||
let mut s = session_named(1, "name");
|
||||
s.activity_detail = Some("a very long agent-reported detail".into());
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
"command must survive: {:?}",
|
||||
all[2]
|
||||
);
|
||||
assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||
#[test]
|
||||
|
||||
@@ -134,6 +134,7 @@ cover every v1 action — only override what you want to change:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
theme = "dark" # dark (default), light, or ansi
|
||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||
|
||||
@@ -152,6 +153,11 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||
# want extra Insert-mode bindings)
|
||||
```
|
||||
|
||||
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||
An unknown name is rejected as an `[tui].theme` configuration error.
|
||||
|
||||
The full action vocabulary is the `Action` enum in
|
||||
`crates/maestro-tui/src/action.rs`.
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
showsMetaLine,
|
||||
} from "./activity.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
describe("activityHeldMs", () => {
|
||||
it("measures time spent in the current state", () => {
|
||||
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
|
||||
expect(activityColorSlug("Teleported")).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
activity: "Active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("showsMetaLine", () => {
|
||||
const fresh = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
it("stays hidden for a freshly-active session", () => {
|
||||
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
|
||||
});
|
||||
|
||||
it("appears once the state has been held past 30 seconds", () => {
|
||||
const held = new Date(Date.now() - 31_000).toISOString();
|
||||
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
|
||||
});
|
||||
|
||||
it("appears for blocked work, exited sessions and reported errors", () => {
|
||||
expect(
|
||||
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
|
||||
).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("needs no activity timestamp when another trigger holds", () => {
|
||||
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+3
-15
@@ -2,11 +2,10 @@
|
||||
// the shared duration format. Both clients compute it identically; the TUI
|
||||
// mirrors this in crates/maestro-tui.
|
||||
|
||||
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
|
||||
import { sessionBlockers } from "./types.js";
|
||||
import type { ActivityState, IsoTimestamp } from "./types.js";
|
||||
|
||||
/** A state held shorter than this keeps the card to one line (contract rule 1). */
|
||||
const META_LINE_MIN_HOLD_MS = 30_000;
|
||||
/** A state held shorter than this has no duration fragment. */
|
||||
export const META_LINE_MIN_HOLD_MS = 30_000;
|
||||
|
||||
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
||||
// implausibly old is treated as "never observed" rather than a huge duration.
|
||||
@@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string {
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
/** Line 2 renders only when at least one contract trigger holds. */
|
||||
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
return (
|
||||
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
|
||||
sessionBlockers(s) !== null ||
|
||||
s.status === "Dead" ||
|
||||
s.activity === "Error"
|
||||
);
|
||||
}
|
||||
|
||||
+16
-2
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("stays a single line while freshly active", () => {
|
||||
it("always renders line 2 for a freshly spawned session", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("renders the command when the session name equals it", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||
command: "claude",
|
||||
metadata: { name: "claude" },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
|
||||
+11
-14
@@ -4,7 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
META_LINE_MIN_HOLD_MS,
|
||||
} from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
@@ -28,16 +33,13 @@ const STATE_WORDS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const name = sessionName(s);
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration = held !== null ? activityDurationLabel(held) : null;
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
const duration =
|
||||
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
@@ -49,9 +51,7 @@ function metaLine(s: Session, now: number): string {
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
showCmd
|
||||
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
|
||||
: "",
|
||||
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
@@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
|
||||
// the actions reserve their width, so the name truncates before it can ever
|
||||
// reach them. Line 2 renders only when a contract trigger holds.
|
||||
const showMeta = showsMetaLine(s, now);
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
@@ -118,7 +115,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${showMeta ? metaLine(s, now) : ""}
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
|
||||
+12
-4
@@ -736,7 +736,6 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 30px;
|
||||
margin: 1px var(--sp-2);
|
||||
padding: 4px var(--sp-3);
|
||||
border-radius: var(--r-1);
|
||||
@@ -787,12 +786,13 @@ textarea {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
|
||||
always, line 2 (meta) only when it has something to say. */
|
||||
/* #120: every session card has two lines, so its row height is stable. */
|
||||
.tree-leaf.session-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
|
||||
height: 49px;
|
||||
}
|
||||
|
||||
.card-line1 {
|
||||
@@ -855,7 +855,8 @@ textarea {
|
||||
ellipsis so it never pushes the trailing fragments off the line. */
|
||||
.activity-detail {
|
||||
color: var(--faint);
|
||||
flex: 0 1 auto;
|
||||
/* Consume only the space left after the trailing status + command. */
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -866,12 +867,19 @@ textarea {
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
color: var(--faint);
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status fragments stay intact. The unconditional command is the final
|
||||
truncation target when even the complete trailing set cannot fit. */
|
||||
.leaf-cmd {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||
name (#107). They reserve their width even while hover-faded. */
|
||||
.leaf-actions {
|
||||
|
||||
Reference in New Issue
Block a user