This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-tui/src/render.rs
T

684 lines
23 KiB
Rust

//! Top-level draw orchestration. Composes sidebar / pane / overlays
//! into one frame, and returns a [`RegionMap`] that the input router uses to
//! hit-test mouse clicks.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use maestro_protocol::ActivityState;
use crate::daemon_link::{format_release, LinkState, LinkStatus};
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, RestartOverlay, SpawnModal, Toast};
use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::structured_pane::StructuredPane;
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub sidebar_items: Vec<SidebarItem>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
impl RegionMap {
pub fn region_at(&self, col: u16, row: u16) -> Region {
if let Some(r) = self.modal {
if inside(r, col, row) {
return Region::Modal;
}
}
if let Some(r) = self.sidebar {
if inside(r, col, row) {
return Region::SidebarRow;
}
}
if let Some(r) = self.pane {
if inside(r, col, row) {
return Region::Pane;
}
}
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(1);
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 {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
/// Bundle of references the renderer reads from. Built by `App::redraw` each
/// frame.
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
pub structured_pane: Option<&'a StructuredPane>,
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
/// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput,
/// The command bar behind [`OverlayKind::CommandBar`] (#153), when one is
/// up.
pub command_bar: Option<&'a crate::command_bar::CommandBar>,
/// Liveness + release of the daemon, for the status bar's indicator and the
/// restart overlay (`docs/RESTART.md`).
pub link: &'a crate::daemon_link::LinkStatus,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
/// mouse events the next time it canonicalises an event.
pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let mut map = RegionMap::default();
let area = f.area();
// Split the screen into top (everything) + 1-row status bar at the bottom.
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let body_area = outer[0];
let status_area = outer[1];
let cols = if inputs.state.sidebar_visible {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(0)])
.split(body_area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(0), Constraint::Min(0)])
.split(body_area)
};
let sidebar_area = cols[0];
let right = cols[1];
if inputs.state.sidebar_visible {
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);
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let title_area = rows[0];
let pane_area = rows[1];
let pane_focused = inputs.state.focus == Focus::Pane;
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
} else if let Some(p) = inputs.structured_pane {
p.render(f, pane_area);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the active session pane.
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
// always see the current mode + key hints.
render_status_bar(
f,
status_area,
inputs.state,
inputs.link,
inputs.structured_pane.is_some(),
);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
if let Some(d) = inputs.draft {
let (area, inner) = SpawnModal.render(f, area);
// The modal owns the keyboard while it is up, so the form
// always draws focused.
d.render(f, inner, true);
map.modal = Some(area);
}
}
Some(OverlayKind::KillConfirm(id)) => {
// #83: surface what the watcher last saw in the worktree, in
// InTheWay's own words, before the operator commits to the kill.
let blockers = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.and_then(|s| s.blocked_work)
.filter(|b| !b.is_clear())
.map(|b| b.to_string());
let kc = KillConfirm {
label: id.to_string(),
blockers,
};
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Rename(id)) => {
let current = inputs
.sidebar
.sessions
.iter()
.find(|s| &s.id == id)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| id.to_string());
let prompt = RenamePrompt {
current: &current,
input: inputs.rename_input,
};
if let Some((x, y)) = prompt.render(f, area) {
f.set_cursor_position((x, y));
}
map.modal = Some(area);
}
Some(OverlayKind::CommandBar) => {
if let Some(bar) = inputs.command_bar {
if let Some((x, y)) = bar.render(f, area) {
f.set_cursor_position((x, y));
}
map.modal = Some(area);
}
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
}
None => {}
}
// Last, so it covers whatever else is up: while the daemon is gone none of
// it can act (`docs/RESTART.md`).
if inputs.link.state == LinkState::Down {
RestartOverlay {
attempts: inputs.link.attempts,
release: inputs
.link
.build
.as_ref()
.map(format_release)
.unwrap_or_else(|| "unknown release".to_string()),
}
.render(f, area);
map.modal = Some(area);
}
map
}
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
palette.mode_style(false)
} else {
palette.card.text_style()
}
.add_modifier(Modifier::BOLD);
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
}
/// Render the always-visible status bar at the bottom of the frame. Surfaces
/// mode + focused panel + the most useful key hints so the user is never
/// guessing what state they're in.
fn render_status_bar(
f: &mut Frame,
area: Rect,
state: &AppState,
link: &LinkStatus,
structured: bool,
) {
// The daemon indicator is pinned right and sized to its own text, so a
// narrow terminal truncates the key hints rather than the release.
let label = link_label(link);
let indicator_w = (label.chars().count() as u16).saturating_add(3);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(indicator_w)])
.split(area);
let mode_label = match state.mode {
Mode::Normal => "[NORMAL]",
Mode::Insert => "[INSERT]",
};
let focus_label = match state.focus {
Focus::Sidebar => "sidebar",
Focus::Pane => "pane",
};
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
(false, Mode::Normal) => {
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
}
(false, Mode::Insert) => "^a: return to sidebar",
};
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}")),
]);
f.render_widget(Paragraph::new(line), cols[0]);
let indicator = ratatui::text::Line::from(vec![
Span::styled(" ●", Style::default().fg(link_colour(link.state))),
Span::raw(format!(" {label} ")),
]);
f.render_widget(Paragraph::new(indicator), cols[1]);
}
/// The status dot's colour: the same three the sidebar cards use for session
/// activity, so "green is fine, red is broken" reads the same everywhere.
fn link_colour(state: LinkState) -> ratatui::style::Color {
let card = palette::active().card;
match state {
LinkState::Online => card.activity(ActivityState::Active),
LinkState::Connecting | LinkState::Reconnecting => {
card.activity(ActivityState::AwaitingInput)
}
LinkState::Down => card.activity(ActivityState::Error),
}
}
/// What sits next to the dot. The browser shows the release on hover; a
/// terminal has no hover, so it is always on the line — replaced by the state
/// while the daemon is not answering, which is the more urgent fact.
fn link_label(link: &LinkStatus) -> String {
match link.state {
LinkState::Connecting => "connecting…".to_string(),
LinkState::Reconnecting => "reconnecting…".to_string(),
LinkState::Down => "daemon unreachable".to_string(),
LinkState::Online => match (&link.upgraded_to, &link.build) {
(Some(build), _) => format!("updated: {} (restart tui)", format_release(build)),
(None, Some(build)) => format_release(build),
(None, None) => "unknown release".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::Region;
/// A link that answered — the normal case every other render test assumes.
fn online_link() -> LinkStatus {
LinkStatus {
state: LinkState::Online,
build: None,
attempts: 0,
upgraded_to: None,
}
}
#[test]
fn region_map_resolves_known_areas() {
let map = RegionMap {
sidebar: Some(Rect {
x: 0,
y: 0,
width: 10,
height: 20,
}),
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 20,
}),
modal: None,
};
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
assert_eq!(map.region_at(15, 5), Region::Pane);
assert_eq!(map.region_at(50, 50), Region::Unknown);
}
#[test]
fn modal_eclipses_other_regions() {
let map = RegionMap {
sidebar: Some(Rect {
x: 0,
y: 0,
width: 10,
height: 20,
}),
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 20,
}),
modal: Some(Rect {
x: 5,
y: 5,
width: 25,
height: 10,
}),
};
// Even though (15, 10) is inside `pane`, the modal claims it.
assert_eq!(map.region_at(15, 10), Region::Modal);
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(1), Some(group_a));
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
assert_eq!(map.sidebar_item_at(3), Some(card_a));
assert_eq!(map.sidebar_item_at(4), 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(2),
Some(SidebarItem::Group(PathBuf::from("/b")))
);
}
fn frame_text(link: &LinkStatus) -> String {
use crate::overlays::HelpOverlay;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let state = AppState::new();
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link,
};
let backend = ratatui::backend::TestBackend::new(120, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
buf.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("")
}
#[test]
fn the_status_bar_names_the_release_the_daemon_is_running() {
let text = frame_text(&LinkStatus {
state: LinkState::Online,
build: Some(maestro_protocol::BuildInfo {
commit: "f89be4a716c7".into(),
commit_date: "2026-09-02T14:03:11Z".into(),
dirty: false,
}),
attempts: 0,
upgraded_to: None,
});
assert!(text.contains("f89be4a716c7"), "{text}");
assert!(text.contains("2026-09-02"), "{text}");
}
#[test]
fn an_unreachable_daemon_covers_the_frame_with_the_restart_overlay() {
let up = frame_text(&LinkStatus {
state: LinkState::Online,
build: None,
attempts: 0,
upgraded_to: None,
});
assert!(!up.contains("daemon restarting"));
let down = frame_text(&LinkStatus {
state: LinkState::Down,
build: None,
attempts: 4,
upgraded_to: None,
});
assert!(down.contains("daemon restarting"), "{down}");
assert!(down.contains("(4 attempts)"), "{down}");
assert!(down.contains("daemon unreachable"), "{down}");
}
#[test]
fn an_upgraded_daemon_tells_the_user_to_restart_the_tui() {
let text = frame_text(&LinkStatus {
state: LinkState::Online,
build: Some(maestro_protocol::BuildInfo::default()),
attempts: 0,
upgraded_to: Some(maestro_protocol::BuildInfo {
commit: "bbb111222333".into(),
commit_date: "2026-09-03T09:00:00Z".into(),
dirty: false,
}),
});
assert!(text.contains("updated: bbb111222333"), "{text}");
assert!(text.contains("restart tui"), "{text}");
}
/// The spawn form is a modal: the frame shows it over whatever the pane
/// was displaying, framed by its own border.
#[test]
fn the_spawn_overlay_renders_the_form_in_a_modal() {
use crate::overlays::HelpOverlay;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_spawn_modal();
let draft = crate::draft::DraftPane::default();
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: Some(&draft),
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
// Mouse clicks inside the form must not fall through to the pane.
assert!(map.unwrap().modal.is_some());
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
for s in [
"new session",
"cmd:",
"dir:",
"name:",
"[ Spawn ]",
"Other directory…",
] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
#[test]
fn panel_rectangles_are_stable_when_focus_moves() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let mut map = None;
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
map.unwrap()
};
let sidebar_focus = draw_map(Focus::Sidebar);
let pane_focus = draw_map(Focus::Pane);
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
assert_eq!(sidebar_focus.pane, pane_focus.pane);
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
}
#[test]
fn panel_focus_uses_backgrounds_without_borders() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
pane_session: None,
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
pane: None,
structured_pane: None,
draft: None,
state: &state,
help: &help,
rename_input: &crate::text_input::TextInput::default(),
command_bar: None,
link: &online_link(),
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
terminal.backend().buffer().clone()
};
let sidebar_focused = render(Focus::Sidebar);
let pane_focused = render(Focus::Pane);
let p = palette::active();
assert_eq!(sidebar_focused[(0, 0)].style().bg, Some(p.sidebar_focus_bg));
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
assert!(title.contains("session"), "missing title: {title:?}");
}
}