feat(maestro): render dormant sidebar cards muted and sorted last
docs/SIDEBAR.md's dormant card contract (#168) applied to the TUI: name and glyph render in the muted role, the activity rail carries no colour, and a dormant session sorts after every live one in its folder, name notwithstanding.
This commit is contained in:
@@ -184,8 +184,16 @@ impl Sidebar {
|
||||
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)));
|
||||
// docs/SIDEBAR.md, "Dormant cards": a dormant session takes
|
||||
// the lowest precedence, name notwithstanding, so a reboot
|
||||
// that leaves a tree dormant never buries live work spawned
|
||||
// into it.
|
||||
sessions.sort_by(|a, b| {
|
||||
dormant_rank(a)
|
||||
.cmp(&dormant_rank(b))
|
||||
.then_with(|| name_for(a).cmp(&name_for(b)))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
SidebarGroup {
|
||||
collapsed: collapsed.contains(&key),
|
||||
key,
|
||||
@@ -276,11 +284,28 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(fill.filled());
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
// docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant
|
||||
// session's activity to Idle and there is nothing running to report
|
||||
// otherwise, so the rail carries no colour — the muted role, not the
|
||||
// idle one.
|
||||
let dormant = s.status == SessionStatus::Dormant;
|
||||
let muted_style = base.patch(
|
||||
surface
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||
let rail_style = if dormant {
|
||||
base.fg(surface.muted)
|
||||
} else {
|
||||
base.fg(activity_color(s.activity, fill))
|
||||
};
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
@@ -291,18 +316,19 @@ impl Sidebar {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
Span::styled(
|
||||
format!("{glyph} "),
|
||||
if dormant { muted_style } else { base },
|
||||
),
|
||||
];
|
||||
// #131: a name the daemon generated is provisional — the session's
|
||||
// first prompt may replace it — so it reads muted, like the meta line,
|
||||
// rather than as a name someone chose. See docs/SIDEBAR.md.
|
||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||
base.patch(
|
||||
palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
)
|
||||
// A dormant card (#168, #169) is a muted variant of the whole card —
|
||||
// the same muted foreground `exited` already uses — regardless of
|
||||
// whether the name itself is provisional.
|
||||
let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) {
|
||||
muted_style
|
||||
} else {
|
||||
base
|
||||
};
|
||||
@@ -328,11 +354,18 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||
let muted = palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
// docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the
|
||||
// duration that follows it (rendered in the rail's own colour) is
|
||||
// muted rather than the idle role.
|
||||
let rail_style = if dormant {
|
||||
base.patch(muted)
|
||||
} else {
|
||||
base.patch(Style::default().fg(activity_color(s.activity, fill)))
|
||||
};
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
@@ -458,6 +491,10 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
||||
spans
|
||||
}
|
||||
|
||||
fn dormant_rank(s: &Session) -> u8 {
|
||||
u8::from(s.status == SessionStatus::Dormant)
|
||||
}
|
||||
|
||||
/// `metadata.name` if present, else the command line.
|
||||
pub(crate) fn name_for(s: &Session) -> String {
|
||||
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||
@@ -1108,6 +1145,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts
|
||||
/// after every live one in its folder, name notwithstanding.
|
||||
#[test]
|
||||
fn dormant_sessions_sort_after_live_ones_in_their_folder() {
|
||||
let mut dormant = session_named(1, "aaa-dormant");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
let live = session_named(2, "zzz-live");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![dormant, live],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["zzz-live", "aaa-dormant"]);
|
||||
}
|
||||
|
||||
/// Dormant sessions still sort by name against each other.
|
||||
#[test]
|
||||
fn dormant_sessions_still_sort_by_name_against_each_other() {
|
||||
let mut b = session_named(1, "b");
|
||||
b.status = SessionStatus::Dormant;
|
||||
let mut a = session_named(2, "a");
|
||||
a.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![b, a],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of
|
||||
/// the normal card — the same muted foreground `exited` already uses —
|
||||
/// covering the glyph and the name, not only the meta line.
|
||||
#[test]
|
||||
fn dormant_card_renders_its_name_and_glyph_muted() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let colour_of = |needle: char| {
|
||||
(0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("must be drawn")
|
||||
};
|
||||
assert_eq!(colour_of('c'), surface.muted, "the dormant name");
|
||||
assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph");
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle
|
||||
/// and there is nothing running to report, so the rail is driven by the
|
||||
/// muted role directly rather than `activity_color`.
|
||||
#[test]
|
||||
fn dormant_card_rail_carries_no_colour() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
s.activity = ActivityState::Idle;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let rail_colour = (0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == RAIL)
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("rail must be drawn");
|
||||
assert_eq!(rail_colour, surface.muted);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
|
||||
Reference in New Issue
Block a user