684 lines
23 KiB
Rust
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: ¤t,
|
|
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:?}");
|
|
}
|
|
}
|