Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3205270b51 | ||
|
|
d8e31f2760 | ||
|
|
fdf9ea1b31 | ||
|
|
22cbfbc9bc | ||
|
|
8157793282 |
@@ -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")
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum Action {
|
||||
// Sidebar highlight movement
|
||||
SidebarNext,
|
||||
SidebarPrev,
|
||||
ToggleSidebarGroup,
|
||||
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
@@ -81,6 +82,7 @@ impl Action {
|
||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
|
||||
@@ -30,14 +30,16 @@ use crate::catalog::CatalogStore;
|
||||
use crate::config::TuiConfig;
|
||||
use crate::draft::DraftInputResult;
|
||||
use crate::emulators::SessionEmulators;
|
||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
||||
use crate::input::{
|
||||
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
|
||||
};
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::worktrees::WorktreesView;
|
||||
use crate::TuiError;
|
||||
@@ -52,6 +54,7 @@ enum OverlayDecision {
|
||||
|
||||
pub struct App {
|
||||
cfg: TuiConfig,
|
||||
home_dir: String,
|
||||
catalog: CatalogStore,
|
||||
emulators: SessionEmulators,
|
||||
state: AppState,
|
||||
@@ -137,6 +140,7 @@ impl App {
|
||||
.expect("build reqwest client");
|
||||
Self {
|
||||
cfg,
|
||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
state: AppState::new(),
|
||||
@@ -158,6 +162,9 @@ impl App {
|
||||
mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||
) -> Result<(), TuiError> {
|
||||
if let Ok(info) = self.daemon.daemon_info().await {
|
||||
self.home_dir = info.home_dir;
|
||||
}
|
||||
let mut events = EventStream::new();
|
||||
loop {
|
||||
self.redraw(terminal)?;
|
||||
@@ -190,6 +197,10 @@ impl App {
|
||||
};
|
||||
let key = self.clicks.promote(key, std::time::Instant::now());
|
||||
|
||||
if self.handle_sidebar_mouse(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Overlay intercept first.
|
||||
let (decision, effects) = self.handle_overlay_input(&key);
|
||||
self.perform(effects).await;
|
||||
@@ -224,6 +235,34 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks select rendered sidebar items directly. Double-clicks continue
|
||||
/// through the keymap so the configured attach binding remains intact.
|
||||
fn handle_sidebar_mouse(&mut self, key: &InputKey) -> bool {
|
||||
let InputKey::Mouse {
|
||||
kind: MouseKind::Click,
|
||||
region: Region::SidebarRow,
|
||||
row,
|
||||
..
|
||||
} = key
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(item) = self.region.sidebar_item_at(*row) else {
|
||||
return true;
|
||||
};
|
||||
match item {
|
||||
SidebarItem::Group(key) => {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Group(key.clone()));
|
||||
self.toggle_sidebar_group(key);
|
||||
}
|
||||
SidebarItem::Session(id) => {
|
||||
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||
/// the daemon.
|
||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||
@@ -330,15 +369,22 @@ impl App {
|
||||
self.state.focus = Focus::Pane;
|
||||
}
|
||||
Action::SidebarNext => {
|
||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, 1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::SidebarPrev => {
|
||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
&self.state.collapsed_sidebar_groups,
|
||||
);
|
||||
self.state.move_sidebar_highlight(&order, -1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||
Action::NewTab => self.state.new_tab(),
|
||||
Action::CloseTab => {
|
||||
if let Some(sid) = self.state.close_tab() {
|
||||
@@ -403,13 +449,50 @@ impl App {
|
||||
/// Return the session under the sidebar highlight, falling back to the
|
||||
/// first listed session so the user can attach without arrowing first.
|
||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||
if let Some(sid) = self.state.sidebar_highlight {
|
||||
if let Some(SidebarItem::Session(sid)) = self.state.sidebar_highlight {
|
||||
return Some(sid);
|
||||
}
|
||||
if self.state.sidebar_highlight.is_some() {
|
||||
return None;
|
||||
}
|
||||
let snap = self.catalog.snapshot();
|
||||
snap.sessions.first().map(|s| s.id)
|
||||
}
|
||||
|
||||
fn toggle_highlighted_sidebar_group(&mut self) {
|
||||
let key = match self.state.sidebar_highlight.clone() {
|
||||
Some(SidebarItem::Group(key)) => Some(key),
|
||||
Some(SidebarItem::Session(id)) => self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(|s| maestro_protocol::display::session_tree_path(&s)),
|
||||
None => None,
|
||||
};
|
||||
if let Some(key) = key {
|
||||
self.toggle_sidebar_group(key);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_sidebar_group(&mut self, key: PathBuf) {
|
||||
let contains_highlight = match self.state.sidebar_highlight {
|
||||
Some(SidebarItem::Session(id)) => {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(maestro_protocol::display::session_tree_path)
|
||||
.as_ref()
|
||||
== Some(&key)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.state.toggle_sidebar_group(key, contains_highlight);
|
||||
}
|
||||
|
||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
@@ -811,7 +894,7 @@ impl App {
|
||||
.collect();
|
||||
let sidebar = Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: self.state.sidebar_highlight,
|
||||
highlight: self.state.sidebar_highlight.clone(),
|
||||
attached,
|
||||
};
|
||||
let tab_bar = TabBar {
|
||||
@@ -833,6 +916,7 @@ impl App {
|
||||
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: &self.home_dir,
|
||||
tab_bar: &tab_bar,
|
||||
pane: pane.as_ref(),
|
||||
state: &self.state,
|
||||
@@ -881,8 +965,16 @@ impl App {
|
||||
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
||||
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
||||
/// visual sync with what the user sees.
|
||||
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
||||
snap.sessions.iter().map(|s| s.id).collect()
|
||||
fn visible_sidebar_order(
|
||||
snap: &crate::catalog::CatalogSnapshot,
|
||||
collapsed: &std::collections::HashSet<PathBuf>,
|
||||
) -> Vec<SidebarItem> {
|
||||
Sidebar {
|
||||
sessions: snap.sessions.clone(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
}
|
||||
.rendered_items_with_collapsed(collapsed)
|
||||
}
|
||||
|
||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||
@@ -1134,9 +1226,32 @@ mod tests {
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app.dispatch(Action::SidebarNext);
|
||||
app
|
||||
}
|
||||
|
||||
fn test_session(sid: Uuid, cwd: &str) -> maestro_protocol::Session {
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
Session {
|
||||
id: sid,
|
||||
command: "bash".into(),
|
||||
cwd: cwd.into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_highlighted_asks_for_an_attach() {
|
||||
let sid = Uuid::from_u128(11);
|
||||
@@ -1148,6 +1263,64 @@ mod tests {
|
||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_highlight_is_not_a_session_action_target() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
let sid = Uuid::from_u128(14);
|
||||
let mut app = make_app();
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![test_session(sid, "/repo")],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||
);
|
||||
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
|
||||
let sid = Uuid::from_u128(15);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||
assert!(app.dispatch(Action::ToggleSidebarGroup).is_empty());
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||
);
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_group_toggles_it_and_selects_the_group() {
|
||||
let sid = Uuid::from_u128(16);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||
let click = InputKey::Mouse {
|
||||
kind: MouseKind::Click,
|
||||
region: Region::SidebarRow,
|
||||
col: 3,
|
||||
row: 2,
|
||||
};
|
||||
assert!(app.handle_sidebar_mouse(&click));
|
||||
assert_eq!(
|
||||
app.state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||
);
|
||||
assert!(app
|
||||
.state
|
||||
.collapsed_sidebar_groups
|
||||
.contains(&PathBuf::from("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirming_a_kill_asks_for_the_kill() {
|
||||
let sid = Uuid::from_u128(12);
|
||||
|
||||
@@ -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,
|
||||
@@ -43,6 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("k", "SidebarPrev"),
|
||||
("down", "SidebarNext"),
|
||||
("up", "SidebarPrev"),
|
||||
("c", "ToggleSidebarGroup"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "NewDraftPane"),
|
||||
@@ -76,6 +79,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 +139,7 @@ impl TuiConfig {
|
||||
}
|
||||
|
||||
Ok(TuiConfig {
|
||||
palette,
|
||||
leader,
|
||||
chord_timeout,
|
||||
keymap,
|
||||
@@ -143,6 +152,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||
use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||
|
||||
@@ -23,6 +23,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct RegionMap {
|
||||
pub sidebar: Option<Rect>,
|
||||
pub sidebar_items: Vec<SidebarItem>,
|
||||
pub tab_bar: Option<Rect>,
|
||||
pub pane: Option<Rect>,
|
||||
pub modal: Option<Rect>,
|
||||
@@ -52,6 +53,24 @@ impl RegionMap {
|
||||
}
|
||||
Region::Unknown
|
||||
}
|
||||
|
||||
/// Resolve a mouse row against the rendered sidebar sequence. The title
|
||||
/// occupies one inner row; groups occupy one row and visible cards two.
|
||||
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
||||
let area = self.sidebar?;
|
||||
let mut cursor = area.y.saturating_add(2);
|
||||
for item in &self.sidebar_items {
|
||||
let height = match item {
|
||||
SidebarItem::Group(_) => 1,
|
||||
SidebarItem::Session(_) => 2,
|
||||
};
|
||||
if row >= cursor && row < cursor.saturating_add(height) {
|
||||
return Some(item.clone());
|
||||
}
|
||||
cursor = cursor.saturating_add(height);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
@@ -63,6 +82,7 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
#[derive(Debug)]
|
||||
pub struct RenderInputs<'a> {
|
||||
pub sidebar: &'a Sidebar,
|
||||
pub home_dir: &'a str,
|
||||
pub tab_bar: &'a TabBar,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||
@@ -102,10 +122,17 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
let right = cols[1];
|
||||
|
||||
if inputs.state.sidebar_visible {
|
||||
inputs
|
||||
.sidebar
|
||||
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
||||
inputs.sidebar.render_with_focus_and_collapsed_home(
|
||||
f,
|
||||
sidebar_area,
|
||||
inputs.state.focus == Focus::Sidebar,
|
||||
&inputs.state.collapsed_sidebar_groups,
|
||||
inputs.home_dir,
|
||||
);
|
||||
map.sidebar = Some(sidebar_area);
|
||||
map.sidebar_items = inputs
|
||||
.sidebar
|
||||
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
||||
}
|
||||
|
||||
// The pane always owns its full border, so focus changes only recolour
|
||||
@@ -132,7 +159,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
if let Some(d) = inputs.draft {
|
||||
d.render(f, pane_inner, pane_focused);
|
||||
}
|
||||
map.pane = Some(pane_area);
|
||||
// `RegionMap::pane` is also the PTY resize source. Keep it aligned with
|
||||
// the cells the emulator actually owns, excluding the persistent border.
|
||||
map.pane = Some(pane_inner);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
// always see the current mode + key hints.
|
||||
@@ -231,6 +260,7 @@ mod tests {
|
||||
width: 10,
|
||||
height: 20,
|
||||
}),
|
||||
sidebar_items: Vec::new(),
|
||||
tab_bar: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
@@ -260,6 +290,7 @@ mod tests {
|
||||
width: 10,
|
||||
height: 20,
|
||||
}),
|
||||
sidebar_items: Vec::new(),
|
||||
tab_bar: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
@@ -284,6 +315,40 @@ mod tests {
|
||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() {
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
let group_a = SidebarItem::Group(PathBuf::from("/a"));
|
||||
let card_a = SidebarItem::Session(Uuid::from_u128(1));
|
||||
let group_b = SidebarItem::Group(PathBuf::from("/b"));
|
||||
let card_b = SidebarItem::Session(Uuid::from_u128(2));
|
||||
let map = RegionMap {
|
||||
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
||||
..RegionMap::default()
|
||||
};
|
||||
assert_eq!(map.sidebar_item_at(2), Some(group_a));
|
||||
assert_eq!(map.sidebar_item_at(3), Some(card_a.clone()));
|
||||
assert_eq!(map.sidebar_item_at(4), Some(card_a));
|
||||
assert_eq!(map.sidebar_item_at(5), Some(group_b));
|
||||
|
||||
let collapsed = RegionMap {
|
||||
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||
sidebar_items: vec![
|
||||
SidebarItem::Group(PathBuf::from("/a")),
|
||||
SidebarItem::Group(PathBuf::from("/b")),
|
||||
SidebarItem::Session(Uuid::from_u128(2)),
|
||||
],
|
||||
..RegionMap::default()
|
||||
};
|
||||
assert_eq!(
|
||||
collapsed.sidebar_item_at(3),
|
||||
Some(SidebarItem::Group(PathBuf::from("/b")))
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||
/// form where the terminal would otherwise be.
|
||||
#[test]
|
||||
@@ -310,6 +375,7 @@ mod tests {
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: Some(draft),
|
||||
@@ -366,6 +432,7 @@ mod tests {
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: None,
|
||||
@@ -385,6 +452,7 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(sidebar_focus.pane, Some(Rect::new(29, 2, 50, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -408,6 +476,7 @@ mod tests {
|
||||
state.focus = focus;
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
tab_bar: &tab_bar,
|
||||
pane: None,
|
||||
draft: None,
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
@@ -16,6 +18,7 @@ use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::palette;
|
||||
use crate::state::SidebarItem;
|
||||
|
||||
/// Time-in-state earns a duration fragment once the session has sat in its
|
||||
/// current activity state this long (docs/SIDEBAR.md).
|
||||
@@ -44,10 +47,17 @@ fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sidebar {
|
||||
pub sessions: Vec<Session>,
|
||||
pub highlight: Option<Uuid>,
|
||||
pub highlight: Option<SidebarItem>,
|
||||
pub attached: HashSet<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SidebarGroup {
|
||||
pub key: PathBuf,
|
||||
pub sessions: Vec<Session>,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
self.render_with_focus(f, area, false);
|
||||
@@ -57,6 +67,27 @@ impl Sidebar {
|
||||
/// and whether the highlighted card claims the selection surface, never
|
||||
/// its geometry.
|
||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
||||
}
|
||||
|
||||
pub fn render_with_focus_and_collapsed(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
collapsed: &HashSet<PathBuf>,
|
||||
) {
|
||||
self.render_with_focus_and_collapsed_home(f, area, focused, collapsed, &home_dir());
|
||||
}
|
||||
|
||||
pub fn render_with_focus_and_collapsed_home(
|
||||
&self,
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
focused: bool,
|
||||
collapsed: &HashSet<PathBuf>,
|
||||
home: &str,
|
||||
) {
|
||||
let border_style = palette::active().border_style(focused);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -69,23 +100,110 @@ impl Sidebar {
|
||||
"sessions",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
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);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
for group in self.groups(collapsed) {
|
||||
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||
if !group.collapsed {
|
||||
for s in &group.sessions {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(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);
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
/// Items in exactly the order and height they occupy on screen.
|
||||
pub fn rendered_items(&self) -> Vec<SidebarItem> {
|
||||
self.rendered_items_with_collapsed(&HashSet::new())
|
||||
}
|
||||
|
||||
pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarItem> {
|
||||
self.groups(collapsed)
|
||||
.into_iter()
|
||||
.flat_map(|group| {
|
||||
let mut items = vec![SidebarItem::Group(group.key)];
|
||||
if !group.collapsed {
|
||||
items.extend(
|
||||
group
|
||||
.sessions
|
||||
.into_iter()
|
||||
.map(|s| SidebarItem::Session(s.id)),
|
||||
);
|
||||
}
|
||||
items
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn groups(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarGroup> {
|
||||
let mut grouped: BTreeMap<PathBuf, Vec<Session>> = BTreeMap::new();
|
||||
for session in &self.sessions {
|
||||
if let Some(key) = session_tree_path(session) {
|
||||
grouped.entry(key).or_default().push(session.clone());
|
||||
}
|
||||
}
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(key, mut sessions)| {
|
||||
sessions
|
||||
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
||||
SidebarGroup {
|
||||
collapsed: collapsed.contains(&key),
|
||||
key,
|
||||
sessions,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn group_row(
|
||||
&self,
|
||||
group: &SidebarGroup,
|
||||
width: usize,
|
||||
focused: bool,
|
||||
home: &str,
|
||||
) -> Vec<Span<'static>> {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(highlighted && focused);
|
||||
let base = Style::default().bg(surface.bg).fg(if highlighted {
|
||||
surface.fg
|
||||
} else {
|
||||
palette.group_row
|
||||
});
|
||||
let edge = highlighted && !focused;
|
||||
let prefix = if edge { "▏ " } else { " " };
|
||||
let chevron = if group.collapsed { "▸ " } else { "▾ " };
|
||||
let path = abbreviate_dir(&group.key, home);
|
||||
let name = abbreviate_folder_name(&path);
|
||||
let count = format!(" ({})", group.sessions.len());
|
||||
let mut spans = vec![Span::styled(
|
||||
prefix,
|
||||
if edge {
|
||||
base.fg(palette.focus_accent)
|
||||
} else {
|
||||
base
|
||||
},
|
||||
)];
|
||||
spans.push(Span::styled(chevron, base));
|
||||
if let Some(stem) = name.stem {
|
||||
spans.push(Span::styled(format!("{stem}/"), surface.dim_style()));
|
||||
}
|
||||
spans.push(Span::styled(name.last, base));
|
||||
spans.push(Span::styled(count, surface.muted_style()));
|
||||
pad_to_width(fit_to_width(spans, width), width, base)
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> 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 highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||
// row only; they reserve their width so the name truncates with `…`
|
||||
@@ -138,7 +256,7 @@ 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 highlighted = self.highlight == Some(s.id);
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let base = card_base(highlighted, focused);
|
||||
let dot_style =
|
||||
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||
@@ -269,6 +387,10 @@ fn name_for(s: &Session) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn home_dir() -> String {
|
||||
std::env::var("HOME").unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -314,6 +436,15 @@ mod tests {
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet<PathBuf>) -> Buffer {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| sidebar.render_with_focus_and_collapsed(f, f.area(), true, collapsed))
|
||||
.unwrap();
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn rows(buf: &Buffer) -> Vec<String> {
|
||||
(0..buf.area.height)
|
||||
.map(|y| {
|
||||
@@ -360,6 +491,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_worktrees_under_their_base_repository() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.cwd = "/home/u/repos/worktrees/feature".into();
|
||||
s.worktree = Some(maestro_protocol::SessionWorktree {
|
||||
path: s.cwd.clone(),
|
||||
base_repo: "/home/u/repos/maestro".into(),
|
||||
});
|
||||
let groups = (Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
})
|
||||
.groups(&HashSet::new());
|
||||
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_render_groups_without_sessions() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||
assert!(sidebar.rendered_items().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_a_group_hides_cards_but_keeps_the_count_and_item() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let key = PathBuf::from("/tmp");
|
||||
let mut collapsed = HashSet::new();
|
||||
collapsed.insert(key.clone());
|
||||
let groups = sidebar.groups(&collapsed);
|
||||
assert_eq!(groups[0].sessions.len(), 2);
|
||||
assert!(groups[0].collapsed);
|
||||
assert_eq!(
|
||||
sidebar.rendered_items_with_collapsed(&collapsed),
|
||||
vec![SidebarItem::Group(key)]
|
||||
);
|
||||
let rendered = render_to_string(&draw_collapsed(&sidebar, 40, 6, &collapsed));
|
||||
assert!(rendered.contains("(2)"));
|
||||
assert!(!rendered.contains("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_display_helpers_abbreviate_group_labels() {
|
||||
let path = maestro_protocol::display::abbreviate_dir(
|
||||
std::path::Path::new("/home/u/git.naps.pt/naps62/maestro"),
|
||||
"/home/u",
|
||||
);
|
||||
let name = maestro_protocol::display::abbreviate_folder_name(&path);
|
||||
assert_eq!(path, "~/git.naps.pt/naps62/maestro");
|
||||
assert_eq!(name.stem.as_deref(), Some("~/g/n"));
|
||||
assert_eq!(name.last, "maestro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_cards_are_always_two_lines_after_one_group_row() {
|
||||
let sidebar = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sidebar, 40, 8));
|
||||
assert!(all[2].contains("/tmp"));
|
||||
assert!(all[3].contains("claude"));
|
||||
assert!(all[4].contains("bash"));
|
||||
assert!(!all[5].contains("bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_session_uses_filled_glyph() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
@@ -450,17 +657,17 @@ mod tests {
|
||||
assert_eq!(content(&all[1]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
all[4].contains('●'),
|
||||
"line 2 must carry the dot: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[3].contains("bash"),
|
||||
all[4].contains("bash"),
|
||||
"line 2 must carry the command: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
!all[3].contains("10s"),
|
||||
!all[4].contains("10s"),
|
||||
"fresh cards have no duration: {:?}",
|
||||
all[3]
|
||||
);
|
||||
@@ -541,7 +748,7 @@ mod tests {
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -782,12 +989,12 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(
|
||||
all[2].contains("friendly name"),
|
||||
all[3].contains("friendly name"),
|
||||
"name must stay on line 1: {:?}",
|
||||
all[2]
|
||||
);
|
||||
assert!(
|
||||
all[3].contains("bash"),
|
||||
all[4].contains("bash"),
|
||||
"command must be on line 2: {:?}",
|
||||
all[3]
|
||||
);
|
||||
@@ -803,12 +1010,12 @@ mod tests {
|
||||
};
|
||||
let all = rows(&draw(&sb, 10, 6));
|
||||
assert!(
|
||||
all[3].contains('●'),
|
||||
all[4].contains('●'),
|
||||
"dot must survive narrow cards: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(
|
||||
all[3].contains('…'),
|
||||
all[4].contains('…'),
|
||||
"command should truncate: {:?}",
|
||||
all[3]
|
||||
);
|
||||
@@ -828,8 +1035,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[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
||||
let meta = content(&all[4]);
|
||||
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||
}
|
||||
|
||||
@@ -847,13 +1054,13 @@ mod tests {
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 6));
|
||||
assert!(all[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
|
||||
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
||||
assert!(
|
||||
all[3].contains("bash"),
|
||||
all[4].contains("bash"),
|
||||
"command must survive: {:?}",
|
||||
all[3]
|
||||
);
|
||||
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
|
||||
assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
@@ -864,7 +1071,7 @@ mod tests {
|
||||
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![highlighted],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -889,7 +1096,7 @@ mod tests {
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
@@ -924,7 +1131,7 @@ mod tests {
|
||||
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||
@@ -982,7 +1189,7 @@ mod tests {
|
||||
fn highlighted_row_anchors_the_actions_top_right() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
@@ -999,7 +1206,7 @@ mod tests {
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
@@ -1020,7 +1227,7 @@ mod tests {
|
||||
}
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
@@ -19,6 +22,16 @@ pub enum Focus {
|
||||
Pane,
|
||||
}
|
||||
|
||||
/// One selectable item in the rendered sidebar sequence.
|
||||
///
|
||||
/// Group paths, unlike a session's position in the catalog, survive catalog
|
||||
/// redraws and make a highlighted collapsed group unambiguous.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum SidebarItem {
|
||||
Group(PathBuf),
|
||||
Session(Uuid),
|
||||
}
|
||||
|
||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
||||
// that boxing every access costs more than the copy does.
|
||||
@@ -70,7 +83,10 @@ pub struct AppState {
|
||||
pub focus: Focus,
|
||||
pub mode: Mode,
|
||||
pub sidebar_visible: bool,
|
||||
pub sidebar_highlight: Option<Uuid>,
|
||||
pub sidebar_highlight: Option<SidebarItem>,
|
||||
/// Group paths whose cards are hidden. This is UI state rather than
|
||||
/// catalog state, so it survives every catalog redraw.
|
||||
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||
/// Private: every transition goes through [`AppState::show_overlay`],
|
||||
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
||||
/// from four call sites is what left a toast on screen with no way to
|
||||
@@ -96,6 +112,7 @@ impl AppState {
|
||||
mode: Mode::Normal,
|
||||
sidebar_visible: true,
|
||||
sidebar_highlight: None,
|
||||
collapsed_sidebar_groups: HashSet::new(),
|
||||
overlay: None,
|
||||
}
|
||||
}
|
||||
@@ -213,19 +230,32 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||
/// rendered item sequence. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
|
||||
if order.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = order.len() as i32;
|
||||
let cur = self
|
||||
.sidebar_highlight
|
||||
.and_then(|id| order.iter().position(|x| *x == id))
|
||||
.as_ref()
|
||||
.and_then(|id| order.iter().position(|x| x == id))
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(-1);
|
||||
let next = (cur + delta).rem_euclid(len) as usize;
|
||||
self.sidebar_highlight = Some(order[next]);
|
||||
self.sidebar_highlight = Some(order[next].clone());
|
||||
}
|
||||
|
||||
/// Toggle a group. When collapsing a group containing the current card,
|
||||
/// leave the highlight on the group row that remains visible.
|
||||
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
|
||||
if self.collapsed_sidebar_groups.remove(&key) {
|
||||
return;
|
||||
}
|
||||
self.collapsed_sidebar_groups.insert(key.clone());
|
||||
if contains_highlight {
|
||||
self.sidebar_highlight = Some(SidebarItem::Group(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle sidebar visibility.
|
||||
@@ -318,22 +348,30 @@ mod tests {
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let c = Uuid::from_u128(3);
|
||||
let order = vec![a, b, c];
|
||||
let order = vec![
|
||||
SidebarItem::Session(a),
|
||||
SidebarItem::Group("repo".into()),
|
||||
SidebarItem::Session(b),
|
||||
SidebarItem::Session(c),
|
||||
];
|
||||
|
||||
// No prior highlight + delta +1 lands on first item.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(b));
|
||||
assert_eq!(
|
||||
state.sidebar_highlight,
|
||||
Some(SidebarItem::Group("repo".into()))
|
||||
);
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
// Wraps forward.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||
// Wraps backward.
|
||||
state.move_sidebar_highlight(&order, -1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -343,6 +381,22 @@ mod tests {
|
||||
assert_eq!(state.sidebar_highlight, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
|
||||
let key = PathBuf::from("/repo");
|
||||
let mut state = AppState::new();
|
||||
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
|
||||
state.toggle_sidebar_group(key.clone(), true);
|
||||
assert_eq!(
|
||||
state.sidebar_highlight,
|
||||
Some(SidebarItem::Group(key.clone()))
|
||||
);
|
||||
assert!(state.collapsed_sidebar_groups.contains(&key));
|
||||
state.toggle_sidebar_group(key.clone(), false);
|
||||
assert!(!state.collapsed_sidebar_groups.contains(&key));
|
||||
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
||||
let mut state = AppState::new();
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user