herdr/src/ui.rs

2588 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
Frame,
};
use tui_term::widget::PseudoTerminal;
use crate::app::state::{ToastKind, ToastNotification};
use crate::app::{AppState, Mode};
use crate::detect::AgentState;
use crate::layout::PaneInfo;
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18;
pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36;
// Braille spinner frames — smooth rotation
const SPINNERS: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame.
/// We want ~8 updates/sec so divide by 8.
fn spinner_frame(tick: u32) -> &'static str {
SPINNERS[(tick as usize / 8) % SPINNERS.len()]
}
use crate::app::state::Palette;
/// Compute view geometry and reconcile pane sizes.
/// Called before render to separate mutation from drawing.
pub fn compute_view(app: &mut AppState, area: Rect) {
let sidebar_w = if app.sidebar_collapsed {
COLLAPSED_WIDTH
} else if app.sidebar_width_auto {
compute_sidebar_width(app)
} else {
app.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
};
let [sidebar_area, main_area] =
Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some();
let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 {
let [tab_bar_rect, terminal_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
(tab_bar_rect, terminal_area)
} else {
(Rect::default(), main_area)
};
let tab_hit_areas = app
.active
.and_then(|i| app.workspaces.get(i))
.map(|ws| compute_tab_hit_areas(ws, tab_bar_rect))
.unwrap_or_default();
let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect);
let split_borders = app
.active
.and_then(|i| app.workspaces.get(i))
.map(|ws| ws.layout.splits(terminal_area))
.unwrap_or_default();
let pane_infos = compute_pane_infos(app, terminal_area);
app.view = crate::app::ViewState {
sidebar_rect: sidebar_area,
tab_bar_rect,
tab_hit_areas,
new_tab_hit_area,
terminal_area,
pane_infos,
split_borders,
};
}
/// Render the UI — reads AppState but does not mutate it.
pub fn render(app: &AppState, frame: &mut Frame) {
let sidebar_area = app.view.sidebar_rect;
let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
if app.sidebar_collapsed {
render_sidebar_collapsed(app, frame, sidebar_area);
} else {
render_sidebar(app, frame, sidebar_area);
}
render_tab_bar(app, frame, tab_bar_area);
render_panes(app, frame, terminal_area);
match app.mode {
Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()),
Mode::ReleaseNotes => render_release_notes_overlay(app, frame, frame.area()),
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
Mode::ContextMenu => {
render_navigate_overlay(app, frame, terminal_area);
render_context_menu(app, frame);
}
Mode::Settings => render_settings_overlay(app, frame, frame.area()),
Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()),
Mode::GlobalMenu => render_global_launcher_menu(app, frame),
Mode::KeybindHelp => render_keybind_help_overlay(app, frame),
Mode::Terminal => {}
}
// Notifications (rendered on top of everything)
let has_config_diagnostic = app.config_diagnostic.is_some();
if let Some(message) = &app.config_diagnostic {
render_config_diagnostic(frame, terminal_area, message, &app.palette);
}
if let Some(toast) = &app.toast {
render_toast_notification(
frame,
terminal_area,
toast,
has_config_diagnostic,
&app.palette,
);
}
}
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut x = area.x;
let mut rects = Vec::new();
let right = area.x + area.width;
for tab in &ws.tabs {
if x >= right.saturating_sub(NEW_TAB_WIDTH) {
break;
}
let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH);
let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x);
let width = desired.min(remaining).max(1);
rects.push(Rect::new(x, area.y, width, 1));
x = x.saturating_add(width + 1);
}
rects
}
fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect {
if area.width == 0 || area.height == 0 {
return Rect::default();
}
let x = tab_hit_areas
.last()
.map(|rect| rect.x + rect.width + 1)
.unwrap_or(area.x)
.min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH));
Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1)
}
fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let Some(ws_idx) = app.active else {
return;
};
let Some(ws) = app.workspaces.get(ws_idx) else {
return;
};
let p = &app.palette;
frame.render_widget(
Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
area,
);
for (idx, tab) in ws.tabs.iter().enumerate() {
let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
break;
};
let active = idx == ws.active_tab;
let style = if active {
Style::default()
.fg(p.panel_bg)
.bg(p.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(p.overlay1).bg(p.surface0)
};
let width = rect.width as usize;
let name = tab.display_name();
let text = format!(" {:width$}", name, width = width.saturating_sub(1));
frame.render_widget(Paragraph::new(text).style(style), rect);
}
if app.view.new_tab_hit_area.width > 0 {
frame.render_widget(
Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
app.view.new_tab_hit_area,
);
}
}
/// Compute pane layout info and resize pane runtimes to match.
fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let Some(ws_idx) = app.active else {
return Vec::new();
};
let Some(ws) = app.workspaces.get(ws_idx) else {
return Vec::new();
};
let multi_pane = ws.layout.pane_count() > 1;
let terminal_active = app.mode == Mode::Terminal;
if ws.zoomed {
let focused_id = ws.layout.focused();
let mut inner_rect = area;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&focused_id) {
if rt
.scroll_metrics()
.is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1)
{
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
area.x + area.width.saturating_sub(1),
area.y,
1,
area.height,
));
}
rt.resize(inner_rect.height, inner_rect.width);
}
return vec![PaneInfo {
id: focused_id,
rect: area,
inner_rect,
scrollbar_rect,
is_focused: true,
}];
}
let mut pane_infos = ws.layout.panes(area);
for info in &mut pane_infos {
let pane_inner = if multi_pane {
let border_set = if info.is_focused && terminal_active {
ratatui::symbols::border::THICK
} else {
ratatui::symbols::border::PLAIN
};
let block = Block::default()
.borders(Borders::ALL)
.border_set(border_set);
block.inner(info.rect)
} else {
area
};
let mut inner_rect = pane_inner;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&info.id) {
if rt
.scroll_metrics()
.is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1)
{
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
pane_inner.x + pane_inner.width.saturating_sub(1),
pane_inner.y,
1,
pane_inner.height,
));
}
rt.resize(inner_rect.height, inner_rect.width);
}
info.inner_rect = inner_rect;
info.scrollbar_rect = scrollbar_rect;
}
pane_infos
}
/// Auto-scale sidebar width based on workspace identity + agent summary.
fn compute_sidebar_width(app: &AppState) -> u16 {
if app.workspaces.is_empty() {
return app
.sidebar_width
.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH);
}
let max_line = app
.workspaces
.iter()
.enumerate()
.map(|(i, ws)| {
let name_len = ws.display_name().len();
let number_len = (i + 1).to_string().len();
// marker + number + space + name + spaces + aggregate dot
let line1 = 3 + number_len + name_len + 3;
// branch line: " branch"
let line2 = ws.branch().map(|b| 3 + b.len()).unwrap_or(0);
line1.max(line2)
})
.max()
.unwrap_or(12);
((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
}
/// Collapsed sidebar: pure glance mode.
fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(
app.mode,
Mode::Navigate
| Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
);
let p = &app.palette;
let sep_style = if is_navigating {
Style::default().fg(p.accent)
} else {
Style::default().fg(p.surface_dim)
};
let sep_x = area.x + area.width.saturating_sub(1);
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
buf[(sep_x, y)].set_symbol("");
buf[(sep_x, y)].set_style(sep_style);
}
let content_w = area.width.saturating_sub(1);
let bottom_y = area.y + area.height.saturating_sub(1);
for (i, ws) in app.workspaces.iter().enumerate() {
let y = area.y + i as u16;
if y >= bottom_y {
break;
}
let (agg_state, agg_seen) = ws.aggregate_state();
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
let is_selected = i == app.selected && is_navigating;
let row_style = if is_selected {
Style::default().bg(p.surface0)
} else {
Style::default()
};
let num_style = if is_selected {
Style::default().fg(p.overlay1).bg(p.surface0)
} else {
Style::default().fg(p.overlay0)
};
if is_selected {
let buf = frame.buffer_mut();
for x in area.x..area.x + content_w {
buf[(x, y)].set_style(row_style);
}
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{}", i + 1), num_style),
Span::styled(" ", row_style),
Span::styled(icon, icon_style),
])),
Rect::new(area.x, y, content_w, 1),
);
}
render_sidebar_toggle(frame, area, true, p);
}
fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
let p = &app.palette;
let is_navigating = matches!(
app.mode,
Mode::Navigate
| Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
| Mode::Settings
| Mode::GlobalMenu
| Mode::KeybindHelp
);
let sep_style = if is_navigating {
Style::default().fg(p.accent)
} else {
Style::default().fg(p.surface_dim)
};
// Right border
let sep_x = area.x + area.width.saturating_sub(1);
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
buf[(sep_x, y)].set_symbol("");
buf[(sep_x, y)].set_style(sep_style);
}
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
// Determine which workspace to show in the detail panel
let detail_ws_idx = if is_navigating {
Some(app.selected)
} else {
app.active
};
// Split sidebar in half: workspaces on top, agents on bottom
let total_h = content.height as usize;
let ws_h = (total_h + 1) / 2; // top half (ceiling)
let detail_h = total_h.saturating_sub(ws_h);
let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
let detail_area = Rect::new(
content.x,
content.y + ws_h as u16,
content.width,
detail_h as u16,
);
// --- Top section: Workspaces ---
render_workspace_list(app, frame, ws_area, is_navigating);
// --- Bottom section: Agent detail ---
if let Some(ws_idx) = detail_ws_idx {
if let Some(ws) = app.workspaces.get(ws_idx) {
render_agent_detail(app, frame, detail_area, ws);
}
}
render_sidebar_toggle(frame, area, false, p);
}
/// Render the workspace list in the top section of the sidebar.
fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) {
let p = &app.palette;
// Reserve last row for "new" button
let list_bottom = area.y + area.height.saturating_sub(1);
let mut row_y = area.y;
for (i, ws) in app.workspaces.iter().enumerate() {
if row_y + 1 >= list_bottom {
break;
}
let selected = i == app.selected && is_navigating;
let is_active = Some(i) == app.active;
let highlighted = selected || is_active; // active always gets a bg
let (agg_state, agg_seen) = ws.aggregate_state();
// Determine row height for background fill
let has_second_line = ws.branch().is_some();
let row_height: u16 = if has_second_line { 2 } else { 1 };
// Background fill: selected gets brighter surface, active gets subtle surface
if highlighted {
let bg = if selected { p.surface0 } else { p.surface_dim };
let buf = frame.buffer_mut();
for y in row_y..row_y + row_height {
if y >= list_bottom {
break;
}
for x in area.x..area.x + area.width {
buf[(x, y)].set_style(Style::default().bg(bg));
}
}
}
// Accent bar on the left edge for active workspace
if is_active {
let buf = frame.buffer_mut();
for y in row_y..row_y + row_height {
if y >= list_bottom {
break;
}
buf[(area.x, y)].set_symbol("");
buf[(area.x, y)].set_style(Style::default().fg(p.accent));
}
}
// Styles — active workspace is always visible, selected (nav mode) is brightest
let name_style = if selected {
Style::default().fg(p.text).add_modifier(Modifier::BOLD)
} else if is_active {
Style::default().fg(p.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(p.subtext0)
};
let num_style = if selected || is_active {
Style::default().fg(p.overlay1)
} else {
Style::default().fg(p.overlay0)
};
// Line 1: marker-space + number + name + aggregate state dot
let marker = if is_active { " " } else { " " }; // accent bar handles active indicator
let mut line1 = vec![
Span::styled(marker, Style::default()),
Span::styled(format!("{} ", i + 1), num_style),
Span::styled(ws.display_name(), name_style),
Span::styled(" ", Style::default()),
];
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
line1.push(Span::styled(icon, icon_style));
frame.render_widget(
Paragraph::new(Line::from(line1)),
Rect::new(area.x, row_y, area.width, 1),
);
row_y += 1;
// Line 2: branch
if row_y < list_bottom {
if let Some(branch) = ws.branch() {
let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
let mut parts = Vec::new();
if ahead > 0 {
parts.push((format!("{}", ahead), p.green));
}
if behind > 0 {
parts.push((format!("{}", behind), p.red));
}
(!parts.is_empty()).then_some(parts)
});
let reserved = upstream_label
.as_ref()
.map(|parts| {
parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
})
.unwrap_or(0);
let max_branch_len = (area.width as usize).saturating_sub(5 + reserved);
let branch_display = if branch.len() > max_branch_len {
format!("{}", &branch[..max_branch_len.saturating_sub(1)])
} else {
branch
};
let branch_color = if selected || is_active {
p.mauve
} else {
p.overlay0
};
let mut spans = vec![
Span::styled(" ", Style::default()),
Span::styled(branch_display, Style::default().fg(branch_color)),
];
if let Some(parts) = upstream_label {
spans.push(Span::styled(" ", Style::default()));
for (idx, (label, color)) in parts.into_iter().enumerate() {
if idx > 0 {
spans.push(Span::styled(" ", Style::default()));
}
spans.push(Span::styled(label, Style::default().fg(color)));
}
}
let line2 = Line::from(spans);
frame.render_widget(
Paragraph::new(line2),
Rect::new(area.x, row_y, area.width, 1),
);
row_y += 1;
}
}
// Spacing between workspace cards
row_y += 1;
}
// Footer actions for workspace section
if list_bottom > area.y {
let new_rect = app.sidebar_new_button_rect();
frame.render_widget(
Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))),
new_rect,
);
let menu_rect = app.global_launcher_rect();
frame.render_widget(
Paragraph::new(Span::styled("menu", Style::default().fg(p.overlay0)))
.alignment(Alignment::Right),
menu_rect,
);
}
}
/// Render the agent detail panel in the bottom section of the sidebar.
fn render_agent_detail(
app: &AppState,
frame: &mut Frame,
area: Rect,
ws: &crate::workspace::Workspace,
) {
let p = &app.palette;
if area.height < 3 {
return;
}
let mut row_y = area.y;
// Horizontal separator
let sep_line = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
Rect::new(area.x, row_y, area.width, 1),
);
row_y += 1;
// Section header
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" agents",
Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
)])),
Rect::new(area.x, row_y, area.width, 1),
);
row_y += 1;
// Blank line for breathing room
row_y += 1;
// Per-pane agent entries, sorted by urgency
let details = ws.pane_details();
for detail in &details {
if row_y >= area.y + area.height {
break;
}
let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
let label_color = state_label_color(detail.state, detail.seen, p);
let label = state_label(detail.state, detail.seen);
// Agent name in soft white, state label right-aligned in its color
let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
// Calculate padding to right-align state label
let used = 3 + detail.label.len() + 1; // " icon name "
let label_len = label.len();
let avail = (area.width as usize).saturating_sub(used + label_len);
let padding = " ".repeat(avail);
let line = Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(icon, icon_style),
Span::styled(" ", Style::default()),
Span::styled(&detail.label, name_style),
Span::styled(padding, Style::default()),
Span::styled(
label,
Style::default().fg(label_color).add_modifier(Modifier::DIM),
),
]);
frame.render_widget(
Paragraph::new(line),
Rect::new(area.x, row_y, area.width, 1),
);
row_y += 1;
}
}
fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Palette) {
// Toggle button not needed when sidebar has content — skip for now
// to avoid conflicting with the agent detail panel at the bottom.
if !collapsed {
return;
}
let bottom_y = area.y + area.height.saturating_sub(1);
let content_w = area.width.saturating_sub(1);
if content_w == 0 || area.height == 0 {
return;
}
let icon = "»";
let x = area.x + content_w / 2;
let toggle_area = Rect::new(x, bottom_y, 1, 1);
frame.render_widget(
Paragraph::new(Span::styled(icon, Style::default().fg(p.overlay0))),
toggle_area,
);
}
fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(ws_idx) = app.active else {
render_empty(app, frame, area);
return;
};
let Some(ws) = app.workspaces.get(ws_idx) else {
render_empty(app, frame, area);
return;
};
let multi_pane = ws.layout.pane_count() > 1;
let terminal_active = app.mode == Mode::Terminal;
for info in &app.view.pane_infos {
if let Some(rt) = ws.runtimes.get(&info.id) {
// Draw borders for multi-pane layouts
if multi_pane {
let (border_style, border_set) = if info.is_focused && terminal_active {
(
Style::default().fg(app.palette.accent),
ratatui::symbols::border::THICK,
)
} else if info.is_focused {
(
Style::default().fg(app.palette.accent),
ratatui::symbols::border::PLAIN,
)
} else {
(
Style::default().fg(app.palette.overlay0),
ratatui::symbols::border::PLAIN,
)
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.border_set(border_set);
frame.render_widget(block, info.rect);
}
// Draw terminal content
if let Ok(parser) = rt.parser.read() {
let show_cursor = parser.screen().scrollback() == 0;
let pt = PseudoTerminal::new(parser.screen())
.cursor(tui_term::widget::Cursor::default().visibility(show_cursor));
frame.render_widget(pt, info.inner_rect);
}
render_pane_scrollbar(app, frame, info, rt);
// Dim unfocused panes only in navigate mode
let should_dim = !info.is_focused && multi_pane && !terminal_active;
if should_dim {
let inner = info.inner_rect;
let buf = frame.buffer_mut();
for y in inner.y..inner.y + inner.height {
for x in inner.x..inner.x + inner.width {
let cell = &mut buf[(x, y)];
let style = cell.style();
let fg = style.fg.unwrap_or(Color::White);
let dimmed_fg = dim_color(fg);
cell.set_style(style.fg(dimmed_fg));
}
}
}
// Selection highlight
render_selection_highlight(
&app.selection,
frame,
info.id,
info.inner_rect,
&app.palette,
);
}
}
}
/// Render selection highlight for a pane by inverting fg/bg colors.
/// Reduce a color's brightness by blending it toward black.
fn dim_color(color: Color) -> Color {
match color {
Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
Color::White => Color::DarkGray,
Color::Gray => Color::DarkGray,
Color::DarkGray => Color::Rgb(30, 30, 30),
Color::Red => Color::Rgb(60, 0, 0),
Color::Green => Color::Rgb(0, 60, 0),
Color::Yellow => Color::Rgb(60, 60, 0),
Color::Blue => Color::Rgb(0, 0, 60),
Color::Magenta => Color::Rgb(60, 0, 60),
Color::Cyan => Color::Rgb(0, 60, 60),
Color::LightRed => Color::Rgb(80, 30, 30),
Color::LightGreen => Color::Rgb(30, 80, 30),
Color::LightYellow => Color::Rgb(80, 80, 30),
Color::LightBlue => Color::Rgb(30, 30, 80),
Color::LightMagenta => Color::Rgb(80, 30, 80),
Color::LightCyan => Color::Rgb(30, 80, 80),
// Indexed colors and others: just use DIM modifier as fallback
_ => Color::DarkGray,
}
}
pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
pub(crate) fn release_notes_scrollbar_rect(
body: Rect,
metrics: crate::pane::ScrollMetrics,
) -> Option<Rect> {
(should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
body.x + body.width - 1,
body.y,
1,
body.height,
))
}
pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
metrics.max_offset_from_bottom > 0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ScrollbarThumb {
pub top: u16,
pub len: u16,
}
pub(crate) fn scrollbar_thumb(
metrics: crate::pane::ScrollMetrics,
track: Rect,
) -> Option<ScrollbarThumb> {
if metrics.max_offset_from_bottom == 0 || track.height == 0 {
return None;
}
let track_height = track.height as usize;
let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows;
if total_rows == 0 {
return None;
}
let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32)
.round()
.max(1.0)
.min(track_height as f32) as usize;
let max_thumb_top = track_height.saturating_sub(thumb_len);
let scrolled_from_top = metrics
.max_offset_from_bottom
.saturating_sub(metrics.offset_from_bottom);
let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 {
0
} else {
((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32)
.round()
.clamp(0.0, max_thumb_top as f32) as usize
};
Some(ScrollbarThumb {
top: track.y + thumb_top as u16,
len: thumb_len as u16,
})
}
pub(crate) fn scrollbar_thumb_grab_offset(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
) -> Option<u16> {
let thumb = scrollbar_thumb(metrics, track)?;
(row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
}
fn scrollbar_offset_from_thumb_top(
metrics: crate::pane::ScrollMetrics,
track: Rect,
thumb_top: usize,
) -> usize {
if metrics.max_offset_from_bottom == 0 {
return 0;
}
let thumb_len = scrollbar_thumb(metrics, track)
.map(|thumb| thumb.len as usize)
.unwrap_or(1);
let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize);
if max_thumb_top == 0 {
return 0;
}
let desired_top = thumb_top.min(max_thumb_top);
let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32
/ max_thumb_top as f32)
.round() as usize;
metrics
.max_offset_from_bottom
.saturating_sub(scrolled_from_top)
}
pub(crate) fn scrollbar_offset_from_row(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
) -> usize {
let thumb = match scrollbar_thumb(metrics, track) {
Some(thumb) => thumb,
None => return 0,
};
let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
let row_offset = clamped_row.saturating_sub(track.y) as usize;
let thumb_center = (thumb.len as usize) / 2;
let desired_top = row_offset.saturating_sub(thumb_center);
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
pub(crate) fn scrollbar_offset_from_drag_row(
metrics: crate::pane::ScrollMetrics,
track: Rect,
row: u16,
grab_row_offset: u16,
) -> usize {
let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
let row_offset = clamped_row.saturating_sub(track.y) as usize;
let desired_top = row_offset.saturating_sub(grab_row_offset as usize);
scrollbar_offset_from_thumb_top(metrics, track, desired_top)
}
fn render_scrollbar(
frame: &mut Frame,
metrics: crate::pane::ScrollMetrics,
track: Rect,
track_color: Color,
thumb_color: Color,
thumb_symbol: &str,
) {
if metrics.max_offset_from_bottom == 0 {
return;
}
let Some(thumb) = scrollbar_thumb(metrics, track) else {
return;
};
let buf = frame.buffer_mut();
for y in track.y..track.y + track.height {
let cell = &mut buf[(track.x, y)];
cell.set_symbol("");
cell.set_style(Style::default().fg(track_color));
}
for y in thumb.top..thumb.top + thumb.len {
let cell = &mut buf[(track.x, y)];
cell.set_symbol(thumb_symbol);
cell.set_style(Style::default().fg(thumb_color));
}
}
fn render_pane_scrollbar(
app: &AppState,
frame: &mut Frame,
info: &PaneInfo,
rt: &crate::pane::PaneRuntime,
) {
let Some(metrics) = rt.scroll_metrics() else {
return;
};
let Some(track) = pane_scrollbar_rect(info) else {
return;
};
let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
(app.palette.overlay0, app.palette.overlay1, "")
} else {
(app.palette.surface_dim, app.palette.overlay0, "")
};
render_scrollbar(
frame,
metrics,
track,
track_color,
thumb_color,
thumb_symbol,
);
}
fn render_selection_highlight(
selection: &Option<crate::selection::Selection>,
frame: &mut Frame,
pane_id: crate::layout::PaneId,
inner: Rect,
p: &Palette,
) {
if let Some(sel) = selection {
if sel.is_visible() && sel.pane_id == pane_id {
let buf = frame.buffer_mut();
for y in 0..inner.height {
for x in 0..inner.width {
if sel.contains(y, x) {
let cell = &mut buf[(inner.x + x, inner.y + y)];
cell.set_style(Style::default().fg(p.panel_bg).bg(p.blue));
}
}
}
}
}
}
fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
let p = &app.palette;
let lines = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled(
" No workspaces yet",
Style::default().fg(p.overlay0),
)),
Line::from(""),
Line::from(Span::styled(
" A workspace is one project context.",
Style::default().fg(p.overlay1),
)),
Line::from(Span::styled(
" Its root pane (top-left) sets the default repo or folder name.",
Style::default().fg(p.overlay1),
)),
Line::from(""),
Line::from(vec![
Span::styled(" Press ", Style::default().fg(p.overlay0)),
Span::styled(
format!("{}", app.keybinds.new_workspace_label),
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" to create one", Style::default().fg(p.overlay0)),
]),
];
frame.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p.surface_dim)),
),
area,
);
}
const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
fn dim_background(frame: &mut Frame, area: Rect) {
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = &mut buf[(x, y)];
cell.set_style(cell.style().add_modifier(Modifier::DIM));
}
}
}
fn render_panel_shell(
frame: &mut Frame,
area: Rect,
border_color: Color,
bg: Color,
) -> Option<Rect> {
if area.width < 2 || area.height < 2 {
return None;
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.border_set(ratatui::symbols::border::PLAIN)
.style(Style::default().bg(bg));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
Some(inner)
}
pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> {
let popup_w = popup_w.min(area.width.saturating_sub(4));
let popup_h = popup_h.min(area.height.saturating_sub(2));
if popup_w < 4 || popup_h < 4 {
return None;
}
let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
Some(Rect::new(popup_x, popup_y, popup_w, popup_h))
}
fn render_modal_shell(
frame: &mut Frame,
area: Rect,
popup_w: u16,
popup_h: u16,
p: &Palette,
) -> Option<Rect> {
let popup = centered_popup_rect(area, popup_w, popup_h)?;
render_panel_shell(frame, popup, p.accent, p.panel_bg)
}
fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
frame.render_widget(
Paragraph::new(Span::styled(
format!(" {title} "),
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
)),
area,
);
}
fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
dim_background(frame, area);
match app.onboarding_step {
0 => render_onboarding_welcome(app, frame, area),
_ => render_onboarding_notifications(app, frame, area),
}
}
fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(notes) = &app.release_notes else {
return;
};
dim_background(frame, area);
let Some(inner) = render_modal_shell(frame, area, 76, 20, &app.palette) else {
return;
};
if inner.height < 8 || inner.width < 20 {
return;
}
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas::<5>(inner);
render_modal_header(frame, rows[0], &format!("v{}", notes.version), &app.palette);
frame.render_widget(
Paragraph::new(" what's new in this release")
.style(Style::default().fg(app.palette.overlay1)),
rows[1],
);
render_action_button(
frame,
release_notes_close_button_rect(rows[0]),
Some("esc"),
"close",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
let body_area = rows[3];
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
max_offset_from_bottom: app.release_notes_max_scroll() as usize,
viewport_rows: body_area.height.max(1) as usize,
};
let track = release_notes_scrollbar_rect(body_area, metrics);
let text_area = track
.map(|_| {
Rect::new(
body_area.x,
body_area.y,
body_area.width.saturating_sub(1),
body_area.height,
)
})
.unwrap_or(body_area);
let body = Paragraph::new(render_release_notes_lines(
notes.body.as_str(),
&app.palette,
))
.wrap(Wrap { trim: false })
.scroll((notes.scroll, 0));
frame.render_widget(body, text_area);
if let Some(track) = track {
render_scrollbar(
frame,
metrics,
track,
app.palette.overlay0,
app.palette.overlay1,
"",
);
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
Span::styled("close", Style::default().fg(app.palette.overlay0)),
Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
rows[4],
);
}
fn render_release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<Line<'a>> {
let mut lines = Vec::new();
for raw in body.lines() {
let trimmed = raw.trim_end();
if trimmed.is_empty() {
lines.push(Line::raw(""));
continue;
}
if let Some(rest) = trimmed.strip_prefix("### ") {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
rest.to_lowercase(),
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
),
]));
continue;
}
if let Some(rest) = trimmed.strip_prefix("- ") {
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(p.accent)),
Span::styled(rest.to_string(), Style::default().fg(p.text)),
]));
continue;
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(trimmed.to_string(), Style::default().fg(p.text)),
]));
}
lines
}
pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect {
let width = action_button_width(Some("esc"), "close");
Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
}
pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
Rect::new(
area.x,
area.y,
action_button_width(Some(""), "continue"),
1,
)
}
pub(crate) struct ActionButtonSpec<'a> {
pub hint: Option<&'a str>,
pub label: &'a str,
}
pub(crate) fn action_button_row_rects(
area: Rect,
buttons: &[ActionButtonSpec<'_>],
gap: u16,
row_offset: u16,
) -> Vec<Rect> {
let widths: Vec<u16> = buttons
.iter()
.map(|button| action_button_width(button.hint, button.label))
.collect();
centered_button_row(area, &widths, gap, row_offset)
}
pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) {
let rects = action_button_row_rects(
area,
&[
ActionButtonSpec {
hint: Some("esc"),
label: "back",
},
ActionButtonSpec {
hint: Some(""),
label: "save",
},
],
2,
0,
);
(rects[0], rects[1])
}
fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else {
return;
};
if inner.height < 10 {
return;
}
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas::<11>(inner);
frame.render_widget(
Paragraph::new(" herdr").style(
Style::default()
.fg(app.palette.text)
.add_modifier(Modifier::BOLD),
),
rows[1],
);
frame.render_widget(
Paragraph::new(" workspace manager for coding agents")
.style(Style::default().fg(app.palette.overlay0)),
rows[2],
);
let line1 = Line::from(vec![
Span::styled(
format!(" {}", ONBOARDING_PREFIX_LABEL),
Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" navigate ", Style::default().fg(app.palette.overlay1)),
Span::styled("·", Style::default().fg(app.palette.overlay0)),
Span::styled(" click sidebar ", Style::default().fg(app.palette.overlay1)),
Span::styled("·", Style::default().fg(app.palette.overlay0)),
Span::styled(" scroll panes", Style::default().fg(app.palette.overlay1)),
]);
frame.render_widget(Paragraph::new(line1), rows[4]);
let line2 = Line::from(vec![
Span::styled(
" ↑↓",
Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" switch workspace ",
Style::default().fg(app.palette.overlay1),
),
Span::styled("·", Style::default().fg(app.palette.overlay0)),
Span::styled(" drag borders ", Style::default().fg(app.palette.overlay1)),
Span::styled("·", Style::default().fg(app.palette.overlay0)),
Span::styled(
"",
Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(" pane", Style::default().fg(app.palette.overlay1)),
]);
frame.render_widget(Paragraph::new(line2), rows[5]);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("", Style::default().fg(app.palette.red)),
Span::styled("needs you ", Style::default().fg(app.palette.overlay1)),
Span::styled("", Style::default().fg(app.palette.yellow)),
Span::styled("working ", Style::default().fg(app.palette.overlay1)),
Span::styled("", Style::default().fg(app.palette.overlay0)),
Span::styled("no agent", Style::default().fg(app.palette.overlay1)),
])),
rows[7],
);
let continue_rect = onboarding_welcome_continue_rect(rows[9]);
render_action_button(
frame,
continue_rect,
Some(""),
"continue",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(
Paragraph::new(" readme has config and more")
.style(Style::default().fg(app.palette.overlay0)),
Rect::new(
continue_rect.x + continue_rect.width + 2,
rows[9].y,
rows[9].width.saturating_sub(continue_rect.width + 2),
1,
),
);
}
fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) {
let Some(inner) = render_modal_shell(frame, area, 52, 10, &app.palette) else {
return;
};
if inner.height < 7 {
return;
}
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas::<7>(inner);
render_modal_header(frame, rows[0], "choose notification style", &app.palette);
frame.render_widget(
Paragraph::new(" herdr can alert you when background work needs attention or finishes.")
.style(Style::default().fg(app.palette.overlay1)),
rows[1],
);
let options = [
"quiet no sound, no visual toasts",
"visual only top-right toasts, no sound",
"sound only sound alerts, no toasts",
"both sound and visual toasts",
];
for (idx, option) in options.iter().enumerate() {
let selected = idx == app.onboarding_list.selected;
let prefix = if selected { "" } else { " " };
let style = if selected {
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
} else {
Style::default().fg(app.palette.text)
};
frame.render_widget(
Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
rows[idx + 2],
);
}
let (back_rect, save_rect) = onboarding_notification_button_rects(rows[6]);
render_action_button(
frame,
back_rect,
Some("esc"),
"back",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
save_rect,
Some(""),
"save",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
}
/// Floating overlay for navigate mode — appears at bottom of terminal area.
fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let key = Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(app.palette.overlay0);
let mode_style = Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD);
let kb = &app.keybinds;
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
Span::raw(" "),
Span::styled("esc", key),
Span::styled(" back ", dim),
Span::styled("↑↓", key),
Span::styled(" ws ", dim),
Span::styled("", key),
Span::styled(" pane ", dim),
Span::styled(kb.new_tab_label.as_str(), key),
Span::styled(" new tab ", dim),
Span::styled(kb.split_vertical_label.as_str(), key),
Span::styled(" split│ ", dim),
Span::styled(kb.split_horizontal_label.as_str(), key),
Span::styled(" split─ ", dim),
Span::styled(kb.close_pane_label.as_str(), key),
Span::styled(" close ", dim),
Span::styled(kb.fullscreen_label.as_str(), key),
Span::styled(" full ", dim),
Span::styled(kb.resize_mode_label.as_str(), key),
Span::styled(" resize ", dim),
Span::styled("?", key),
Span::styled(" keybinds ", dim),
Span::styled("s", key),
Span::styled(" settings", dim),
]);
let overlay_y = area.y + area.height.saturating_sub(1);
let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
frame.render_widget(Clear, overlay_area);
let bg = Style::default().bg(app.palette.panel_bg);
let buf = frame.buffer_mut();
for x in overlay_area.x..overlay_area.x + overlay_area.width {
buf[(x, overlay_y)].set_style(bg);
}
frame.render_widget(Paragraph::new(line), overlay_area);
}
fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
let rect = app.global_menu_rect();
let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
else {
return;
};
let items = ["keybinds", "settings"];
for (idx, item) in items.iter().enumerate() {
let y = inner.y + idx as u16;
if y >= inner.y + inner.height {
break;
}
let selected = idx == app.global_menu.highlighted;
let style = if selected {
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.palette.text)
};
frame.render_widget(
Paragraph::new(format!(" {item}"))
.style(style)
.alignment(Alignment::Left),
Rect::new(inner.x, y, inner.width, 1),
);
}
}
fn optional_keybind_label(label: &Option<String>) -> String {
label.clone().unwrap_or_else(|| "unset".to_string())
}
fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> {
let kb = &app.keybinds;
let mut groups = Vec::new();
groups.push((
"global",
vec![
(
crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
"navigate mode",
),
("prefix + ?".to_string(), "keybinds"),
],
));
groups.push((
"navigation",
vec![
("esc".to_string(), "back"),
("↑ / ↓".to_string(), "workspace list"),
("h j k l / arrows".to_string(), "move focus"),
("tab / shift+tab".to_string(), "cycle pane"),
("enter".to_string(), "open workspace"),
("s".to_string(), "settings"),
("q".to_string(), "quit"),
],
));
let workspace_tab = vec![
(kb.new_workspace_label.clone(), "new workspace"),
(kb.rename_workspace_label.clone(), "rename workspace"),
(kb.close_workspace_label.clone(), "close workspace"),
(
optional_keybind_label(&kb.previous_workspace_label),
"previous workspace",
),
(
optional_keybind_label(&kb.next_workspace_label),
"next workspace",
),
(kb.new_tab_label.clone(), "new tab"),
(optional_keybind_label(&kb.rename_tab_label), "rename tab"),
(
optional_keybind_label(&kb.previous_tab_label),
"previous tab",
),
(optional_keybind_label(&kb.next_tab_label), "next tab"),
(optional_keybind_label(&kb.close_tab_label), "close tab"),
];
groups.push(("workspaces / tabs", workspace_tab));
let panes = vec![
(kb.split_vertical_label.clone(), "split vertical"),
(kb.split_horizontal_label.clone(), "split horizontal"),
(kb.close_pane_label.clone(), "close pane"),
(kb.fullscreen_label.clone(), "fullscreen"),
(kb.resize_mode_label.clone(), "resize mode"),
(kb.toggle_sidebar_label.clone(), "toggle sidebar"),
(
optional_keybind_label(&kb.focus_pane_left_label),
"focus pane left",
),
(
optional_keybind_label(&kb.focus_pane_down_label),
"focus pane down",
),
(
optional_keybind_label(&kb.focus_pane_up_label),
"focus pane up",
),
(
optional_keybind_label(&kb.focus_pane_right_label),
"focus pane right",
),
];
groups.push(("panes", panes));
groups
}
pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> {
let heading_style = Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD);
let key_style = Style::default()
.fg(app.palette.mauve)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(app.palette.text);
let groups = keybind_help_groups(app);
let key_width = groups
.iter()
.flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
.max()
.unwrap_or(8);
let mut lines = Vec::new();
for (group, entries) in groups {
lines.push((
group.len() + 1,
Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
));
for (key, label) in entries {
let padded_key = format!(" {:<width$} ", key, width = key_width);
let width = padded_key.chars().count() + label.chars().count();
lines.push((
width,
Line::from(vec![
Span::styled(padded_key, key_style),
Span::styled(label.to_string(), label_style),
]),
));
}
lines.push((0, Line::raw("")));
}
lines
}
fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
dim_background(frame, frame.area());
let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
return;
};
if inner.height < 6 || inner.width < 20 {
return;
}
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas::<4>(inner);
render_modal_header(frame, rows[0], "keybinds", &app.palette);
render_action_button(
frame,
release_notes_close_button_rect(rows[0]),
Some("esc"),
"close",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(
Paragraph::new(" available commands and configured shortcuts")
.style(Style::default().fg(app.palette.overlay1)),
rows[1],
);
let body_area = rows[2];
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: app
.keybind_help_max_scroll()
.saturating_sub(app.keybind_help.scroll) as usize,
max_offset_from_bottom: app.keybind_help_max_scroll() as usize,
viewport_rows: body_area.height.max(1) as usize,
};
let track = release_notes_scrollbar_rect(body_area, metrics);
let text_area = track
.map(|_| {
Rect::new(
body_area.x,
body_area.y,
body_area.width.saturating_sub(1),
body_area.height,
)
})
.unwrap_or(body_area);
let body = Paragraph::new(
keybind_help_lines(app)
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>(),
)
.wrap(Wrap { trim: false })
.scroll((app.keybind_help.scroll, 0));
frame.render_widget(body, text_area);
if let Some(track) = track {
render_scrollbar(
frame,
metrics,
track,
app.palette.overlay0,
app.palette.overlay1,
"",
);
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
Span::styled("jump", Style::default().fg(app.palette.overlay0)),
Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)),
Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
Span::styled("close", Style::default().fg(app.palette.overlay0)),
Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
rows[3],
);
}
/// Floating overlay for resize mode.
fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let key = Style::default()
.fg(app.palette.accent)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(app.palette.overlay0);
let mode_style = Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.mauve)
.add_modifier(Modifier::BOLD);
let line = Line::from(vec![
Span::styled(" RESIZE ", mode_style),
Span::raw(" "),
Span::styled("h/l", key),
Span::styled(" width ", dim),
Span::styled("j/k", key),
Span::styled(" height ", dim),
Span::styled("esc", key),
Span::styled(" done", dim),
]);
let overlay_y = area.y + area.height.saturating_sub(1);
let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
frame.render_widget(Clear, overlay_area);
let bg = Style::default().bg(app.palette.panel_bg);
let buf = frame.buffer_mut();
for x in overlay_area.x..overlay_area.x + overlay_area.width {
buf[(x, overlay_y)].set_style(bg);
}
frame.render_widget(Paragraph::new(line), overlay_area);
}
/// Centered popup confirmation dialog with dimmed background.
pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
let rects = action_button_row_rects(
inner,
&[
ActionButtonSpec {
hint: Some(""),
label: "save",
},
ActionButtonSpec {
hint: Some("^c"),
label: "clear",
},
ActionButtonSpec {
hint: Some("esc"),
label: "cancel",
},
],
2,
3,
);
(rects[0], rects[1], rects[2])
}
fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
dim_background(frame, area);
let title = match app.mode {
Mode::RenameWorkspace => "rename workspace",
Mode::RenameTab => "rename tab",
_ => return,
};
let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
return;
};
if inner.height < 4 {
return;
}
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas::<5>(inner);
render_modal_header(frame, rows[0], title, &app.palette);
let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
frame.render_widget(Clear, input_rect);
frame.render_widget(
Paragraph::new(format!(" {}", app.name_input)).style(
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0),
),
input_rect,
);
let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
render_action_button(
frame,
save_rect,
Some(""),
"save",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.accent)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
clear_rect,
Some("^c"),
"clear",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
cancel_rect,
Some("esc"),
"cancel",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
}
fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let ws_name = app
.workspaces
.get(app.selected)
.map(|ws| ws.display_name())
.unwrap_or_else(|| "?".to_string());
let pane_count = app
.workspaces
.get(app.selected)
.map(|ws| ws.layout.pane_count())
.unwrap_or(0);
let pane_text = if pane_count == 1 {
"1 pane".to_string()
} else {
format!("{pane_count} panes")
};
// Dim the entire background
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
let cell = &mut buf[(x, y)];
cell.set_style(cell.style().add_modifier(Modifier::DIM));
}
}
// Centered popup
let Some(popup) = confirm_close_popup_rect(area) else {
return;
};
let warn = Style::default()
.fg(app.palette.red)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(app.palette.overlay0);
let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]);
let detail_line = Line::from(vec![
Span::styled(
format!(" {ws_name}"),
Style::default()
.fg(app.palette.text)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{pane_text}"), dim),
]);
let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
else {
return;
};
if inner.height >= 3 {
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas::<3>(inner);
frame.render_widget(Paragraph::new(title_line), rows[0]);
frame.render_widget(Paragraph::new(detail_line), rows[1]);
let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner);
render_action_button(
frame,
confirm_rect,
Some(""),
"confirm",
Style::default()
.fg(app.palette.panel_bg)
.bg(app.palette.red)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
cancel_rect,
Some("esc"),
"cancel",
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
.add_modifier(Modifier::BOLD),
);
}
}
pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String {
match hint {
Some(hint) => format!(" {hint} {label} "),
None => format!(" {label} "),
}
}
pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
action_button_text(hint, label).chars().count() as u16
}
fn render_action_button(
frame: &mut Frame,
rect: Rect,
hint: Option<&str>,
label: &str,
style: Style,
) {
frame.render_widget(
Paragraph::new(action_button_text(hint, label))
.style(style)
.alignment(Alignment::Center),
rect,
);
}
fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) -> Vec<Rect> {
let total_w = widths
.iter()
.copied()
.sum::<u16>()
.saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16));
let mut x = inner.x + inner.width.saturating_sub(total_w) / 2;
let y = inner.y + row_offset.min(inner.height.saturating_sub(1));
widths
.iter()
.map(|w| {
let rect = Rect::new(
x,
y,
(*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))),
1,
);
x = x.saturating_add(*w).saturating_add(gap);
rect
})
.collect()
}
pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option<Rect> {
centered_popup_rect(area, 44, 5)
}
pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
let rects = action_button_row_rects(
inner,
&[
ActionButtonSpec {
hint: Some(""),
label: "confirm",
},
ActionButtonSpec {
hint: Some("esc"),
label: "cancel",
},
],
2,
2,
);
(rects[0], rects[1])
}
pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) {
let rects = action_button_row_rects(
inner,
&[
ActionButtonSpec {
hint: Some(""),
label: "apply",
},
ActionButtonSpec {
hint: Some("esc"),
label: "close",
},
],
2,
inner.height.saturating_sub(1),
);
(rects[0], rects[1])
}
/// Right-click context menu popup anchored near the click position.
// ---------------------------------------------------------------------------
// Settings overlay
// ---------------------------------------------------------------------------
fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
use crate::app::state::SettingsSection;
let p = &app.palette;
let Some(popup) = centered_popup_rect(area, 56, 20) else {
return;
};
// Dim everything behind the modal
dim_background(frame, area);
let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else {
return;
};
if inner.height < 4 || inner.width < 10 {
return;
}
let mut y = inner.y;
// Title
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" settings",
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
)])),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
// Tab bar
let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
.select(
SettingsSection::ALL
.iter()
.position(|section| *section == app.settings.section)
.unwrap_or(0),
)
.style(Style::default().fg(p.overlay1))
.highlight_style(
Style::default()
.fg(p.panel_bg)
.bg(p.accent)
.add_modifier(Modifier::BOLD),
)
.divider(" ")
.padding(" ", " ");
frame.render_widget(tabs, Rect::new(inner.x, y, inner.width, 1));
y += 1;
// Separator
let sep = "".repeat(inner.width as usize);
frame.render_widget(
Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
Rect::new(inner.x, y, inner.width, 1),
);
y += 1;
// Section content
let content_area = Rect::new(inner.x, y, inner.width, inner.y + inner.height - y);
match app.settings.section {
SettingsSection::Theme => {
render_settings_theme(app, frame, content_area);
}
SettingsSection::Sound => {
render_settings_toggle(
frame,
content_area,
p,
"sound alerts",
"play sounds when agents change state in background",
app.sound_enabled(),
app.settings.list.selected,
);
}
SettingsSection::Toast => {
render_settings_toggle(
frame,
content_area,
p,
"visual toasts",
"show top-right notifications for background events",
app.toast_config.enabled,
app.settings.list.selected,
);
}
}
// Footer buttons + hints
let footer_y = inner.y + inner.height - 1;
if footer_y > y {
let (apply_rect, close_rect) = settings_button_rects(inner);
render_action_button(
frame,
apply_rect,
Some(""),
"apply",
Style::default()
.fg(p.panel_bg)
.bg(p.accent)
.add_modifier(Modifier::BOLD),
);
render_action_button(
frame,
close_rect,
Some("esc"),
"close",
Style::default()
.fg(p.text)
.bg(p.surface0)
.add_modifier(Modifier::BOLD),
);
let hint_area = Rect::new(inner.x, footer_y.saturating_sub(1), inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↑↓", Style::default().fg(p.overlay0)),
Span::styled(" select ", Style::default().fg(p.overlay1)),
Span::styled("tab", Style::default().fg(p.overlay0)),
Span::styled(" section", Style::default().fg(p.overlay1)),
])),
hint_area,
);
}
}
/// Render the theme picker list inside the settings panel.
fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
use crate::app::state::THEME_NAMES;
let p = &app.palette;
let items: Vec<ListItem> = THEME_NAMES
.iter()
.map(|name| {
let is_current = name.to_lowercase().replace([' ', '_'], "-")
== app.theme_name.to_lowercase().replace([' ', '_'], "-");
let marker = if is_current { "" } else { "" };
ListItem::new(Line::from(vec![
Span::styled(*name, Style::default().fg(p.subtext0)),
Span::styled(marker, Style::default().fg(p.green)),
]))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(p.surface0)
.fg(p.text)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("")
.style(Style::default().fg(p.subtext0));
let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
frame.render_stateful_widget(list, area, &mut state);
}
/// Reusable toggle widget for boolean settings (sound, toast).
fn render_settings_toggle(
frame: &mut Frame,
area: Rect,
p: &crate::app::state::Palette,
title: &str,
description: &str,
current_value: bool,
selected_idx: usize,
) {
let [desc_area, _, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(2),
])
.areas::<3>(area);
let max_desc_len = (desc_area.width as usize).saturating_sub(2);
let desc_text = if description.len() > max_desc_len {
format!(" {}", &description[..max_desc_len.saturating_sub(2)])
} else {
format!(" {description}")
};
frame.render_widget(
Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))),
desc_area,
);
let items: Vec<ListItem> = ["on", "off"]
.into_iter()
.map(|label| {
let is_active = (label == "on") == current_value;
let marker = if is_active { "" } else { "" };
ListItem::new(Line::from(vec![
Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)),
Span::styled(marker, Style::default().fg(p.green)),
]))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(p.surface0)
.fg(p.text)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
let mut state = ListState::default().with_selected(Some(selected_idx.min(1)));
frame.render_stateful_widget(list, list_area, &mut state);
}
fn render_context_menu(app: &AppState, frame: &mut Frame) {
let Some(menu) = &app.context_menu else {
return;
};
let p = &app.palette;
let Some(menu_rect) = app.context_menu_rect() else {
return;
};
let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else {
return;
};
let items: Vec<ListItem> = menu
.items()
.iter()
.map(|item| ListItem::new(Line::from(*item)))
.collect();
let list = List::new(items)
.style(Style::default().fg(p.text))
.highlight_style(
Style::default()
.bg(p.accent)
.fg(p.panel_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ");
let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
frame.render_stateful_widget(list, inner, &mut state);
}
fn render_toast_notification(
frame: &mut Frame,
area: Rect,
toast: &ToastNotification,
offset_for_warning: bool,
p: &Palette,
) {
let dot_color = match toast.kind {
ToastKind::NeedsAttention => p.red,
ToastKind::Finished => p.blue,
ToastKind::UpdateInstalled => p.accent,
};
let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4;
let width = content_width.saturating_add(2).min(area.width);
let height = 4u16.min(area.height);
let x = area.x + area.width.saturating_sub(width);
let y = area.y
+ area
.height
.saturating_sub(height + if offset_for_warning { 1 } else { 0 });
let toast_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, toast_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p.overlay0))
.style(Style::default().bg(p.panel_bg));
let inner = block.inner(toast_area);
frame.render_widget(block, toast_area);
if inner.height < 2 {
return;
}
let [title_row, context_row] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
let title = Line::from(vec![
Span::styled("", Style::default().fg(dot_color)),
Span::raw(" "),
Span::styled(
&toast.title,
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
),
]);
let context = Line::from(vec![
Span::styled(" ", Style::default().fg(p.overlay0)),
Span::styled(&toast.context, Style::default().fg(p.overlay0)),
]);
frame.render_widget(Paragraph::new(title), title_row);
frame.render_widget(Paragraph::new(context), context_row);
}
/// Visual badge for a pane's state + seen flag.
///
/// | State | Icon | Color |
/// |--------------------|------|--------|
/// | Blocked | ● | Red |
/// | Working | ● | Yellow |
/// | Done (idle+unseen) | ● | Blue |
/// | Idle (seen) | ○ | Green |
/// | Unknown | · | Gray |
///
/// Filled dot = needs attention (blocked/working, or finished unseen).
/// Hollow dot = nothing to do here.
fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
let text = format!(" config warning: {message} ");
let width = text.len() as u16 + 2;
let notif_area = Rect::new(
area.x + area.width.saturating_sub(width.min(area.width)),
area.y,
width.min(area.width),
1,
);
frame.render_widget(Clear, notif_area);
frame.render_widget(
Paragraph::new(Span::styled(
text,
Style::default()
.fg(p.panel_bg)
.bg(p.yellow)
.add_modifier(Modifier::BOLD),
)),
notif_area,
);
}
/// Compact dot icon for workspace-level aggregate state (top section).
fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) {
match (state, seen) {
(AgentState::Blocked, _) => ("", Style::default().fg(p.red)),
(AgentState::Working, _) => ("", Style::default().fg(p.yellow)),
(AgentState::Idle, false) => ("", Style::default().fg(p.teal)),
(AgentState::Idle, true) => ("", Style::default().fg(p.green)),
(AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)),
}
}
/// Rich icon for per-pane agent detail (bottom section).
/// Uses animated spinner for the working state.
fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) {
match (state, seen) {
(AgentState::Blocked, _) => ("", Style::default().fg(p.red)),
(AgentState::Working, _) => (spinner_frame(tick), Style::default().fg(p.yellow)),
(AgentState::Idle, false) => ("", Style::default().fg(p.teal)),
(AgentState::Idle, true) => ("", Style::default().fg(p.green)),
(AgentState::Unknown, _) => ("", Style::default().fg(p.overlay0)),
}
}
/// State label for the agent detail panel.
fn state_label(state: AgentState, seen: bool) -> &'static str {
match (state, seen) {
(AgentState::Blocked, _) => "blocked",
(AgentState::Working, _) => "working",
(AgentState::Idle, false) => "done",
(AgentState::Idle, true) => "idle",
(AgentState::Unknown, _) => "idle",
}
}
/// Color for the state label text.
fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
match (state, seen) {
(AgentState::Blocked, _) => p.red,
(AgentState::Working, _) => p.yellow,
(AgentState::Idle, false) => p.teal,
(AgentState::Idle, true) => p.green,
(AgentState::Unknown, _) => p.overlay0,
}
}
fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
spans.push(Span::raw(" "));
for (i, (k, desc)) in items.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", dim_style));
}
spans.push(Span::styled(k.to_string(), key_style));
spans.push(Span::styled(format!(" {desc}"), dim_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pane_scrollbar_rect_uses_rightmost_inner_column() {
let info = PaneInfo {
id: crate::layout::PaneId::from_raw(1),
rect: Rect::new(0, 0, 12, 8),
inner_rect: Rect::new(1, 1, 9, 6),
scrollbar_rect: Some(Rect::new(10, 1, 1, 6)),
is_focused: true,
};
assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6)));
}
#[test]
fn scrollbar_stays_hidden_without_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 0,
viewport_rows: 5,
};
assert!(!should_show_scrollbar(metrics));
}
#[test]
fn scrollbar_shows_with_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
assert!(should_show_scrollbar(metrics));
}
#[test]
fn scrollbar_thumb_reaches_bottom_when_scrolled_to_bottom() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 5);
let thumb = scrollbar_thumb(metrics, track).expect("thumb");
assert_eq!(thumb.top + thumb.len, track.y + track.height);
}
#[test]
fn scrollbar_offset_mapping_hits_top_middle_and_bottom() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 5);
assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20);
assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10);
assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0);
}
#[test]
fn dragging_from_current_thumb_row_preserves_offset() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 7,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
let track = Rect::new(9, 4, 1, 8);
let thumb = scrollbar_thumb(metrics, track).expect("thumb");
let row = thumb.top + thumb.len / 2;
let grab = scrollbar_thumb_grab_offset(metrics, track, row).expect("grab");
assert_eq!(scrollbar_offset_from_drag_row(metrics, track, row, grab), 7);
}
#[test]
fn keybind_help_shows_unset_for_optional_actions() {
let app = crate::app::state::AppState::test_new();
let groups = keybind_help_groups(&app);
let workspace_tab = groups
.iter()
.find(|(name, _)| *name == "workspaces / tabs")
.expect("workspace tab group")
.1
.clone();
let panes = groups
.iter()
.find(|(name, _)| *name == "panes")
.expect("panes group")
.1
.clone();
assert!(workspace_tab.contains(&("unset".to_string(), "previous workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "next workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "rename tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "previous tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "next tab")));
assert!(workspace_tab.contains(&("unset".to_string(), "close tab")));
assert!(panes.contains(&("unset".to_string(), "focus pane left")));
assert!(panes.contains(&("unset".to_string(), "focus pane down")));
assert!(panes.contains(&("unset".to_string(), "focus pane up")));
assert!(panes.contains(&("unset".to_string(), "focus pane right")));
}
}