Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9b027bd1 | ||
|
|
4c3f65027d | ||
|
|
76eb3d1386 | ||
|
|
877f409194 | ||
|
|
39d4014dcb | ||
|
|
f02510465c | ||
|
|
8e251bf8be | ||
|
|
bc95d16a98 | ||
|
|
f0108c5645 | ||
|
|
c534cee7cc |
@@ -41,7 +41,7 @@ use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
@@ -1510,7 +1510,7 @@ impl DraftPane {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
format!("error: {error}"),
|
||||
Style::default().fg(Color::Red),
|
||||
crate::palette::active().error_style(),
|
||||
))),
|
||||
row,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod projects;
|
||||
pub mod render;
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Named colour roles for the TUI.
|
||||
//!
|
||||
//! Every colour the TUI paints on its own chrome comes from here, named for
|
||||
//! what it means rather than what it looks like: the card surface, the
|
||||
//! selection surface, the panel border in its focused and unfocused forms,
|
||||
//! the text weights (`fg` / `muted` / `dim`), and one role per
|
||||
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
|
||||
//! a colour out.
|
||||
//!
|
||||
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
|
||||
//! than of the user's terminal theme — and can therefore be asserted (see
|
||||
//! [`contrast_ratio`] and this module's tests).
|
||||
//!
|
||||
//! Each surface carries its own activity colours. A state colour picked for
|
||||
//! the dark card background is not legible on the selection background, so
|
||||
//! 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.
|
||||
//!
|
||||
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||
//! pane and pass through `pane::translate_color` verbatim.
|
||||
|
||||
use maestro_protocol::ActivityState;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// A background and the foreground roles legible on it.
|
||||
///
|
||||
/// The sidebar has two: the card surface every card sits on, and the
|
||||
/// selection surface the highlighted card swaps in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Surface {
|
||||
/// The surface itself.
|
||||
pub bg: Color,
|
||||
/// Primary text on this surface.
|
||||
pub fg: Color,
|
||||
/// Secondary text — the blocked-work, exited, detail and command
|
||||
/// fragments of a card's meta line.
|
||||
pub muted: Color,
|
||||
/// The quietest legible text on this surface, for decoration that must
|
||||
/// not compete with content.
|
||||
pub dim: Color,
|
||||
/// `ActivityState::Active`.
|
||||
pub active: Color,
|
||||
/// `ActivityState::Idle`.
|
||||
pub idle: Color,
|
||||
/// `ActivityState::AwaitingInput`.
|
||||
pub awaiting: Color,
|
||||
/// `ActivityState::Error`.
|
||||
pub error: Color,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
/// The bare surface: a background with no foreground override.
|
||||
pub fn base(&self) -> Style {
|
||||
Style::default().bg(self.bg)
|
||||
}
|
||||
|
||||
/// Primary text on this surface.
|
||||
pub fn text_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.fg)
|
||||
}
|
||||
|
||||
/// Secondary text on this surface.
|
||||
pub fn muted_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.muted)
|
||||
}
|
||||
|
||||
/// The quietest text on this surface.
|
||||
pub fn dim_style(&self) -> Style {
|
||||
Style::default().bg(self.bg).fg(self.dim)
|
||||
}
|
||||
|
||||
/// The colour carrying `state` on this surface.
|
||||
pub fn activity(&self, state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => self.active,
|
||||
ActivityState::Idle => self.idle,
|
||||
ActivityState::AwaitingInput => self.awaiting,
|
||||
ActivityState::Error => self.error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every foreground role on this surface, paired with its role name.
|
||||
/// Used by the contrast tests, and by anything that needs to reason about
|
||||
/// the surface as a whole.
|
||||
pub fn roles(&self) -> [(&'static str, Color); 7] {
|
||||
[
|
||||
("fg", self.fg),
|
||||
("muted", self.muted),
|
||||
("dim", self.dim),
|
||||
("active", self.active),
|
||||
("idle", self.idle),
|
||||
("awaiting", self.awaiting),
|
||||
("error", self.error),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The full set of colour roles the TUI paints with.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Palette {
|
||||
/// The surface an unselected card sits on.
|
||||
pub card: Surface,
|
||||
/// The surface the highlighted card swaps in.
|
||||
pub selection: Surface,
|
||||
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
|
||||
/// paint this background too; otherwise its contrast depends on the
|
||||
/// operator's terminal theme.
|
||||
pub chrome_bg: Color,
|
||||
/// Panel border while that panel holds focus.
|
||||
pub border_focused: Color,
|
||||
/// Panel border while it does not.
|
||||
pub border_unfocused: Color,
|
||||
/// Focus affordances that are not the border itself — markers, titles and
|
||||
/// the like on the panel that holds focus.
|
||||
pub focus_accent: Color,
|
||||
/// Sidebar group headings.
|
||||
pub group_row: Color,
|
||||
/// Status-bar mode indicator, normal mode.
|
||||
pub mode_normal_bg: Color,
|
||||
pub mode_normal_fg: Color,
|
||||
/// Status-bar mode indicator, insert mode.
|
||||
pub mode_insert_bg: Color,
|
||||
pub mode_insert_fg: Color,
|
||||
/// Error text outside a card — form errors, failed actions.
|
||||
pub error_text: Color,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
/// The one palette that ships today.
|
||||
pub const DEFAULT: 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),
|
||||
},
|
||||
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),
|
||||
},
|
||||
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),
|
||||
};
|
||||
|
||||
/// 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 {
|
||||
if selected {
|
||||
&self.selection
|
||||
} else {
|
||||
&self.card
|
||||
}
|
||||
}
|
||||
|
||||
/// Border style for a panel, by whether it holds focus.
|
||||
pub fn border_style(&self, focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().bg(self.chrome_bg).fg(self.border_focused)
|
||||
} else {
|
||||
Style::default()
|
||||
.bg(self.chrome_bg)
|
||||
.fg(self.border_unfocused)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style for a sidebar group heading.
|
||||
pub fn group_row_style(&self) -> Style {
|
||||
Style::default()
|
||||
.bg(self.chrome_bg)
|
||||
.fg(self.group_row)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
/// Style for the status bar's mode indicator.
|
||||
pub fn mode_style(&self, insert: bool) -> Style {
|
||||
if insert {
|
||||
Style::default()
|
||||
.fg(self.mode_insert_fg)
|
||||
.bg(self.mode_insert_bg)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(self.mode_normal_fg)
|
||||
.bg(self.mode_normal_bg)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style for error text outside a card.
|
||||
pub fn error_style(&self) -> Style {
|
||||
Style::default().bg(self.chrome_bg).fg(self.error_text)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Palette {
|
||||
fn default() -> Self {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// The palette in force. Config-driven preset selection lands here.
|
||||
pub fn active() -> Palette {
|
||||
Palette::DEFAULT
|
||||
}
|
||||
|
||||
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||
/// luminance — they are whatever the terminal theme says — so they return
|
||||
/// `None` and cannot be contrast-checked.
|
||||
pub fn relative_luminance(color: Color) -> Option<f64> {
|
||||
let Color::Rgb(r, g, b) = color else {
|
||||
return None;
|
||||
};
|
||||
fn channel(v: u8) -> f64 {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
|
||||
/// (black on white). `None` if either colour is not RGB.
|
||||
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
|
||||
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||
Some((hi + 0.05) / (lo + 0.05))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const STATES: [ActivityState; 4] = [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
];
|
||||
|
||||
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug this palette fixes: a state colour chosen for the dark card
|
||||
/// background is not legible on the selection background, so the
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Other panels' roles are RGB too, so a preset swap moves all of them.
|
||||
/// Foregrounds are checked against the chrome background they are
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_ratio_matches_known_values() {
|
||||
let black = Color::Rgb(0, 0, 0);
|
||||
let white = Color::Rgb(255, 255, 255);
|
||||
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
|
||||
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
|
||||
assert_eq!(contrast_ratio(Color::Cyan, white), None);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// PTY cell colours pass through verbatim: they belong to the program
|
||||
/// running in the pane, not to the TUI's own chrome, so they are deliberately
|
||||
/// not palette roles (see `crate::palette`).
|
||||
fn translate_color(c: vt100::Color) -> Option<Color> {
|
||||
match c {
|
||||
vt100::Color::Default => None,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! hit-test mouse clicks.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
@@ -11,6 +11,7 @@ use ratatui::Frame;
|
||||
use crate::input::Region;
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
@@ -193,14 +194,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// Border style for a panel based on whether it currently has focus.
|
||||
/// Focused = cyan; unfocused = the terminal default.
|
||||
/// Border style for a panel based on whether it currently has focus. Both
|
||||
/// forms are palette roles (`border_focused` / `border_unfocused`).
|
||||
fn focus_style(focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
palette::active().border_style(focused)
|
||||
}
|
||||
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
@@ -219,10 +216,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: back to normal",
|
||||
};
|
||||
let mode_style = match state.mode {
|
||||
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
|
||||
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
let line = ratatui::text::Line::from(vec![
|
||||
Span::styled(format!(" {mode_label} "), mode_style),
|
||||
Span::raw(format!(" {focus_label} • {hint}")),
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
//!
|
||||
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! blocked-work and exited fragments when they apply. Absent entirely when
|
||||
//! it has nothing to say.
|
||||
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -16,8 +15,10 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Time-in-state alone earns a meta line only once the session has sat in
|
||||
/// its current activity state this long (docs/SIDEBAR.md).
|
||||
use crate::palette;
|
||||
|
||||
/// Time-in-state earns a duration fragment once the session has sat in its
|
||||
/// current activity state this long (docs/SIDEBAR.md).
|
||||
const META_LINE_AFTER_SECS: i64 = 30;
|
||||
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
||||
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
||||
@@ -30,16 +31,12 @@ const META_INDENT: &str = " ";
|
||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||
const ACTIONS: &str = "⧉ ✕";
|
||||
|
||||
/// Every card sits on this background so cards read as cards against the
|
||||
/// panel; the highlighted card swaps it for the focus background instead
|
||||
/// (`Modifier::REVERSED` made the card unreadable against a background).
|
||||
const CARD_BG: Color = Color::Black;
|
||||
/// 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 {
|
||||
if highlighted {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().bg(CARD_BG)
|
||||
}
|
||||
palette::active().surface(highlighted).text_style()
|
||||
}
|
||||
|
||||
/// Renderable sidebar state.
|
||||
@@ -60,11 +57,7 @@ impl Sidebar {
|
||||
/// 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 = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let border_style = palette::active().border_style(focused);
|
||||
let block = Block::default()
|
||||
.borders(Borders::RIGHT)
|
||||
.border_style(border_style);
|
||||
@@ -80,9 +73,8 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
lines.push(Line::from(self.name_row(s, width)));
|
||||
if let Some(meta) = self.meta_row(s, width) {
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
let meta = self.meta_row(s, width);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
@@ -121,10 +113,7 @@ impl Sidebar {
|
||||
);
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(fill),
|
||||
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
|
||||
));
|
||||
spans.push(Span::styled(" ".repeat(fill), base));
|
||||
}
|
||||
if highlighted {
|
||||
spans.push(Span::styled(ACTIONS, base));
|
||||
@@ -132,8 +121,8 @@ impl Sidebar {
|
||||
spans
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
|
||||
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
|
||||
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||
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);
|
||||
@@ -141,28 +130,18 @@ impl Sidebar {
|
||||
// on line 1; the kill confirm names the specifics.
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let errored = s.activity == ActivityState::Error;
|
||||
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
|
||||
&& blocked.is_none()
|
||||
&& !dead
|
||||
&& !errored
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = card_base(self.highlight == Some(s.id));
|
||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
|
||||
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
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(META_INDENT, base),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held {
|
||||
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
}
|
||||
// The trailing fragments are measured up front so a long detail
|
||||
// truncates ahead of them instead of pushing them off the line
|
||||
// (docs/SIDEBAR.md).
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
@@ -170,9 +149,19 @@ impl Sidebar {
|
||||
if dead {
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
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.
|
||||
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;
|
||||
}
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
@@ -180,19 +169,17 @@ impl Sidebar {
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
Some(fit_to_width(spans, width))
|
||||
spans.push(command);
|
||||
fit_to_width(spans, width)
|
||||
}
|
||||
}
|
||||
|
||||
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
|
||||
/// word is never spelled out — this colour carries it.
|
||||
fn activity_color(state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => Color::Green,
|
||||
ActivityState::Idle => Color::DarkGray,
|
||||
ActivityState::AwaitingInput => Color::Yellow,
|
||||
ActivityState::Error => Color::Red,
|
||||
}
|
||||
/// Palette role carrying an [`ActivityState`], per docs/SIDEBAR.md. The
|
||||
/// 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) -> Color {
|
||||
palette::active().surface(highlighted).activity(state)
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
@@ -246,8 +233,7 @@ fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
||||
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
||||
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
spans.push(Span::styled(" ".repeat(fill), pad_style));
|
||||
spans.push(Span::styled(" ".repeat(fill), base));
|
||||
}
|
||||
spans
|
||||
}
|
||||
@@ -418,10 +404,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A freshly-active session stays one line — never an empty placeholder
|
||||
/// row (docs/SIDEBAR.md).
|
||||
/// A fresh session still gets its unconditional second line, but no
|
||||
/// duration until it has been held in state for 30 seconds.
|
||||
#[test]
|
||||
fn freshly_active_session_stays_one_line() {
|
||||
fn fresh_session_renders_two_lines_without_duration() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity_since = Utc::now() - Duration::seconds(10);
|
||||
let sb = Sidebar {
|
||||
@@ -433,8 +419,18 @@ mod tests {
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
content(&all[2]).is_empty(),
|
||||
"line 2 must be absent, got {:?}",
|
||||
all[2].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
"line 2 must carry the command: {:?}",
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
!all[2].contains("10s"),
|
||||
"fresh cards have no duration: {:?}",
|
||||
all[2]
|
||||
);
|
||||
}
|
||||
@@ -474,12 +470,13 @@ mod tests {
|
||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||
#[test]
|
||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||
for (state, expected) in [
|
||||
(ActivityState::Active, Color::Green),
|
||||
(ActivityState::Idle, Color::DarkGray),
|
||||
(ActivityState::AwaitingInput, Color::Yellow),
|
||||
(ActivityState::Error, Color::Red),
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
let expected = palette::active().card.activity(state);
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = state;
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
@@ -495,6 +492,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected card keeps carrying its state on the dot, but in the
|
||||
/// selection surface's own colours. A state colour picked for the dark
|
||||
/// card background painted onto the selection background is the contrast
|
||||
/// bug this palette fixes — this test fails if it comes back.
|
||||
#[test]
|
||||
fn selected_card_never_paints_a_card_state_colour_on_the_selection_background() {
|
||||
let p = palette::active();
|
||||
for state in [
|
||||
ActivityState::Active,
|
||||
ActivityState::Idle,
|
||||
ActivityState::AwaitingInput,
|
||||
ActivityState::Error,
|
||||
] {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = state;
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let on_selection = |fg: Color| {
|
||||
(0..buf.area.height).any(|y| {
|
||||
(0..buf.area.width).any(|x| {
|
||||
let st = buf[(x, y)].style();
|
||||
st.bg == Some(p.selection.bg) && st.fg == Some(fg)
|
||||
})
|
||||
})
|
||||
};
|
||||
assert!(
|
||||
!on_selection(p.card.activity(state)),
|
||||
"{state:?}: card surface colour {:?} painted onto the selection background",
|
||||
p.card.activity(state)
|
||||
);
|
||||
assert!(
|
||||
on_selection(p.selection.activity(state)),
|
||||
"{state:?}: the dot must still carry the state on the selected card"
|
||||
);
|
||||
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
|
||||
.expect("palette roles are RGB");
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{state:?} on the selection background is only {ratio:.2}:1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
||||
/// even freshly dead, with the muted `exited` fragment.
|
||||
#[test]
|
||||
@@ -516,11 +561,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar is narrow: the meta line truncates with `…` and never
|
||||
/// wraps onto a third row (docs/SIDEBAR.md).
|
||||
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
||||
/// onto a third row (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn meta_line_truncates_instead_of_wrapping() {
|
||||
fn meta_line_command_truncates_instead_of_wrapping() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.command = "a command that cannot fit".into();
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 120,
|
||||
@@ -536,7 +582,7 @@ mod tests {
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(
|
||||
meta.trim_end().ends_with('…'),
|
||||
"truncated meta line must end with …: {meta:?}"
|
||||
"truncated command must end with …: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
@@ -651,10 +697,6 @@ mod tests {
|
||||
rendered.contains("exited"),
|
||||
"dead session must still show line 2:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains('h') || !rendered.contains("● "),
|
||||
"no duration may follow the dot for an epoch stamp:\n{rendered}"
|
||||
);
|
||||
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||
assert!(
|
||||
!line
|
||||
@@ -699,6 +741,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_renders_on_line_two_even_when_name_is_metadata() {
|
||||
let s = session_named(1, "friendly name");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
all[1].contains("friendly name"),
|
||||
"name must stay on line 1: {:?}",
|
||||
all[1]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains("bash"),
|
||||
"command must be on line 2: {:?}",
|
||||
all[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_meta_line_keeps_dot_and_truncates_command() {
|
||||
let s = session_named(1, "name");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 10, 6));
|
||||
assert!(
|
||||
all[2].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
all[2].contains('…'),
|
||||
"command should truncate: {:?}",
|
||||
all[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||
#[test]
|
||||
@@ -738,22 +822,23 @@ mod tests {
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let all = rows(&buf);
|
||||
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
|
||||
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
|
||||
|
||||
let p = palette::active();
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().bg,
|
||||
Some(Color::Cyan),
|
||||
"focused card must sit on the focus background"
|
||||
Some(p.selection.bg),
|
||||
"focused card must sit on the selection surface"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().fg,
|
||||
Some(Color::Black),
|
||||
"focused card text must stay readable on cyan"
|
||||
Some(p.selection.fg),
|
||||
"focused card text must use the selection foreground role"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, bash_y as u16)].style().bg,
|
||||
Some(Color::Black),
|
||||
"unfocused cards must still have a card background"
|
||||
Some(p.card.bg),
|
||||
"unfocused cards must still sit on the card surface"
|
||||
);
|
||||
// The panel itself (header row) stays on the terminal default.
|
||||
assert!(
|
||||
@@ -776,7 +861,7 @@ mod tests {
|
||||
for x in 0..27 {
|
||||
assert_eq!(
|
||||
buf[(x, y)].style().bg,
|
||||
Some(Color::Black),
|
||||
Some(palette::active().card.bg),
|
||||
"gap in card background at col {x}"
|
||||
);
|
||||
}
|
||||
|
||||
+37
-12
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
|
||||
#[command(
|
||||
name = "maestro",
|
||||
version,
|
||||
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
|
||||
arg_required_else_help = true
|
||||
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Override the daemon HTTP base clients talk to (e.g.
|
||||
@@ -37,8 +36,11 @@ struct Cli {
|
||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
|
||||
/// the terminal UI. Builds without the `tui` feature have no UI surface,
|
||||
/// so they print help and exit 0 instead.
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
@@ -89,7 +91,22 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match cli.command {
|
||||
let command = match cli.command {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
|
||||
}
|
||||
#[cfg(not(feature = "tui"))]
|
||||
{
|
||||
use clap::CommandFactory;
|
||||
let _ = <Cli as CommandFactory>::command().print_help();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
match command {
|
||||
#[cfg(feature = "cli")]
|
||||
Command::List(args) => {
|
||||
maestro_cli::dispatch(
|
||||
@@ -166,19 +183,19 @@ fn init_tracing(cli: &Cli) {
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
fn is_cli_subcommand(command: &Command) -> bool {
|
||||
fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
Command::List(_)
|
||||
| Command::Spawn(_)
|
||||
| Command::Attach(_)
|
||||
| Command::Kill(_)
|
||||
| Command::Hooks(_)
|
||||
Some(Command::List(_))
|
||||
| Some(Command::Spawn(_))
|
||||
| Some(Command::Attach(_))
|
||||
| Some(Command::Kill(_))
|
||||
| Some(Command::Hooks(_))
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "cli"))]
|
||||
fn is_cli_subcommand(_command: &Command) -> bool {
|
||||
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -216,12 +233,20 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
||||
fn command_of(argv: &[&str]) -> Command {
|
||||
fn command_of(argv: &[&str]) -> Option<Command> {
|
||||
Cli::try_parse_from(argv)
|
||||
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
||||
.command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_invocation_parses_to_no_subcommand() {
|
||||
// The TUI-vs-help default is applied in `dispatch`; parsing must not
|
||||
// reject an empty argv.
|
||||
assert!(command_of(&["maestro"]).is_none());
|
||||
assert!(command_of(&["maestro", "--verbose"]).is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "cli")]
|
||||
#[test]
|
||||
fn cli_subcommands_are_classified_as_cli() {
|
||||
|
||||
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
|
||||
assert!(text.contains("maestro"), "--version output: {text}");
|
||||
}
|
||||
|
||||
/// A bare `maestro` must never fall back to help on a build that has a TUI:
|
||||
/// the missing subcommand defaults to `tui` (#119). Point at a port with
|
||||
/// nothing listening so the TUI's health probe fails fast instead of blocking
|
||||
/// on a terminal.
|
||||
#[cfg(feature = "tui")]
|
||||
#[test]
|
||||
fn no_args_prints_help_and_fails() {
|
||||
// `arg_required_else_help = true` => exit non-zero but show usage.
|
||||
fn bare_invocation_dispatches_to_the_tui() {
|
||||
let (ok, text) = run(&["--daemon-url", "http://127.0.0.1:9"]);
|
||||
assert!(
|
||||
!ok,
|
||||
"TUI against a dead daemon should exit non-zero:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("daemon unreachable"),
|
||||
"bare invocation should have run the TUI's daemon probe:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("Usage"),
|
||||
"bare invocation must not print help when a TUI exists:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds without the `tui` feature keep the historical behaviour: no args =>
|
||||
/// help, exit 0 (#119).
|
||||
#[cfg(not(feature = "tui"))]
|
||||
#[test]
|
||||
fn bare_invocation_prints_help_and_exits_zero() {
|
||||
let (ok, text) = run(&[]);
|
||||
assert!(!ok, "bare invocation should fail:\n{text}");
|
||||
assert!(ok, "bare invocation should exit 0:\n{text}");
|
||||
assert!(text.contains("Usage"), "expected usage text:\n{text}");
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,9 @@ The wizard always installs the default-feature build.
|
||||
|
||||
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
|
||||
in the default feature set, so a vanilla `cargo install maestro` builds it.
|
||||
A bare `maestro` opens the TUI too — the missing subcommand defaults to `tui`
|
||||
(#119), so `maestro --daemon-url http://host:7070` works as well. Builds
|
||||
without the `tui` feature have no UI to open and print help instead (exit 0).
|
||||
Drop the `tui` feature to skip it on hosts that don't need the UI surface
|
||||
(`--no-default-features --features cli,daemon`).
|
||||
|
||||
|
||||
+20
-11
@@ -69,21 +69,30 @@ height.
|
||||
|
||||
The state word is never spelled out — the colour carries it.
|
||||
|
||||
| Activity state | Web token | ratatui colour |
|
||||
| ---------------- | --------- | ----------------- |
|
||||
| `Active` | `--live` | `Color::Green` |
|
||||
| `Idle` | `--muted` | `Color::DarkGray` |
|
||||
| `AwaitingInput` | `--warn` | `Color::Yellow` |
|
||||
| `Error` | `--danger`| `Color::Red` |
|
||||
| Activity state | Web token | TUI palette role |
|
||||
| ---------------- | --------- | ---------------- |
|
||||
| `Active` | `--live` | `active` |
|
||||
| `Idle` | `--muted` | `idle` |
|
||||
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||
| `Error` | `--danger`| `error` |
|
||||
|
||||
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
||||
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
|
||||
named colours above; do not introduce RGB values or a new palette module for
|
||||
this.
|
||||
and `prefers-color-scheme` variants for each). The TUI names roles, not
|
||||
colours: a role's value comes from the active palette
|
||||
(`maestro-tui/src/palette.rs`), never from a colour spelled out at the use
|
||||
site.
|
||||
|
||||
A palette holds one role set per **surface** — the card surface every card
|
||||
sits on, and the selection surface the highlighted card swaps in — so each
|
||||
state role resolves to the value picked for the surface it is painted on. The
|
||||
highlighted card therefore takes its state colours from the selection
|
||||
surface; a colour chosen for the card background is never painted onto the
|
||||
selection background. Contrast is a property of the palette (values are RGB)
|
||||
and is asserted in the palette's tests, not left to the terminal theme.
|
||||
|
||||
The duration text inherits its state's colour; the blocked-work, exited and
|
||||
command fragments use the muted colour (`--faint` / default style), since the
|
||||
⚠ glyph already signals severity.
|
||||
command fragments use the surface's `muted` role, since the ⚠ glyph already
|
||||
signals severity.
|
||||
|
||||
## Duration format
|
||||
|
||||
|
||||
Reference in New Issue
Block a user