361 lines
11 KiB
Rust
361 lines
11 KiB
Rust
//! 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::style::{Color, Style};
|
|
use ratatui::text::Span;
|
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
|
use ratatui::Frame;
|
|
|
|
use crate::input::Region;
|
|
use crate::keymap::Mode;
|
|
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
|
use crate::pane::Pane;
|
|
use crate::sidebar::Sidebar;
|
|
use crate::state::{AppState, Focus, OverlayKind};
|
|
use crate::tabs::TabBar;
|
|
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
|
|
|
/// 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>,
|
|
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
|
pub draft: Option<&'a crate::draft::DraftPane>,
|
|
pub state: &'a AppState,
|
|
pub help: &'a HelpOverlay,
|
|
/// Retained-worktree entries for the worktrees view (#85).
|
|
pub worktrees: Option<&'a WorktreesView>,
|
|
}
|
|
|
|
/// 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(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
|
map.sidebar = Some(sidebar_area);
|
|
}
|
|
|
|
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
|
|
// indicator) + flex pane area below. Saves the 4 rows the old
|
|
// `Length(3) + Borders::ALL` layout was eating before any content showed.
|
|
let rows = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(1),
|
|
Constraint::Length(1),
|
|
Constraint::Min(0),
|
|
])
|
|
.split(right);
|
|
let tab_area = rows[0];
|
|
let separator_area = rows[1];
|
|
let pane_area = rows[2];
|
|
|
|
inputs.tab_bar.render(f, tab_area);
|
|
map.tab_bar = Some(tab_area);
|
|
|
|
let pane_focused = inputs.state.focus == Focus::Pane;
|
|
f.render_widget(
|
|
Block::default()
|
|
.borders(Borders::TOP)
|
|
.border_style(focus_style(pane_focused)),
|
|
separator_area,
|
|
);
|
|
if let Some(p) = inputs.pane {
|
|
p.render(f, pane_area);
|
|
}
|
|
if let Some(d) = inputs.draft {
|
|
d.render(f, pane_area, pane_focused);
|
|
}
|
|
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);
|
|
|
|
match inputs.state.overlay() {
|
|
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::Help) => {
|
|
inputs.help.render(f, area);
|
|
map.modal = Some(area);
|
|
}
|
|
Some(OverlayKind::Worktrees) => {
|
|
if let Some(view) = inputs.worktrees {
|
|
view.render(f, area);
|
|
map.modal = Some(area);
|
|
}
|
|
}
|
|
Some(OverlayKind::ReclaimConfirm {
|
|
branch, blockers, ..
|
|
}) => {
|
|
let rc = ReclaimConfirm {
|
|
branch: branch.clone(),
|
|
blockers: blockers.clone(),
|
|
};
|
|
rc.render(f, area);
|
|
map.modal = Some(area);
|
|
}
|
|
Some(OverlayKind::Toast(text)) => {
|
|
let t = Toast { text: text.clone() };
|
|
t.render(f, area);
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
map
|
|
}
|
|
|
|
/// Border style for a panel based on whether it currently has focus.
|
|
/// Focused = cyan; unfocused = the terminal default.
|
|
fn focus_style(focused: bool) -> Style {
|
|
if focused {
|
|
Style::default().fg(Color::Cyan)
|
|
} else {
|
|
Style::default()
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
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 state.mode {
|
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
|
Mode::Insert => "^a: back to normal",
|
|
};
|
|
let mode_style = match state.mode {
|
|
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
|
|
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
|
|
};
|
|
let 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), area);
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
|
/// form where the terminal would otherwise be.
|
|
#[test]
|
|
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
|
|
use crate::overlays::HelpOverlay;
|
|
use crate::state::PaneRef;
|
|
use std::collections::HashSet;
|
|
|
|
let mut state = AppState::new();
|
|
state.open_draft_pane();
|
|
let draft = match &state.tabs[0].pane {
|
|
Some(PaneRef::Draft(d)) => d,
|
|
other => panic!("expected a draft pane, got {other:?}"),
|
|
};
|
|
let sidebar = Sidebar {
|
|
sessions: Vec::new(),
|
|
highlight: None,
|
|
attached: HashSet::new(),
|
|
};
|
|
let tab_bar = TabBar {
|
|
tab_names: vec!["1".into()],
|
|
active_idx: 0,
|
|
};
|
|
let help = HelpOverlay { lines: Vec::new() };
|
|
let inputs = RenderInputs {
|
|
sidebar: &sidebar,
|
|
tab_bar: &tab_bar,
|
|
pane: None,
|
|
draft: Some(draft),
|
|
state: &state,
|
|
help: &help,
|
|
worktrees: None,
|
|
};
|
|
|
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
|
terminal
|
|
.draw(|f| {
|
|
draw(f, inputs);
|
|
})
|
|
.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');
|
|
}
|
|
for s in [
|
|
"draft",
|
|
"cmd:",
|
|
"dir:",
|
|
"name:",
|
|
"[ Spawn ]",
|
|
"Other directory…",
|
|
] {
|
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
|
}
|
|
}
|
|
}
|