feat(maestro-tui): status bar + focus highlight + j/k sidebar nav (NAP-287)
ci / rust (push) Successful in 15m39s
ci / rust (push) Successful in 15m39s
First-use UX fix: the TUI rendered the daemon list but felt frozen because mode and focus were invisible, and the sidebar had no nav keys, so `Enter`/`AttachHighlighted` had nothing to target. - `render::draw` now reserves the bottom row for a status bar that always shows `[NORMAL]`/`[INSERT]` (cyan/yellow chip), the focused panel (sidebar / pane), and the most useful key hints (`j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit`). Status bar is never eclipsed by overlays. - `Sidebar::render_with_focus` and the pane block now draw their border in cyan when focused, in the default colour otherwise. - New `Action::SidebarNext` / `SidebarPrev`, bound to `j`/`k` and `down`/`up` in the shipped Normal-mode defaults. Dispatch walks the catalog's session list, calling `AppState::move_sidebar_highlight` (wraps both ends). Workspace clippy + 85 unit tests + 3 integration tests green.
This commit is contained in:
@@ -31,6 +31,10 @@ pub enum Action {
|
||||
FocusSidebar,
|
||||
FocusPane(Direction),
|
||||
|
||||
// Sidebar highlight movement
|
||||
SidebarNext,
|
||||
SidebarPrev,
|
||||
|
||||
// Catalog actions on the highlighted sidebar row
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
@@ -70,6 +74,8 @@ impl Action {
|
||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
|
||||
|
||||
@@ -191,6 +191,28 @@ impl App {
|
||||
// either in the sidebar or back in the pane.
|
||||
self.state.focus = Focus::Pane;
|
||||
}
|
||||
Action::SidebarNext => {
|
||||
let order: Vec<Uuid> = self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
self.state.move_sidebar_highlight(&order, 1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::SidebarPrev => {
|
||||
let order: Vec<Uuid> = self
|
||||
.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
self.state.move_sidebar_highlight(&order, -1);
|
||||
self.state.focus = Focus::Sidebar;
|
||||
}
|
||||
Action::NewTab => self.state.new_tab(),
|
||||
Action::CloseTab => self.state.close_tab(),
|
||||
Action::NextTab => self.state.next_tab(),
|
||||
|
||||
@@ -38,6 +38,11 @@ pub enum TuiConfigError {
|
||||
const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// Mode
|
||||
("i", "EnterInsert"),
|
||||
// Sidebar nav
|
||||
("j", "SidebarNext"),
|
||||
("k", "SidebarPrev"),
|
||||
("down", "SidebarNext"),
|
||||
("up", "SidebarPrev"),
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "OpenSpawnModal"),
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
//! hit-test mouse clicks.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
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, SpawnModal, Toast};
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, OverlayKind};
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::tabs::TabBar;
|
||||
|
||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||
@@ -71,22 +74,32 @@ 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(area)
|
||||
.split(body_area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(0), Constraint::Min(0)])
|
||||
.split(area)
|
||||
.split(body_area)
|
||||
};
|
||||
let sidebar_area = cols[0];
|
||||
let right = cols[1];
|
||||
|
||||
if inputs.state.sidebar_visible {
|
||||
inputs.sidebar.render(f, sidebar_area);
|
||||
inputs
|
||||
.sidebar
|
||||
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
||||
map.sidebar = Some(sidebar_area);
|
||||
}
|
||||
|
||||
@@ -100,7 +113,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
inputs.tab_bar.render(f, tab_area);
|
||||
map.tab_bar = Some(tab_area);
|
||||
|
||||
let pane_block = Block::default().borders(Borders::ALL);
|
||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||
let pane_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(focus_style(pane_focused));
|
||||
let pane_inner = pane_block.inner(pane_area);
|
||||
f.render_widget(pane_block, pane_area);
|
||||
if let Some(p) = inputs.pane {
|
||||
@@ -108,6 +124,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
}
|
||||
map.pane = Some(pane_inner);
|
||||
|
||||
// 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::Spawn) => {
|
||||
inputs.spawn.render(f, area);
|
||||
@@ -134,6 +154,43 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
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: spawn • ?: 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::*;
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::HashSet;
|
||||
|
||||
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
@@ -23,7 +23,22 @@ pub struct Sidebar {
|
||||
|
||||
impl Sidebar {
|
||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||
let block = Block::default().title(" daemons ").borders(Borders::ALL);
|
||||
self.render_with_focus(f, area, false);
|
||||
}
|
||||
|
||||
/// Same as [`Sidebar::render`] but draws the border in the focus colour
|
||||
/// when `focused`. Used by the top-level orchestrator to surface which
|
||||
/// panel currently consumes navigation keys.
|
||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||
let border_style = if focused {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(" daemons ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
|
||||
@@ -120,6 +120,22 @@ impl AppState {
|
||||
self.focus = Focus::Pane;
|
||||
}
|
||||
|
||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
||||
if order.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = order.len() as i32;
|
||||
let cur = self
|
||||
.sidebar_highlight
|
||||
.and_then(|id| order.iter().position(|x| *x == id))
|
||||
.map(|i| i as i32)
|
||||
.unwrap_or(-1);
|
||||
let next = (cur + delta).rem_euclid(len) as usize;
|
||||
self.sidebar_highlight = Some(order[next]);
|
||||
}
|
||||
|
||||
/// Toggle sidebar visibility.
|
||||
pub fn toggle_sidebar(&mut self) {
|
||||
self.sidebar_visible = !self.sidebar_visible;
|
||||
@@ -204,6 +220,37 @@ mod tests {
|
||||
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_walks_order_and_wraps() {
|
||||
let mut state = AppState::new();
|
||||
let a = Uuid::from_u128(1);
|
||||
let b = Uuid::from_u128(2);
|
||||
let c = Uuid::from_u128(3);
|
||||
let order = vec![a, b, c];
|
||||
|
||||
// No prior highlight + delta +1 lands on first item.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(b));
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
// Wraps forward.
|
||||
state.move_sidebar_highlight(&order, 1);
|
||||
assert_eq!(state.sidebar_highlight, Some(a));
|
||||
// Wraps backward.
|
||||
state.move_sidebar_highlight(&order, -1);
|
||||
assert_eq!(state.sidebar_highlight, Some(c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_sidebar_highlight_is_noop_on_empty_order() {
|
||||
let mut state = AppState::new();
|
||||
state.move_sidebar_highlight(&[], 1);
|
||||
assert_eq!(state.sidebar_highlight, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_binds_to_active_tab_and_focuses_pane() {
|
||||
let mut state = AppState::new();
|
||||
|
||||
Reference in New Issue
Block a user