Compare commits

..
Author SHA1 Message Date
Miguel Palhas 8157793282 feat(maestro): add TUI theme presets 2026-08-25 13:30:59 +01:00
7 changed files with 401 additions and 322 deletions
+59 -4
View File
@@ -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")
}
+31 -1
View File
@@ -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(&section).unwrap().palette, expected);
}
}
#[test]
fn defaults_cover_core_actions() {
+1
View File
@@ -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
+233 -94
View File
@@ -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"
);
}
}
}
+18 -103
View File
@@ -108,29 +108,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map.sidebar = Some(sidebar_area);
}
// The pane always owns its full border, so focus changes only recolour
// chrome and never change the rectangles handed to the input router.
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
// indicator) + flex pane area below. Saves the 4 rows the old
// `Length(3) + Borders::ALL` layout was eating before any content showed.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(right);
let tab_area = rows[0];
let pane_area = rows[1];
let separator_area = rows[1];
let pane_area = rows[2];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let pane_focused = inputs.state.focus == Focus::Pane;
let pane_block = Block::default()
.borders(Borders::ALL)
.border_style(focus_style(pane_focused));
let pane_inner = pane_block.inner(pane_area);
f.render_widget(pane_block, pane_area);
f.render_widget(
Block::default()
.borders(Borders::TOP)
.border_style(focus_style(pane_focused)),
separator_area,
);
if let Some(p) = inputs.pane {
p.render(f, pane_inner);
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_inner, pane_focused);
d.render(f, pane_area, pane_focused);
}
map.pane = Some(pane_area);
@@ -344,96 +351,4 @@ mod tests {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
#[test]
fn panel_rectangles_are_stable_when_focus_moves() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
map.unwrap()
};
let sidebar_focus = draw_map(Focus::Sidebar);
let pane_focus = draw_map(Focus::Pane);
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar);
assert_eq!(sidebar_focus.pane, pane_focus.pane);
}
#[test]
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: None,
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
terminal.backend().buffer().clone()
};
let sidebar_focused = render(Focus::Sidebar);
let pane_focused = render(Focus::Pane);
let p = palette::active();
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
assert_eq!(
sidebar_focused[(28, 1)].style().fg,
Some(p.border_unfocused)
);
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
}
}
+53 -120
View File
@@ -31,13 +31,12 @@ const META_INDENT: &str = " ";
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
const ACTIONS: &str = "⧉ ✕";
/// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
palette::active()
.surface(highlighted && focused)
.text_style()
/// Every card sits on the palette's card surface so cards read as cards
/// against the panel; the highlighted card swaps in the selection surface
/// instead (`Modifier::REVERSED` made the card unreadable against a
/// background).
fn card_base(highlighted: bool) -> Style {
palette::active().surface(highlighted).text_style()
}
/// Renderable sidebar state.
@@ -53,13 +52,14 @@ impl Sidebar {
self.render_with_focus(f, area, false);
}
/// Render the panel with a persistent border. Focus changes its colour
/// and whether the highlighted card claims the selection surface, never
/// its geometry.
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
/// focus colour when `focused`. Box borders eat too many rows/cols for
/// the v1 layout — a single vertical rule keeps the panel boundary
/// without stealing space.
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
let border_style = palette::active().border_style(focused);
let block = Block::default()
.borders(Borders::ALL)
.borders(Borders::RIGHT)
.border_style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
@@ -71,22 +71,22 @@ impl Sidebar {
))];
for s in &self.sessions {
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted, focused);
lines.push(Line::from(self.name_row(s, width, focused)));
let meta = self.meta_row(s, width, focused);
let base = card_base(highlighted);
lines.push(Line::from(self.name_row(s, width)));
let meta = self.meta_row(s, width);
lines.push(Line::from(pad_to_width(meta, width, base)));
}
f.render_widget(Paragraph::new(lines), inner);
}
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted, focused);
let base = card_base(highlighted);
// Line-1 actions are keyboard affordances shown on the highlighted
// row only; they reserve their width so the name truncates with `…`
// before it can ever reach them (docs/SIDEBAR.md).
@@ -102,16 +102,8 @@ impl Sidebar {
let sep = if highlighted { 1 } else { 0 };
let name_budget = usable.saturating_sub(prefix + sep);
let edge = highlighted && !focused;
let mut spans: Vec<Span<'static>> = vec![
Span::styled(
if edge { "▏ " } else { INDENT },
if edge {
base.fg(palette::active().focus_accent)
} else {
base
},
),
Span::styled(INDENT, base),
Span::styled(format!("{glyph} "), base),
];
spans.extend(
@@ -130,7 +122,7 @@ impl Sidebar {
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
@@ -139,22 +131,11 @@ impl Sidebar {
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted, focused);
let dot_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active()
.surface(highlighted && focused)
.muted_style();
let edge = highlighted && !focused;
let base = card_base(highlighted);
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
let muted = palette::active().surface(highlighted).muted_style();
let mut spans = vec![
Span::styled(
if edge { " ▏ " } else { META_INDENT },
if edge {
base.fg(palette::active().focus_accent)
} else {
base
},
),
Span::styled(META_INDENT, base),
Span::styled("●", dot_style),
];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
@@ -198,10 +179,8 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
fn activity_color(state: ActivityState, highlighted: bool) -> Color {
palette::active().surface(highlighted).activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -302,15 +281,9 @@ mod tests {
}
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
draw_with_focus(sidebar, w, h, true)
}
fn draw_with_focus(sidebar: &Sidebar, w: u16, h: u16, focused: bool) -> Buffer {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| sidebar.render_with_focus(f, f.area(), focused))
.unwrap();
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
terminal.backend().buffer().clone()
}
@@ -336,10 +309,7 @@ mod tests {
/// Row text without the trailing space padding and right border.
fn content(row: &str) -> &str {
row.trim()
.trim_start_matches('│')
.trim_end_matches('│')
.trim()
row.trim_end().trim_end_matches('│').trim_end()
}
#[test]
@@ -447,22 +417,22 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
assert_eq!(content(&all[1]), "sessions");
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[3].contains('●'),
all[2].contains('●'),
"line 2 must carry the dot: {:?}",
all[3]
all[2]
);
assert!(
all[3].contains("bash"),
all[2].contains("bash"),
"line 2 must carry the command: {:?}",
all[3]
all[2]
);
assert!(
!all[3].contains("10s"),
!all[2].contains("10s"),
"fresh cards have no duration: {:?}",
all[3]
all[2]
);
}
@@ -690,7 +660,7 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 42, 8));
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
@@ -782,14 +752,14 @@ mod tests {
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[2].contains("friendly name"),
all[1].contains("friendly name"),
"name must stay on line 1: {:?}",
all[2]
all[1]
);
assert!(
all[3].contains("bash"),
all[2].contains("bash"),
"command must be on line 2: {:?}",
all[3]
all[2]
);
}
@@ -803,14 +773,14 @@ mod tests {
};
let all = rows(&draw(&sb, 10, 6));
assert!(
all[3].contains('●'),
all[2].contains('●'),
"dot must survive narrow cards: {:?}",
all[3]
all[2]
);
assert!(
all[3].contains('…'),
all[2].contains('…'),
"command should truncate: {:?}",
all[3]
all[2]
);
}
@@ -828,8 +798,8 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
let meta = content(&all[2]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
}
@@ -847,13 +817,13 @@ mod tests {
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 6));
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(all[2].contains("⚠ 1 uncommitted file"), "{:?}", all[2]);
assert!(
all[3].contains("bash"),
all[2].contains("bash"),
"command must survive: {:?}",
all[3]
all[2]
);
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
assert!(all[2].contains('…'), "detail must truncate: {:?}", all[2]);
}
/// Reversed video makes a card unreadable against a background; cards
@@ -899,64 +869,27 @@ mod tests {
let p = palette::active();
assert_eq!(
buf[(1, claude_y as u16)].style().bg,
buf[(0, claude_y as u16)].style().bg,
Some(p.selection.bg),
"focused card must sit on the selection surface"
);
assert_eq!(
buf[(1, claude_y as u16)].style().fg,
buf[(0, claude_y as u16)].style().fg,
Some(p.selection.fg),
"focused card text must use the selection foreground role"
);
assert_eq!(
buf[(1, bash_y as u16)].style().bg,
buf[(0, bash_y as u16)].style().bg,
Some(p.card.bg),
"unfocused cards must still sit on the card surface"
);
// The panel itself (header row) stays on the terminal default.
assert!(
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
"panel must keep the terminal default background"
);
}
#[test]
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(Uuid::from_u128(1)),
attached: Default::default(),
};
let buf = draw_with_focus(&sb, 28, 8, false);
let y = (0..buf.area.height)
.find(|&y| (0..buf.area.width).any(|x| buf[(x, y)].symbol() == "c"))
.expect("highlighted session row");
assert_eq!(
buf[(1, y)].style().bg,
Some(palette::active().card.bg),
"unfocused highlight must not paint the selection surface"
);
assert!(
(0..buf.area.width).any(|x| buf[(x, y)].symbol() == "▏"),
"unfocused highlight must retain a visible edge marker"
);
let ordinary = draw_with_focus(
&Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: None,
attached: Default::default(),
},
28,
8,
false,
);
assert!(
!(0..ordinary.area.height)
.any(|row| (0..ordinary.area.width).any(|x| ordinary[(x, row)].symbol() == "▏")),
"ordinary unhighlighted cards must not carry the cursor marker"
);
}
/// The card background paints the full card width, not just the glyphs.
#[test]
fn card_background_covers_the_full_card_width() {
@@ -968,7 +901,7 @@ mod tests {
};
let buf = draw(&sb, 28, 8);
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
for x in 1..27 {
for x in 0..27 {
assert_eq!(
buf[(x, y)].style().bg,
Some(palette::active().card.bg),
+6
View File
@@ -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`.