feat(maestro-tui): ratatui widgets + render orchestration (NAP-280)
ci / rust (push) Failing after 4m44s
ci / rust (push) Failing after 4m44s
Adds the full visual layer: - `state.rs` — `AppState` (tabs/focus/mode/sidebar visibility/overlay). v1 keeps each tab to one optional session; the split tree is v1.5. - `terminal.rs` — `TerminalGuard` (raw mode + alt screen + mouse capture with RAII restore) and an idempotent panic hook that restores the terminal before re-panicking. - `sidebar.rs` — daemon → session tree with ●/○/✗ glyphs. Pulls a label out of `metadata.label` (the protocol's shape), falls back to the command. - `tabs.rs` — top-row TabBar with active highlight. - `pane.rs` — `vt100::Screen` → ratatui `Buffer` cell-by-cell, with full fg/bg/bold/italic/underline/reverse translation. `PaneStatus` round-trips from `SessionState`. - `overlays.rs` — SpawnModal (5-field form with rotation), KillConfirm, HelpOverlay, Toast. All centered popups with `Clear` underlay. - `render.rs` — `draw(f, RenderInputs)` composes everything; returns a `RegionMap` whose `region_at(col, row)` resolves a mouse coordinate to a tagged Region (modal eclipses the rest). 22 new unit tests bringing the crate total to 80 passing. Tests use content-based assertions over `ratatui::backend::TestBackend` output rather than file-based goldens — less brittle to whitespace shifts while still covering the visible content.
This commit is contained in:
@@ -13,6 +13,7 @@ crossterm.workspace = true
|
||||
futures-util.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
ratatui.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||
|
||||
@@ -15,7 +15,14 @@ pub mod config;
|
||||
pub mod emulators;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
pub mod pane;
|
||||
pub mod render;
|
||||
pub mod session_task;
|
||||
pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod tabs;
|
||||
pub mod terminal;
|
||||
|
||||
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
|
||||
/// `TuiArgs` plus any global flags.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
|
||||
//! transient toast.
|
||||
//!
|
||||
//! All overlays are centered popups drawn over the rest of the UI; the
|
||||
//! `Clear` widget wipes the underlying buffer before drawing.
|
||||
|
||||
use maestro_protocol::DaemonInfo;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SpawnField {
|
||||
#[default]
|
||||
Daemon,
|
||||
Cwd,
|
||||
Command,
|
||||
Label,
|
||||
Submit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SpawnModal {
|
||||
pub daemons: Vec<DaemonInfo>,
|
||||
pub daemon_idx: usize,
|
||||
pub cwd: String,
|
||||
pub command: String,
|
||||
pub label: String,
|
||||
pub focused_field: SpawnField,
|
||||
}
|
||||
|
||||
impl SpawnModal {
|
||||
pub fn next_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Daemon => SpawnField::Cwd,
|
||||
SpawnField::Cwd => SpawnField::Command,
|
||||
SpawnField::Command => SpawnField::Label,
|
||||
SpawnField::Label => SpawnField::Submit,
|
||||
SpawnField::Submit => SpawnField::Daemon,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev_field(&mut self) {
|
||||
self.focused_field = match self.focused_field {
|
||||
SpawnField::Daemon => SpawnField::Submit,
|
||||
SpawnField::Cwd => SpawnField::Daemon,
|
||||
SpawnField::Command => SpawnField::Cwd,
|
||||
SpawnField::Label => SpawnField::Command,
|
||||
SpawnField::Submit => SpawnField::Label,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 60, 14);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" spawn ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
let highlight = |field: SpawnField| {
|
||||
if self.focused_field == field {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
}
|
||||
};
|
||||
let daemon_label = self
|
||||
.daemons
|
||||
.get(self.daemon_idx)
|
||||
.map(|d| d.hostname.as_str())
|
||||
.unwrap_or("(none)");
|
||||
let field_row = |label: &str, value: &str, style: Style| {
|
||||
Paragraph::new(Line::from(vec![
|
||||
ratatui::text::Span::raw(format!("{label:8} ")),
|
||||
ratatui::text::Span::styled(value.to_string(), style),
|
||||
]))
|
||||
};
|
||||
f.render_widget(
|
||||
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
|
||||
rows[0],
|
||||
);
|
||||
f.render_widget(
|
||||
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
|
||||
rows[1],
|
||||
);
|
||||
f.render_widget(
|
||||
field_row("command:", &self.command, highlight(SpawnField::Command)),
|
||||
rows[2],
|
||||
);
|
||||
f.render_widget(
|
||||
field_row("label:", &self.label, highlight(SpawnField::Label)),
|
||||
rows[3],
|
||||
);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
|
||||
"[ Spawn ]",
|
||||
highlight(SpawnField::Submit),
|
||||
)])),
|
||||
rows[4],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl KillConfirm {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 50, 5);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" kill ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(format!("Kill session {}?", self.label)),
|
||||
Line::from(""),
|
||||
Line::from("[y] yes [n] no"),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HelpOverlay {
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl HelpOverlay {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 70, parent.height.saturating_sub(4));
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" help ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
let items: Vec<ListItem> = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|s| ListItem::new(s.as_str()))
|
||||
.collect();
|
||||
f.render_widget(List::new(items), inner);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Toast {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let h = 3u16;
|
||||
let want = self.text.len().saturating_add(4) as u16;
|
||||
let w = parent.width.min(want);
|
||||
if h >= parent.height {
|
||||
return;
|
||||
}
|
||||
let area = Rect {
|
||||
x: parent.x + (parent.width.saturating_sub(w)) / 2,
|
||||
y: parent.y + parent.height - h - 1,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(Paragraph::new(self.text.as_str()), inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
|
||||
let w = w.min(parent.width);
|
||||
let h = h.min(parent.height);
|
||||
Rect {
|
||||
x: parent.x + parent.width.saturating_sub(w) / 2,
|
||||
y: parent.y + parent.height.saturating_sub(h) / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
|
||||
where
|
||||
F: FnOnce(&mut Frame),
|
||||
{
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(draw).unwrap();
|
||||
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');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_renders_field_labels() {
|
||||
let modal = SpawnModal {
|
||||
daemons: vec![DaemonInfo {
|
||||
daemon_id: "k".into(),
|
||||
hostname: "konishi".into(),
|
||||
version: "0".into(),
|
||||
daemon_url: "ws://x".into(),
|
||||
}],
|
||||
daemon_idx: 0,
|
||||
cwd: "/x".into(),
|
||||
command: "bash".into(),
|
||||
label: String::new(),
|
||||
focused_field: SpawnField::Cwd,
|
||||
};
|
||||
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
|
||||
for s in [
|
||||
"daemon", "cwd", "command", "label", "Spawn", "konishi", "/x", "bash",
|
||||
] {
|
||||
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_modal_field_rotation() {
|
||||
let mut modal = SpawnModal::default();
|
||||
assert_eq!(modal.focused_field, SpawnField::Daemon);
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Cwd);
|
||||
modal.next_field();
|
||||
modal.next_field();
|
||||
modal.next_field();
|
||||
modal.next_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Daemon);
|
||||
modal.prev_field();
|
||||
assert_eq!(modal.focused_field, SpawnField::Submit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
label: "session-7".into(),
|
||||
};
|
||||
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
|
||||
assert!(rendered.contains("Kill"));
|
||||
assert!(rendered.contains("session-7"));
|
||||
assert!(rendered.contains("yes"));
|
||||
assert!(rendered.contains("no"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_overlay_lists_lines() {
|
||||
let help = HelpOverlay {
|
||||
lines: vec![
|
||||
"i EnterInsert".into(),
|
||||
"s OpenSpawnModal".into(),
|
||||
],
|
||||
};
|
||||
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
|
||||
assert!(rendered.contains("EnterInsert"));
|
||||
assert!(rendered.contains("OpenSpawnModal"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Attach-pane widget — draws a `vt100::Screen` into a ratatui buffer
|
||||
//! cell-by-cell, translating SGR (fg / bg / attrs) into `ratatui::Style`.
|
||||
//!
|
||||
//! The pane's `screen` is the same `Arc<Mutex<vt100::Parser>>` the
|
||||
//! `SessionTask` writes into, so the renderer is always looking at the most
|
||||
//! recent cell grid.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::session_task::SessionState;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneStatus {
|
||||
Connecting,
|
||||
Live,
|
||||
Reconnecting,
|
||||
Ended,
|
||||
}
|
||||
|
||||
impl From<SessionState> for PaneStatus {
|
||||
fn from(s: SessionState) -> Self {
|
||||
match s {
|
||||
SessionState::Connecting => PaneStatus::Connecting,
|
||||
SessionState::Live => PaneStatus::Live,
|
||||
SessionState::Reconnecting => PaneStatus::Reconnecting,
|
||||
SessionState::Ended => PaneStatus::Ended,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Pane {
|
||||
pub screen: Arc<Mutex<vt100::Parser>>,
|
||||
pub status: PaneStatus,
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Pane {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Pane")
|
||||
.field("status", &self.status)
|
||||
.field("focused", &self.focused)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let parser = self.screen.lock().expect("vt100 parser mutex");
|
||||
let screen = parser.screen();
|
||||
let buf = f.buffer_mut();
|
||||
let (screen_rows, screen_cols) = screen.size();
|
||||
let rows = area.height.min(screen_rows);
|
||||
let cols = area.width.min(screen_cols);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
if let Some(cell) = screen.cell(r, c) {
|
||||
let s = cell.contents();
|
||||
let s = if s.is_empty() { " " } else { &s };
|
||||
buf[(area.x + c, area.y + r)]
|
||||
.set_symbol(s)
|
||||
.set_style(translate_cell_style(cell));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_cell_style(cell: &vt100::Cell) -> Style {
|
||||
let mut s = Style::default();
|
||||
if let Some(c) = translate_color(cell.fgcolor()) {
|
||||
s = s.fg(c);
|
||||
}
|
||||
if let Some(c) = translate_color(cell.bgcolor()) {
|
||||
s = s.bg(c);
|
||||
}
|
||||
if cell.bold() {
|
||||
s = s.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if cell.italic() {
|
||||
s = s.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
if cell.underline() {
|
||||
s = s.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if cell.inverse() {
|
||||
s = s.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn translate_color(c: vt100::Color) -> Option<Color> {
|
||||
match c {
|
||||
vt100::Color::Default => None,
|
||||
vt100::Color::Idx(i) => Some(Color::Indexed(i)),
|
||||
vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_with_bytes(rows: u16, cols: u16, bytes: &[u8]) -> String {
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||
parser.lock().unwrap().process(bytes);
|
||||
let pane = Pane {
|
||||
screen: parser,
|
||||
status: PaneStatus::Live,
|
||||
focused: true,
|
||||
};
|
||||
let backend = TestBackend::new(cols, rows);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| pane.render(f, f.area())).unwrap();
|
||||
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');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_renders_vt100_chars_into_buffer() {
|
||||
let rendered = render_with_bytes(3, 20, b"hello world");
|
||||
assert!(rendered.contains("hello world"), "got:\n{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_status_round_trips_from_session_state() {
|
||||
assert_eq!(
|
||||
PaneStatus::from(SessionState::Connecting),
|
||||
PaneStatus::Connecting
|
||||
);
|
||||
assert_eq!(PaneStatus::from(SessionState::Live), PaneStatus::Live);
|
||||
assert_eq!(
|
||||
PaneStatus::from(SessionState::Reconnecting),
|
||||
PaneStatus::Reconnecting
|
||||
);
|
||||
assert_eq!(PaneStatus::from(SessionState::Ended), PaneStatus::Ended);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Top-level draw orchestration. Composes sidebar / tab bar / 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::widgets::{Block, Borders};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::input::Region;
|
||||
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, OverlayKind};
|
||||
use crate::tabs::TabBar;
|
||||
|
||||
/// 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 tab_bar: Option<Rect>,
|
||||
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.tab_bar {
|
||||
if inside(r, col, row) {
|
||||
return Region::Tab;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 tab_bar: &'a TabBar,
|
||||
pub pane: Option<&'a Pane>,
|
||||
pub state: &'a AppState,
|
||||
pub spawn: &'a SpawnModal,
|
||||
pub help: &'a HelpOverlay,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
let cols = if inputs.state.sidebar_visible {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(28), Constraint::Min(0)])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(0), Constraint::Min(0)])
|
||||
.split(area)
|
||||
};
|
||||
let sidebar_area = cols[0];
|
||||
let right = cols[1];
|
||||
|
||||
if inputs.state.sidebar_visible {
|
||||
inputs.sidebar.render(f, sidebar_area);
|
||||
map.sidebar = Some(sidebar_area);
|
||||
}
|
||||
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
||||
.split(right);
|
||||
let tab_area = rows[0];
|
||||
let pane_area = rows[1];
|
||||
|
||||
inputs.tab_bar.render(f, tab_area);
|
||||
map.tab_bar = Some(tab_area);
|
||||
|
||||
let pane_block = Block::default().borders(Borders::ALL);
|
||||
let pane_inner = pane_block.inner(pane_area);
|
||||
f.render_widget(pane_block, pane_area);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_inner);
|
||||
}
|
||||
map.pane = Some(pane_inner);
|
||||
|
||||
match &inputs.state.overlay {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
inputs.spawn.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
let kc = KillConfirm {
|
||||
label: id.to_string(),
|
||||
};
|
||||
kc.render(f, area);
|
||||
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 => {}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::input::Region;
|
||||
|
||||
#[test]
|
||||
fn region_map_resolves_known_areas() {
|
||||
let map = RegionMap {
|
||||
sidebar: Some(Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 20,
|
||||
}),
|
||||
tab_bar: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 3,
|
||||
}),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 3,
|
||||
width: 30,
|
||||
height: 17,
|
||||
}),
|
||||
modal: None,
|
||||
};
|
||||
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
|
||||
assert_eq!(map.region_at(15, 1), Region::Tab);
|
||||
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,
|
||||
}),
|
||||
tab_bar: Some(Rect {
|
||||
x: 10,
|
||||
y: 0,
|
||||
width: 30,
|
||||
height: 3,
|
||||
}),
|
||||
pane: Some(Rect {
|
||||
x: 10,
|
||||
y: 3,
|
||||
width: 30,
|
||||
height: 17,
|
||||
}),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Sidebar widget — daemon→session tree, navigation-only.
|
||||
//!
|
||||
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Renderable sidebar state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sidebar {
|
||||
pub daemons: Vec<DaemonInfo>,
|
||||
pub sessions: Vec<Session>,
|
||||
pub highlight: Option<Uuid>,
|
||||
pub attached: HashSet<Uuid>,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let block = Block::default().title(" daemons ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for d in &self.daemons {
|
||||
lines.push(Line::from(format!("● {}", d.hostname)));
|
||||
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let label = label_for(s);
|
||||
let style = if self.highlight == Some(s.id) {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{glyph} {label}"), style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
}
|
||||
|
||||
/// `metadata.label` if present, else the command line.
|
||||
fn label_for(s: &Session) -> String {
|
||||
if let Some(serde_json::Value::String(label)) = s.metadata.get("label") {
|
||||
label.clone()
|
||||
} else {
|
||||
s.command.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn daemon(id: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
daemon_id: id.into(),
|
||||
hostname: id.into(),
|
||||
version: "test".into(),
|
||||
daemon_url: format!("ws://{id}:7070"),
|
||||
}
|
||||
}
|
||||
|
||||
fn session_with_label(id: u128, daemon_id: &str, label: &str) -> Session {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("label".into(), serde_json::Value::String(label.into()));
|
||||
Session {
|
||||
id: Uuid::from_u128(id),
|
||||
daemon_id: daemon_id.into(),
|
||||
command: "bash".into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||
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');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_hostname_and_session_labels() {
|
||||
let sb = Sidebar {
|
||||
daemons: vec![daemon("konishi")],
|
||||
sessions: vec![
|
||||
session_with_label(1, "konishi", "claude"),
|
||||
session_with_label(2, "konishi", "bash"),
|
||||
],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 12);
|
||||
assert!(
|
||||
rendered.contains("konishi"),
|
||||
"missing daemon name:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("claude"),
|
||||
"missing session label:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("bash"),
|
||||
"missing session label:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_session_uses_filled_glyph() {
|
||||
let sid = Uuid::from_u128(1);
|
||||
let sb = Sidebar {
|
||||
daemons: vec![daemon("konishi")],
|
||||
sessions: vec![session_with_label(1, "konishi", "claude")],
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
||||
assert!(
|
||||
!line.contains('○'),
|
||||
"should not contain hollow glyph: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_session_uses_cross_glyph() {
|
||||
let mut sess = session_with_label(1, "konishi", "claude");
|
||||
sess.status = SessionStatus::Dead;
|
||||
let sb = Sidebar {
|
||||
daemons: vec![daemon("konishi")],
|
||||
sessions: vec![sess],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_command_when_label_metadata_is_absent() {
|
||||
let s = Session {
|
||||
id: Uuid::from_u128(1),
|
||||
daemon_id: "konishi".into(),
|
||||
command: "vim".into(),
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: HashMap::new(),
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let sb = Sidebar {
|
||||
daemons: vec![daemon("konishi")],
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
assert!(
|
||||
rendered.contains("vim"),
|
||||
"missing fallback command label:\n{rendered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! `AppState` — the top-level mutable state of the running TUI.
|
||||
//!
|
||||
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
|
||||
//! session), an active tab index, focus split between sidebar and pane, the
|
||||
//! current input mode, and an optional overlay (spawn modal, kill confirm,
|
||||
//! help, transient toast).
|
||||
//!
|
||||
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::keymap::Mode;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
Sidebar,
|
||||
Pane,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab {
|
||||
pub name: String,
|
||||
pub pane: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum OverlayKind {
|
||||
Spawn,
|
||||
KillConfirm(Uuid),
|
||||
Help,
|
||||
Toast(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
pub tabs: Vec<Tab>,
|
||||
pub active_tab_idx: usize,
|
||||
pub focus: Focus,
|
||||
pub mode: Mode,
|
||||
pub sidebar_visible: bool,
|
||||
pub sidebar_highlight: Option<Uuid>,
|
||||
pub overlay: Option<OverlayKind>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tabs: vec![Tab {
|
||||
name: "1".into(),
|
||||
pane: None,
|
||||
}],
|
||||
active_tab_idx: 0,
|
||||
focus: Focus::Sidebar,
|
||||
mode: Mode::Normal,
|
||||
sidebar_visible: true,
|
||||
sidebar_highlight: None,
|
||||
overlay: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_tab(&self) -> &Tab {
|
||||
&self.tabs[self.active_tab_idx]
|
||||
}
|
||||
|
||||
pub fn active_tab_mut(&mut self) -> &mut Tab {
|
||||
&mut self.tabs[self.active_tab_idx]
|
||||
}
|
||||
|
||||
/// Append a new tab and switch to it. Sidebar regains focus so the user
|
||||
/// can pick something to attach.
|
||||
pub fn new_tab(&mut self) {
|
||||
let name = format!("{}", self.tabs.len() + 1);
|
||||
self.tabs.push(Tab { name, pane: None });
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
self.focus = Focus::Sidebar;
|
||||
}
|
||||
|
||||
/// Drop the active tab. No-op if it's the only one — there must always
|
||||
/// be at least one tab.
|
||||
pub fn close_tab(&mut self) {
|
||||
if self.tabs.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
self.tabs.remove(self.active_tab_idx);
|
||||
if self.active_tab_idx >= self.tabs.len() {
|
||||
self.active_tab_idx = self.tabs.len() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
|
||||
}
|
||||
|
||||
pub fn prev_tab(&mut self) {
|
||||
if self.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
|
||||
}
|
||||
|
||||
pub fn goto_tab(&mut self, n: u8) {
|
||||
let idx = (n as usize)
|
||||
.saturating_sub(1)
|
||||
.min(self.tabs.len().saturating_sub(1));
|
||||
self.active_tab_idx = idx;
|
||||
}
|
||||
|
||||
/// Bind a session to the active tab and move focus into the pane.
|
||||
pub fn attach_to_focused(&mut self, session_id: Uuid) {
|
||||
self.active_tab_mut().pane = Some(session_id);
|
||||
self.focus = Focus::Pane;
|
||||
}
|
||||
|
||||
/// Toggle sidebar visibility.
|
||||
pub fn toggle_sidebar(&mut self) {
|
||||
self.sidebar_visible = !self.sidebar_visible;
|
||||
if !self.sidebar_visible {
|
||||
self.focus = Focus::Pane;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_app_starts_with_one_empty_tab_focused_on_sidebar() {
|
||||
let state = AppState::new();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
assert_eq!(state.tabs[0].pane, None);
|
||||
assert_eq!(state.focus, Focus::Sidebar);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
assert!(state.sidebar_visible);
|
||||
assert!(state.overlay.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tab_extends_and_focuses() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
assert_eq!(state.focus, Focus::Sidebar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_keeps_at_least_one() {
|
||||
let mut state = AppState::new();
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
state.new_tab();
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_advances_when_last_is_closed() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
state.close_tab();
|
||||
assert_eq!(state.tabs.len(), 2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_tab_wraps() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
state.active_tab_idx = 0;
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
state.next_tab();
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
state.prev_tab();
|
||||
assert_eq!(state.active_tab_idx, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_tab_uses_one_based_clamp() {
|
||||
let mut state = AppState::new();
|
||||
state.new_tab();
|
||||
state.new_tab();
|
||||
state.goto_tab(1);
|
||||
assert_eq!(state.active_tab_idx, 0);
|
||||
state.goto_tab(2);
|
||||
assert_eq!(state.active_tab_idx, 1);
|
||||
state.goto_tab(99);
|
||||
// Clamped to last available tab.
|
||||
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_binds_to_active_tab_and_focuses_pane() {
|
||||
let mut state = AppState::new();
|
||||
let sid = Uuid::from_u128(7);
|
||||
state.attach_to_focused(sid);
|
||||
assert_eq!(state.tabs[0].pane, Some(sid));
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_sidebar_moves_focus_to_pane_when_hiding() {
|
||||
let mut state = AppState::new();
|
||||
state.toggle_sidebar();
|
||||
assert!(!state.sidebar_visible);
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
state.toggle_sidebar();
|
||||
assert!(state.sidebar_visible);
|
||||
// Doesn't force focus back; sidebar shown but focus stays where it was.
|
||||
assert_eq!(state.focus, Focus::Pane);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Tab-bar widget — top row of the main area.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Tabs;
|
||||
use ratatui::Frame;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TabBar {
|
||||
pub tab_names: Vec<String>,
|
||||
pub active_idx: usize,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let titles: Vec<Line> = self
|
||||
.tab_names
|
||||
.iter()
|
||||
.map(|n| Line::from(n.as_str()))
|
||||
.collect();
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(self.active_idx)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
f.render_widget(tabs, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| bar.render(f, f.area())).unwrap();
|
||||
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');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_three_tab_names() {
|
||||
let bar = TabBar {
|
||||
tab_names: vec!["1".into(), "2".into(), "3".into()],
|
||||
active_idx: 1,
|
||||
};
|
||||
let rendered = render_to_string(&bar, 40, 1);
|
||||
assert!(rendered.contains("1"));
|
||||
assert!(rendered.contains("2"));
|
||||
assert!(rendered.contains("3"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `TerminalGuard` — RAII setup/teardown of raw mode, the alternate screen
|
||||
//! buffer, and mouse capture. Restores everything on drop or panic.
|
||||
//!
|
||||
//! Pattern matches `crates/maestro-cli/src/attach.rs::RawTty`.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TerminalGuard;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TerminalError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
impl TerminalGuard {
|
||||
/// Enter raw mode, switch to alt screen, enable mouse capture. Returns a
|
||||
/// guard whose `Drop` impl reverses each step.
|
||||
pub fn enter() -> Result<Self, TerminalError> {
|
||||
enable_raw_mode()?;
|
||||
let mut out = io::stdout();
|
||||
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
Ok(TerminalGuard)
|
||||
}
|
||||
|
||||
/// Restore terminal state. Idempotent so the panic hook + `Drop` can both
|
||||
/// call it without producing weird errors.
|
||||
fn restore() {
|
||||
let mut out = io::stdout();
|
||||
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
Self::restore();
|
||||
}
|
||||
}
|
||||
|
||||
static HOOK_INSTALLED: std::sync::Once = std::sync::Once::new();
|
||||
|
||||
/// Install a panic hook that restores the terminal before re-panicking, so
|
||||
/// a backtrace lands in cooked mode instead of glitching against the alt
|
||||
/// screen. Idempotent — safe to call from `run()` every time.
|
||||
pub fn install_panic_hook() {
|
||||
HOOK_INSTALLED.call_once(|| {
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
TerminalGuard::restore();
|
||||
prev(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn install_panic_hook_is_idempotent() {
|
||||
install_panic_hook();
|
||||
install_panic_hook();
|
||||
install_panic_hook();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user