aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs3616
1 files changed, 167 insertions, 3449 deletions
diff --git a/src/ui.rs b/src/ui.rs
index 29af2e17..06ed1f34 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,15 +1,64 @@
use ratatui::{
- layout::{Alignment, Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::Span,
Frame,
};
-use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification};
+mod dialogs;
+mod keybind_help;
+mod menus;
+mod onboarding;
+mod panes;
+mod release_notes;
+mod scrollbar;
+mod settings;
+mod sidebar;
+mod status;
+mod tabs;
+mod widgets;
+
+use self::dialogs::{render_confirm_close_overlay, render_rename_overlay};
+use self::keybind_help::render_keybind_help_overlay;
+use self::menus::{
+ render_context_menu, render_global_launcher_menu, render_navigate_overlay,
+ render_resize_overlay,
+};
+use self::onboarding::render_onboarding_overlay;
+pub(crate) use self::onboarding::{
+ onboarding_notification_button_rects, onboarding_welcome_continue_rect,
+};
+use self::panes::{compute_pane_infos, render_panes};
+use self::release_notes::render_release_notes_overlay;
+pub(crate) use self::release_notes::{
+ release_notes_close_button_rect, release_notes_display_lines, release_notes_sections,
+ RELEASE_NOTES_MODAL_SIZE,
+};
+pub(crate) use self::scrollbar::{
+ pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row,
+ scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar,
+};
+use self::settings::render_settings_overlay;
+use self::sidebar::{render_sidebar, render_sidebar_collapsed};
+use self::status::{render_config_diagnostic, render_toast_notification};
+use self::tabs::render_tab_bar;
+pub(crate) use self::{
+ dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects},
+ settings::settings_button_rects,
+ sidebar::{
+ agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics,
+ agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections,
+ collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
+ sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect,
+ workspace_list_scroll_metrics, workspace_list_scrollbar_rect,
+ },
+};
+pub(crate) use self::{
+ keybind_help::keybind_help_lines,
+ tabs::compute_tab_bar_view,
+ widgets::{centered_popup_rect, modal_stack_areas},
+};
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;
@@ -20,211 +69,26 @@ 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 {
+pub(super) fn spinner_frame(tick: u32) -> &'static str {
SPINNERS[(tick as usize / 8) % SPINNERS.len()]
}
-use crate::app::state::Palette;
-
-pub(crate) struct AgentPanelEntry {
- pub ws_idx: usize,
- pub tab_idx: usize,
- pub pane_id: crate::layout::PaneId,
- pub primary_label: String,
- pub primary_tab_label: Option<String>,
- pub agent_label: Option<String>,
- pub state: AgentState,
- pub seen: bool,
-}
-
-fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
- if total_h == 0 {
- return (0, 0);
- }
-
- if total_h < 6 {
- let ws_h = (total_h + 1) / 2;
- return (ws_h, total_h.saturating_sub(ws_h));
- }
-
- let ratio = split_ratio.clamp(0.1, 0.9);
- let ws_h = ((total_h as f32) * ratio).round() as u16;
- let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
- let detail_h = total_h.saturating_sub(ws_h);
- (ws_h, detail_h)
-}
-
-pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
- return (Rect::default(), Rect::default());
- }
-
- let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
- let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
- (ws_area, detail_area)
-}
-
-pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height < 6 {
- return Rect::default();
- }
-
- let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
- Rect::new(content.x, content.y + ws_h, content.width, 1)
-}
-
-fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
- if matches!(
- app.mode,
- Mode::Navigate
- | Mode::RenameWorkspace
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
- | Mode::Settings
- | Mode::GlobalMenu
- | Mode::KeybindHelp
- ) {
- Some(app.selected)
- } else {
- app.active
- }
-}
-
-fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
- match scope {
- AgentPanelScope::CurrentWorkspace => "current",
- AgentPanelScope::AllWorkspaces => "all",
- }
-}
-
-pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
- if area.width == 0 || area.height < 2 {
- return Rect::default();
- }
-
- let label = agent_panel_toggle_label(scope);
- let width = label.chars().count() as u16;
- Rect::new(
- area.x + area.width.saturating_sub(width),
- area.y + 1,
- width,
- 1,
- )
-}
-
-pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
- match app.agent_panel_scope {
- AgentPanelScope::CurrentWorkspace => {
- let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
- return Vec::new();
- };
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return Vec::new();
- };
- ws.pane_details()
- .into_iter()
- .map(|detail| AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: detail.label,
- primary_tab_label: None,
- agent_label: None,
- state: detail.state,
- seen: detail.seen,
- })
- .collect()
- }
- AgentPanelScope::AllWorkspaces => app
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- let multi_tab = ws.tabs.len() > 1;
- let workspace_label = ws.display_name();
- ws.pane_details()
- .into_iter()
- .map(move |detail| AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: workspace_label.clone(),
- primary_tab_label: multi_tab.then_some(detail.tab_label),
- agent_label: Some(detail.agent_label),
- state: detail.state,
- seen: detail.seen,
- })
- })
- .collect(),
- }
-}
-
-fn truncate_text(text: &str, max_width: usize) -> String {
- let len = text.chars().count();
- if len <= max_width {
- return text.to_string();
- }
- if max_width == 0 {
- return String::new();
- }
- if max_width == 1 {
- return "…".to_string();
- }
- let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
- format!("{prefix}…")
+/// Compute view geometry and reconcile pane sizes.
+/// Called before render to separate mutation from drawing.
+pub fn compute_view(app: &mut AppState, area: Rect) {
+ compute_view_internal(app, area, true);
}
-fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
- let Some(tab_label) = entry.primary_tab_label.as_deref() else {
- return truncate_text(&entry.primary_label, max_width);
- };
-
- let separator = " · ";
- let separator_width = separator.chars().count();
- if max_width <= separator_width + 2 {
- return truncate_text(
- &format!("{}{}{}", entry.primary_label, separator, tab_label),
- max_width,
- );
- }
-
- let available = max_width.saturating_sub(separator_width);
- let min_tab = 4.min(available.saturating_sub(1)).max(1);
- let preferred_workspace = ((available * 2) / 3).max(1);
- let mut workspace_budget = preferred_workspace
- .min(available.saturating_sub(min_tab))
- .max(1);
- let mut tab_budget = available.saturating_sub(workspace_budget);
-
- let workspace_len = entry.primary_label.chars().count();
- let tab_len = tab_label.chars().count();
-
- if workspace_len < workspace_budget {
- let spare = workspace_budget - workspace_len;
- workspace_budget = workspace_len;
- tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
- }
- if tab_len < tab_budget {
- let spare = tab_budget - tab_len;
- tab_budget = tab_len;
- workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
- }
-
- format!(
- "{}{}{}",
- truncate_text(&entry.primary_label, workspace_budget),
- separator,
- truncate_text(tab_label, tab_budget)
- )
+/// Compute view geometry for a client-sized render without resizing pane runtimes.
+///
+/// This is used by the headless server when a non-foreground client needs its
+/// own frame size while the shared pane runtimes stay pinned to the foreground
+/// client.
+pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) {
+ compute_view_internal(app, area, false);
}
-/// Compute view geometry and reconcile pane sizes.
-/// Called before render to separate mutation from drawing.
-pub fn compute_view(app: &mut AppState, area: Rect) {
+fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) {
let sidebar_w = if app.sidebar_collapsed {
COLLAPSED_WIDTH
} else {
@@ -281,7 +145,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
.map(|ws| ws.layout.splits(terminal_area))
.unwrap_or_default();
- let pane_infos = compute_pane_infos(app, terminal_area);
+ let pane_infos = compute_pane_infos(app, terminal_area, resize_panes);
app.view = crate::app::ViewState {
sidebar_rect: sidebar_area,
@@ -343,1454 +207,6 @@ pub fn render(app: &AppState, frame: &mut Frame) {
}
}
-const MIN_TAB_WIDTH: u16 = 8;
-const NEW_TAB_WIDTH: u16 = 3;
-const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
-const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
-const AGENT_PANEL_HEADER_ROWS: u16 = 3;
-
-#[derive(Default)]
-pub(crate) struct TabBarView {
- pub scroll: usize,
- pub tab_hit_areas: Vec<Rect>,
- pub scroll_left_hit_area: Rect,
- pub scroll_right_hit_area: Rect,
- pub new_tab_hit_area: Rect,
-}
-
-fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
- if ws.branch().is_some() {
- 2
- } else {
- 1
- }
-}
-
-pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
- let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
- ws_area
-}
-
-pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
- let footer_y = area.y + area.height.saturating_sub(1);
- let body_height = footer_y.saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
- let body = workspace_list_body_rect(area, false);
- if body.width == 0 || body.height == 0 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- for ws in app.workspaces.iter().skip(scroll) {
- let needed = workspace_row_height(ws).saturating_add(1);
- if used_rows.saturating_add(needed) > body.height {
- break;
- }
- used_rows = used_rows.saturating_add(needed);
- visible += 1;
- }
- visible
-}
-
-pub(crate) fn workspace_list_scroll_metrics(
- app: &AppState,
- area: Rect,
-) -> crate::pane::ScrollMetrics {
- let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
- let total_rows = app.workspaces.len();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(app.workspace_scroll)
- .saturating_sub(viewport_rows);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
- viewport_rows,
- }
-}
-
-pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = workspace_list_scroll_metrics(app, area);
- let body = workspace_list_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
- let body_height = (area.y + area.height).saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn agent_panel_visible_count(area: Rect) -> usize {
- let body = agent_panel_body_rect(area, false);
- if body.width == 0 || body.height < 2 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- while used_rows.saturating_add(2) <= body.height {
- used_rows = used_rows.saturating_add(2);
- visible += 1;
- if used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
- }
- visible
-}
-
-pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
- let viewport_rows = agent_panel_visible_count(area);
- let total_rows = agent_panel_entries(app).len();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(app.agent_panel_scroll)
- .saturating_sub(viewport_rows);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
- viewport_rows,
- }
-}
-
-pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = agent_panel_scroll_metrics(app, area);
- let body = agent_panel_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn compute_workspace_card_areas(
- app: &AppState,
- area: Rect,
-) -> Vec<crate::app::state::WorkspaceCardArea> {
- let ws_area = workspace_list_rect(area, app.sidebar_section_split);
- if ws_area == Rect::default() {
- return Vec::new();
- }
-
- let metrics = workspace_list_scroll_metrics(app, ws_area);
- let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
- if body.width == 0 || body.height == 0 {
- return Vec::new();
- }
-
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- let mut cards = Vec::new();
-
- for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
- let row_height = workspace_row_height(ws);
- if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
- break;
- }
- cards.push(crate::app::state::WorkspaceCardArea {
- ws_idx,
- rect: Rect::new(body.x, row_y, body.width, row_height),
- });
- row_y = row_y.saturating_add(row_height + 1);
- }
-
- cards
-}
-
-fn tab_width(tab: &crate::workspace::Tab) -> u16 {
- (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH)
-}
-
-fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
- let mut rects = vec![Rect::default(); ws.tabs.len()];
- if area.width == 0 || area.height == 0 {
- return rects;
- }
-
- let mut x = area.x;
- let right = area.x + area.width;
- for idx in scroll..ws.tabs.len() {
- if x >= right {
- break;
- }
- let desired = tab_width(&ws.tabs[idx]);
- let remaining = right.saturating_sub(x);
- let width = desired.min(remaining).max(1);
- rects[idx] = Rect::new(x, area.y, width, 1);
- x = x.saturating_add(width + 1);
- }
- rects
-}
-
-fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- let mut best_scroll = ws.active_tab;
- let mut best_distance = u16::MAX;
- let viewport_center = area.x.saturating_mul(2).saturating_add(area.width);
-
- for scroll in 0..=ws.active_tab {
- let rects = layout_tab_hit_areas(ws, area, scroll);
- let Some(active_rect) = rects.get(ws.active_tab).copied() else {
- continue;
- };
- if active_rect.width == 0 {
- continue;
- }
-
- let active_center = active_rect
- .x
- .saturating_mul(2)
- .saturating_add(active_rect.width);
- let distance = active_center.abs_diff(viewport_center);
- if distance <= best_distance {
- best_distance = distance;
- best_scroll = scroll;
- }
- }
-
- best_scroll
-}
-
-fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 {
- tab_hit_areas
- .iter()
- .rev()
- .find(|rect| rect.width > 0)
- .map(|rect| rect.x + rect.width)
- .unwrap_or(fallback_x)
-}
-
-fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- (0..ws.tabs.len())
- .find(|&scroll| {
- layout_tab_hit_areas(ws, area, scroll)
- .last()
- .is_some_and(|rect| rect.width > 0)
- })
- .unwrap_or(0)
-}
-
-pub(crate) fn compute_tab_bar_view(
- ws: &crate::workspace::Workspace,
- area: Rect,
- current_scroll: usize,
- follow_active: bool,
-) -> TabBarView {
- if area.width == 0 || area.height == 0 {
- return TabBarView::default();
- }
-
- let area_right = area.x + area.width;
- let all_tabs_area = Rect::new(
- area.x,
- area.y,
- area.width.saturating_sub(NEW_TAB_WIDTH),
- area.height,
- );
- let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0);
- let overflow = all_tabs.iter().any(|rect| rect.width == 0);
- if !overflow {
- let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x);
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
- return TabBarView {
- scroll: 0,
- tab_hit_areas: all_tabs,
- scroll_left_hit_area: Rect::default(),
- scroll_right_hit_area: Rect::default(),
- new_tab_hit_area,
- };
- }
-
- let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1);
- let tab_area_x = left_hit_area.x + left_hit_area.width;
- let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH);
- let tab_area_right = area_right.saturating_sub(reserved_trailing_width);
- let tab_area = Rect::new(
- tab_area_x,
- area.y,
- tab_area_right.saturating_sub(tab_area_x),
- area.height,
- );
-
- let max_scroll = max_tab_scroll(ws, tab_area);
- let scroll = if follow_active {
- centered_tab_scroll(ws, tab_area).min(max_scroll)
- } else {
- current_scroll.min(max_scroll)
- };
- let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll);
- let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right);
- let right_hit_area = Rect::new(
- trailing_x,
- area.y,
- area_right
- .saturating_sub(trailing_x)
- .min(TAB_SCROLL_BUTTON_WIDTH),
- 1,
- );
- let new_tab_x = right_hit_area.x + right_hit_area.width;
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
-
- TabBarView {
- scroll,
- tab_hit_areas,
- scroll_left_hit_area: left_hit_area,
- scroll_right_hit_area: right_hit_area,
- new_tab_hit_area,
- }
-}
-
-fn tab_drop_indicator_x(
- app: &AppState,
- ws: &crate::workspace::Workspace,
- insert_idx: usize,
-) -> Option<u16> {
- let visible_tabs = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .filter(|(_, rect)| rect.width > 0);
- let first_visible = visible_tabs.clone().next()?;
- let last_visible = visible_tabs.last().unwrap_or(first_visible);
-
- if insert_idx == 0 {
- return Some(if first_visible.0 == 0 {
- first_visible.1.x
- } else {
- app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
- });
- }
-
- if let Some((_, rect)) = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(idx, rect)| *idx == insert_idx && rect.width > 0)
- {
- return Some(rect.x.saturating_sub(1));
- }
-
- if insert_idx >= ws.tabs.len() {
- return Some(if last_visible.0 + 1 >= ws.tabs.len() {
- last_visible.1.x + last_visible.1.width
- } else {
- app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
- });
- }
-
- None
-}
-
-fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let Some(active_ws_idx) = app.active else {
- return;
- };
- let Some(ws) = app.workspaces.get(active_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,
- );
-
- let first_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let last_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .rev()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
- let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
- && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
-
- if app.view.tab_scroll_left_hit_area.width > 0 {
- let style = if can_scroll_left {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" < ").style(style),
- app.view.tab_scroll_left_hit_area,
- );
- }
-
- if app.view.tab_scroll_right_hit_area.width > 0 {
- let style = if can_scroll_right {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" > ").style(style),
- app.view.tab_scroll_right_hit_area,
- );
- }
-
- for (idx, tab) in ws.tabs.iter().enumerate() {
- let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
- break;
- };
- if rect.width == 0 {
- continue;
- }
- let active = idx == ws.active_tab;
- let style = if active {
- let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent);
- if tab.is_auto_named() {
- base.add_modifier(Modifier::DIM)
- } else {
- base.add_modifier(Modifier::BOLD)
- }
- } else if tab.is_auto_named() {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- } 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 let Some(crate::app::state::DragState {
- target:
- crate::app::state::DragTarget::TabReorder {
- ws_idx,
- insert_idx: Some(insert_idx),
- ..
- },
- }) = &app.drag
- {
- if *ws_idx == active_ws_idx {
- if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) {
- frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)]
- .set_symbol("│")
- .set_style(Style::default().fg(p.accent));
- }
- }
- }
-
- 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,
- );
- }
-
- if first_visible_idx.is_some_and(|idx| idx > 0) {
- let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width;
- if x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
- if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
- let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1);
- if x >= area.x && x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
-}
-
-/// 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.
-pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
- return (Rect::default(), None, Rect::default());
- }
-
- if content.height < 7 {
- return (content, None, Rect::default());
- }
-
- let total_h = content.height as usize;
- let ws_h = (total_h + 1) / 2;
- let detail_h = total_h.saturating_sub(ws_h + 1);
- if ws_h == 0 || detail_h == 0 {
- return (content, None, Rect::default());
- }
-
- let divider_y = content.y + ws_h as u16;
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
- let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16);
- (ws_area, Some(divider_y), detail_area)
-}
-
-/// Collapsed sidebar: workspace glance on top, compact agent list below.
-fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
- let is_navigating = matches!(app.mode, Mode::Navigate);
-
- 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 (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
- if ws_area == Rect::default() {
- render_sidebar_toggle(app, frame, area, true, p);
- return;
- }
-
- for (visible_idx, ws) in app.workspaces.iter().enumerate() {
- let y = ws_area.y + visible_idx as u16;
- if y >= ws_area.y + ws_area.height {
- break;
- }
- let (agg_state, agg_seen) = ws.aggregate_state();
- let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
- let is_selected = visible_idx == app.selected && is_navigating;
- let is_active = Some(visible_idx) == app.active;
- let row_style = if is_selected {
- Style::default().bg(p.surface0)
- } else if is_active {
- Style::default().bg(p.surface_dim)
- } else {
- Style::default()
- };
- let num_style = if is_selected {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else if is_active {
- Style::default().fg(p.text).bg(p.surface_dim)
- } else {
- Style::default().fg(p.overlay0)
- };
-
- if is_selected || is_active {
- let buf = frame.buffer_mut();
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, y)].set_style(row_style);
- }
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{}", visible_idx + 1), num_style),
- Span::styled(" ", row_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(ws_area.x, y, ws_area.width, 1),
- );
- }
-
- if let Some(divider_y) = divider_y {
- let buf = frame.buffer_mut();
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, divider_y)].set_symbol("─");
- buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim));
- }
- }
-
- let detail_ws_idx = if is_navigating {
- Some(app.selected)
- } else {
- app.active
- };
- let detail_content_area = Rect::new(
- detail_area.x,
- detail_area.y,
- detail_area.width,
- detail_area.height.saturating_sub(1),
- );
- if detail_content_area != Rect::default() {
- if let Some(ws_idx) = detail_ws_idx {
- if let Some(ws) = app.workspaces.get(ws_idx) {
- for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
- let y = detail_content_area.y + detail_idx as u16;
- if y >= detail_content_area.y + detail_content_area.height {
- break;
- }
- let pane_num = ws
- .public_pane_number(detail.pane_id)
- .unwrap_or(detail_idx + 1);
- let pane_style = Style::default().fg(p.overlay0);
- let (icon, icon_style) =
- agent_icon(detail.state, detail.seen, app.spinner_tick, p);
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{pane_num}"), pane_style),
- Span::styled(" ", pane_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(detail_content_area.x, y, detail_content_area.width, 1),
- );
- }
- }
- }
- }
-
- render_sidebar_toggle(app, frame, area, true, p);
-}
-
-pub(crate) fn workspace_drop_indicator_row(
- cards: &[crate::app::state::WorkspaceCardArea],
- area: Rect,
- insert_idx: usize,
-) -> Option<u16> {
- if area.height == 0 {
- return None;
- }
- let list_bottom = area.y + area.height.saturating_sub(1);
-
- let first = cards.first()?;
- if insert_idx == first.ws_idx {
- return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
- }
-
- if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
- return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
- }
-
- cards
- .last()
- .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
- .map(|card| card.rect.y.saturating_add(card.rect.height))
- .filter(|y| *y < list_bottom)
-}
-
-fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
- let is_navigating = matches!(app.mode, Mode::Navigate);
- 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 (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
-
- // --- Top section: Workspaces ---
- render_workspace_list(app, frame, ws_area, is_navigating);
-
- // --- Bottom section: Agent detail ---
- render_agent_detail(app, frame, detail_area);
-
- render_sidebar_toggle(app, 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;
- let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
- Some(*source_ws_idx)
- }
- _ => None,
- };
- let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder {
- insert_idx: Some(insert_idx),
- ..
- }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
- _ => None,
- };
-
- // Section header + reserve last row for "new" button
- let list_bottom = area.y + area.height.saturating_sub(1);
- if area.height > 0 {
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " spaces",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y, area.width, 1),
- );
- }
-
- let metrics = workspace_list_scroll_metrics(app, area);
- let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
- let cards = &app.view.workspace_card_areas;
-
- for card in cards {
- let i = card.ws_idx;
- let ws = &app.workspaces[i];
- let row_y = card.rect.y;
- let row_height = card.rect.height;
- let selected = i == app.selected && is_navigating;
- let is_active = Some(i) == app.active;
- let is_dragged = dragged_ws_idx == Some(i);
- let highlighted = selected || is_active || is_dragged;
- let (agg_state, agg_seen) = ws.aggregate_state();
-
- if highlighted {
- let bg = if selected {
- p.surface0
- } else if is_dragged {
- p.surface1
- } 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 card.rect.x..card.rect.x + card.rect.width {
- buf[(x, y)].set_style(Style::default().bg(bg));
- }
- }
- }
-
- if is_active {
- let buf = frame.buffer_mut();
- for y in row_y..row_y + row_height {
- if y >= list_bottom {
- break;
- }
- buf[(card.rect.x, y)].set_symbol("▌");
- buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
- let name_style = if selected || is_active || is_dragged {
- 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)
- };
-
- let mut line1 = vec![
- Span::styled(" ", 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(card.rect.x, row_y, card.rect.width, 1),
- );
-
- if row_height > 1 && row_y + 1 < 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 = (card.rect.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)));
- }
- }
- frame.render_widget(
- Paragraph::new(Line::from(spans)),
- Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
- );
- }
- }
- }
-
- if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
- let indicator_right = scrollbar_rect
- .map(|rect| rect.x)
- .unwrap_or(area.x + area.width);
- let buf = frame.buffer_mut();
- for x in area.x..indicator_right {
- buf[(x, y)].set_symbol("─");
- buf[(x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
- if let Some(track) = scrollbar_rect {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-
- // 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();
- let menu_line = if app.update_available.is_some() {
- Line::from(vec![
- Span::styled("menu", Style::default().fg(p.overlay0)),
- Span::styled(
- " ●",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- ])
- } else {
- Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
- };
- frame.render_widget(
- Paragraph::new(menu_line).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) {
- let p = &app.palette;
-
- if area.height < 3 {
- return;
- }
-
- 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, area.y, area.width, 1),
- );
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " agents",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y + 1, area.width, 1),
- );
- let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
- if toggle_rect != Rect::default() {
- frame.render_widget(
- Paragraph::new(Span::styled(
- agent_panel_toggle_label(app.agent_panel_scope),
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- ))
- .alignment(Alignment::Right),
- toggle_rect,
- );
- }
-
- let details = agent_panel_entries(app);
- let metrics = agent_panel_scroll_metrics(app, area);
- let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
- let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
- if body == Rect::default() {
- return;
- }
-
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- for detail in details.iter().skip(app.agent_panel_scroll) {
- if row_y.saturating_add(1) >= body_bottom {
- 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);
-
- let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
- let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM);
- let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
-
- let primary_label =
- format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
- let name_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(icon, icon_style),
- Span::styled(" ", Style::default()),
- Span::styled(primary_label, name_style),
- ]);
- frame.render_widget(
- Paragraph::new(name_line),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
- let mut status_spans = vec![
- Span::styled(" ", Style::default()),
- Span::styled(label, status_style),
- ];
- if let Some(agent_label) = &detail.agent_label {
- status_spans.push(Span::styled(" · ", agent_style));
- status_spans.push(Span::styled(agent_label, agent_style));
- }
- frame.render_widget(
- Paragraph::new(Line::from(status_spans)),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
- if row_y < body_bottom {
- row_y += 1;
- }
- }
-
- if let Some(track) = scrollbar_rect {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-}
-
-pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
- 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 Rect::default();
- }
- let x = area.x + content_w / 2;
- Rect::new(x, bottom_y, 1, 1)
-}
-
-fn render_sidebar_toggle(
- app: &AppState,
- 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 toggle_area = collapsed_sidebar_toggle_rect(area);
- if toggle_area == Rect::default() {
- return;
- }
- let icon_style = if app.update_available.is_some() {
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.overlay0)
- };
- frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), 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. Only the focused pane should own the cursor.
- rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
- 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,
- rt.scroll_metrics(),
- &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,
- scroll_metrics: Option<crate::pane::ScrollMetrics>,
- 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, scroll_metrics) {
- let cell = &mut buf[(inner.x + x, inner.y + y)];
- cell.set_style(Style::default().fg(panel_contrast_fg(p)).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 {
@@ -1801,1730 +217,7 @@ fn dim_background(frame: &mut Frame, area: Rect) {
}
}
-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)
-}
-
-fn panel_contrast_fg(p: &Palette) -> Color {
- match p.panel_bg {
- Color::Reset => p.surface_dim,
- color => color,
- }
-}
-
-pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
-
-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) {
- let line = Line::from(vec![
- Span::styled(
- title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- ),
- Span::raw(" "),
- Span::styled("⬆", Style::default().fg(p.accent)),
- ]);
- frame.render_widget(Paragraph::new(line), area);
-}
-
-#[derive(Debug, Clone, Copy)]
-pub(crate) struct ModalStackAreas {
- pub header: Rect,
- pub content: Rect,
- pub footer: Option<Rect>,
- pub actions: Option<Rect>,
-}
-
-pub(crate) fn modal_stack_areas(
- inner: Rect,
- header_height: u16,
- footer_height: u16,
- actions_height: u16,
- gap: u16,
-) -> ModalStackAreas {
- #[derive(Clone, Copy)]
- enum Slot {
- Header,
- Content,
- Footer,
- Actions,
- }
-
- let mut constraints = Vec::new();
- let mut slots = Vec::new();
- let mut push = |slot: Slot, constraint: Constraint| {
- if !slots.is_empty() {
- constraints.push(Constraint::Length(gap));
- }
- constraints.push(constraint);
- slots.push(slot);
- };
-
- push(Slot::Header, Constraint::Length(header_height));
- push(Slot::Content, Constraint::Min(0));
- if footer_height > 0 {
- push(Slot::Footer, Constraint::Length(footer_height));
- }
- if actions_height > 0 {
- push(Slot::Actions, Constraint::Length(actions_height));
- }
-
- let areas = Layout::vertical(constraints).split(inner);
- let mut header = Rect::default();
- let mut content = Rect::default();
- let mut footer = None;
- let mut actions = None;
-
- for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) {
- match slot {
- Slot::Header => header = area,
- Slot::Content => content = area,
- Slot::Footer => footer = Some(area),
- Slot::Actions => actions = Some(area),
- }
- }
-
- ModalStackAreas {
- header,
- content,
- footer,
- actions,
- }
-}
-
-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,
- RELEASE_NOTES_MODAL_SIZE.0,
- RELEASE_NOTES_MODAL_SIZE.1,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 8 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- let header_title_area = Rect::new(
- header_rows[0].x + 1,
- header_rows[0].y,
- header_rows[0].width.saturating_sub(2),
- header_rows[0].height,
- );
- let header_subtitle_area = Rect::new(
- header_rows[1].x + 1,
- header_rows[1].y,
- header_rows[1].width.saturating_sub(2),
- header_rows[1].height,
- );
-
- render_modal_header(
- frame,
- header_title_area,
- &format!("v{}", notes.version),
- &app.palette,
- );
- let subtitle = if notes.preview {
- "preview before updating"
- } else {
- "what's new in this release"
- };
- frame.render_widget(
- Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
- header_subtitle_area,
- );
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-
- let sections = release_notes_sections(stack.content, notes.preview);
- 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: sections.notes_body.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(sections.notes_body, metrics);
- let notes_text_area = track
- .map(|_| {
- Rect::new(
- sections.notes_body.x,
- sections.notes_body.y,
- sections.notes_body.width.saturating_sub(1),
- sections.notes_body.height,
- )
- })
- .unwrap_or(sections.notes_body);
-
- if let Some(instructions_area) = sections.instructions {
- render_release_notes_preview_panel(frame, instructions_area, &notes.version, &app.palette);
- }
-
- let body = Paragraph::new(
- release_notes_lines(notes.body.as_str(), &app.palette)
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((notes.scroll, 0));
- frame.render_widget(body, notes_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(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
-fn release_notes_inline_spans<'a>(
- text: &str,
- base_style: Style,
- code_style: Style,
-) -> (usize, Vec<Span<'a>>) {
- let mut spans = Vec::new();
- let mut width = 0;
- let mut remaining = text;
-
- while let Some(start) = remaining.find('`') {
- let (before, after_start) = remaining.split_at(start);
- if !before.is_empty() {
- width += before.chars().count();
- spans.push(Span::styled(before.to_string(), base_style));
- }
-
- let after_start = &after_start[1..];
- let Some(end) = after_start.find('`') else {
- let literal = format!("`{after_start}");
- width += literal.chars().count();
- spans.push(Span::styled(literal, base_style));
- remaining = "";
- break;
- };
-
- let (code, after_end) = after_start.split_at(end);
- width += code.chars().count();
- if !code.is_empty() {
- spans.push(Span::styled(code.to_string(), code_style));
- }
- remaining = &after_end[1..];
- }
-
- if !remaining.is_empty() {
- width += remaining.chars().count();
- spans.push(Span::styled(remaining.to_string(), base_style));
- }
-
- if spans.is_empty() {
- spans.push(Span::styled(String::new(), base_style));
- }
-
- (width, spans)
-}
-
-pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> {
- let mut lines = Vec::new();
- let mut in_fenced_code_block = false;
- let text_style = Style::default().fg(p.text);
- let inline_code_style = Style::default()
- .fg(p.accent)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD);
-
- for raw in body.lines() {
- let trimmed = raw.trim_end();
- if trimmed.trim_start().starts_with("```") {
- in_fenced_code_block = !in_fenced_code_block;
- continue;
- }
-
- if in_fenced_code_block {
- let code_bg = p.surface1;
- let gutter_style = Style::default().fg(p.accent).bg(code_bg);
- let code_style = Style::default().fg(p.text).bg(code_bg);
- let width = 2 + trimmed.chars().count();
- let mut spans = vec![
- Span::styled("▏", gutter_style),
- Span::styled(" ", code_style),
- ];
- if !trimmed.is_empty() {
- spans.push(Span::styled(trimmed.to_string(), code_style));
- }
- lines.push((width, Line::from(spans)));
- continue;
- }
-
- if trimmed.is_empty() {
- lines.push((0, Line::raw("")));
- continue;
- }
-
- if let Some(rest) = trimmed.strip_prefix("### ") {
- let text = rest.trim().to_string();
- if text.is_empty() {
- lines.push((0, Line::raw("")));
- continue;
- }
- let width = 1 + text.chars().count();
- lines.push((
- width,
- Line::from(vec![
- Span::raw(" "),
- Span::styled(
- text.to_uppercase(),
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- ]),
- ));
- continue;
- }
-
- if let Some(rest) = trimmed.strip_prefix("- ") {
- let (text_width, mut spans) =
- release_notes_inline_spans(rest, text_style, inline_code_style);
- let width = 3 + text_width;
- let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))];
- line_spans.append(&mut spans);
- lines.push((width, Line::from(line_spans)));
- continue;
- }
-
- let (text_width, mut spans) =
- release_notes_inline_spans(trimmed, text_style, inline_code_style);
- let width = 1 + text_width;
- let mut line_spans = vec![Span::raw(" ")];
- line_spans.append(&mut spans);
- lines.push((width, Line::from(line_spans)));
- }
-
- lines
-}
-
-pub(crate) struct ReleaseNotesSections {
- pub instructions: Option<Rect>,
- pub notes_body: Rect,
-}
-
-pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections {
- if preview && area.height >= 6 {
- let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area);
- ReleaseNotesSections {
- instructions: Some(rows[0]),
- notes_body: rows[1],
- }
- } else {
- ReleaseNotesSections {
- instructions: None,
- notes_body: area,
- }
- }
-}
-
-fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> {
- let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD);
- let text_style = Style::default().fg(p.text);
- let code_style = Style::default()
- .fg(p.accent)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD);
-
- vec![
- Line::from(vec![
- Span::styled(
- "●",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::styled(" update available", title_style),
- ]),
- Line::from(vec![
- Span::styled("quit herdr and run ", text_style),
- Span::styled("herdr update", code_style),
- Span::styled(" to install", text_style),
- ]),
- ]
-}
-
-fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) {
- let rows = Layout::vertical([
- Constraint::Length(2),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<4>(area);
-
- let text_area = Rect::new(
- rows[0].x + 1,
- rows[0].y,
- rows[0].width.saturating_sub(2),
- rows[0].height,
- );
- frame.render_widget(
- Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }),
- text_area,
- );
-
- let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1);
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- "─".repeat(divider_area.width as usize),
- Style::default().fg(p.surface1),
- )])),
- divider_area,
- );
-}
-
-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: "start",
- },
- ],
- 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, 16, &app.palette) else {
- return;
- };
- if inner.height < 11 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 0, 1, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
- let content_rows = Layout::vertical([
- Constraint::Length(3),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<4>(stack.content);
-
- frame.render_widget(
- Paragraph::new(" herdr").style(
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- header_rows[0],
- );
- frame.render_widget(
- Paragraph::new(" terminal workspace manager for coding agents")
- .style(Style::default().fg(app.palette.overlay0)),
- header_rows[1],
- );
-
- frame.render_widget(
- Paragraph::new(
- " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.",
- )
- .style(Style::default().fg(app.palette.overlay1)),
- content_rows[0],
- );
-
- let key_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(
- ONBOARDING_PREFIX_LABEL,
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " enters navigate mode · ",
- Style::default().fg(app.palette.overlay1),
- ),
- Span::styled(
- "?",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " shows keybinds and settings",
- Style::default().fg(app.palette.overlay1),
- ),
- ]);
- frame.render_widget(Paragraph::new(key_line), content_rows[2]);
-
- let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
- render_action_button(
- frame,
- continue_rect,
- Some("↵"),
- "continue",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else {
- return;
- };
-
- if inner.height < 11 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 3, 0, 1, 1);
- let header_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(stack.header);
- let option_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<5>(stack.content);
-
- render_modal_header(frame, header_rows[0], "notification style", &app.palette);
- frame.render_widget(
- Paragraph::new(" herdr watches background panes and can alert you")
- .style(Style::default().fg(app.palette.overlay1)),
- header_rows[1],
- );
- frame.render_widget(
- Paragraph::new(" when agents finish or need attention.")
- .style(Style::default().fg(app.palette.overlay1)),
- header_rows[2],
- );
-
- let options = [
- "quiet no interruptions",
- "visual only top-right toasts",
- "sound only sound alerts",
- "both sound and 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(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- } else {
- Style::default().fg(app.palette.text)
- };
- frame.render_widget(
- Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
- option_rows[idx],
- );
- }
-
- let (back_rect, save_rect) =
- onboarding_notification_button_rects(stack.actions.unwrap_or_default());
- 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("↵"),
- "start",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .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(panel_contrast_fg(&app.palette))
- .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),
- Span::styled("q", key),
- Span::styled(" quit", 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);
-
- if app.update_available.is_some() {
- let status = Line::from(vec![Span::styled(
- " update available",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- )]);
- let width = 13u16.min(overlay_area.width);
- let status_area = Rect::new(
- overlay_area.x + overlay_area.width.saturating_sub(width),
- overlay_area.y,
- width,
- overlay_area.height,
- );
- frame.render_widget(Clear, status_area);
- frame.render_widget(
- Paragraph::new(status).alignment(Alignment::Right),
- status_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 = app.global_menu_labels();
- 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 rect = Rect::new(inner.x, y, inner.width, 1);
-
- if *item == "update available" {
- let line = if selected {
- Line::from(vec![
- Span::styled(
- " update available ",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- "● ",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- ])
- } else {
- Line::from(vec![
- Span::styled(" update available ", Style::default().fg(app.palette.text)),
- Span::styled(
- "● ",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- ])
- };
- frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect);
- continue;
- }
-
- let style = if selected {
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .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,
- );
- }
-}
-
-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));
-
- if !kb.custom_commands.is_empty() {
- groups.push((
- "custom",
- kb.custom_commands
- .iter()
- .map(|binding| (binding.label.clone(), "custom command"))
- .collect(),
- ));
- }
-
- 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 stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .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)),
- header_rows[1],
- );
-
- let body_area = stack.content;
- 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(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
-/// 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(panel_contrast_fg(&app.palette))
- .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 if app.creating_new_tab => "new tab",
- 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(panel_contrast_fg(&app.palette))
- .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(panel_contrast_fg(&app.palette))
- .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 stack = modal_stack_areas(inner, 3, 2, 0, 1);
- let header_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(stack.header);
-
- // Title
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " settings",
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- )])),
- header_rows[0],
- );
-
- // 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(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD),
- )
- .divider(" ")
- .padding(" ", " ");
- frame.render_widget(tabs, header_rows[1]);
-
- // Separator
- let sep = "─".repeat(inner.width as usize);
- frame.render_widget(
- Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
- header_rows[2],
- );
-
- // Section content
- let content_area = stack.content;
-
- 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
- if let Some(footer_area) = stack.footer {
- let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
- .areas::<2>(footer_area);
- let (apply_rect, close_rect) = settings_button_rects(inner);
- render_action_button(
- frame,
- apply_rect,
- Some("↵"),
- "apply",
- Style::default()
- .fg(panel_contrast_fg(p))
- .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),
- );
-
- 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)),
- ])),
- footer_rows[0],
- );
- }
-}
-
-/// 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(panel_contrast_fg(p))
- .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(panel_contrast_fg(p))
- .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(" "));
@@ -3540,9 +233,13 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V
#[cfg(test)]
mod tests {
+ use super::keybind_help::keybind_help_groups;
+ use super::release_notes::{release_notes_lines, release_notes_preview_lines};
+ use super::scrollbar::scrollbar_thumb;
use super::*;
- use crate::{detect::Agent, workspace::Workspace};
+ use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace};
use ratatui::{backend::TestBackend, Terminal};
+ use ratatui::{style::Color, text::Line};
#[tokio::test]
async fn focused_pane_cursor_wins_during_terminal_render() {
@@ -3607,6 +304,37 @@ mod tests {
}
#[test]
+ fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut ws = Workspace::test_new("one");
+ let repo = temp_git_repo("main");
+ ws.identity_cwd = repo.clone();
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0].pane_cwds.insert(root_pane, repo.clone());
+
+ app.workspaces = vec![ws];
+ app.selected = 0;
+ app.mode = Mode::Navigate;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+
+ let backend = TestBackend::new(80, 20);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal.draw(|frame| render(&app, frame)).unwrap();
+ let buffer = terminal.backend().buffer();
+
+ let card = app.view.workspace_card_areas[0].rect;
+ let line1 = buffer_row_text(buffer, card, card.y);
+ let line2 = buffer_row_text(buffer, card, card.y + 1);
+
+ assert!(line1.starts_with(" · one"));
+ assert!(!line1.contains("1 one"));
+ assert_eq!(line2, " main");
+
+ std::fs::remove_dir_all(repo).ok();
+ }
+
+ #[test]
fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
@@ -3765,78 +493,11 @@ mod tests {
}
#[test]
- fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
- let mut app = crate::app::state::AppState::test_new();
- let mut first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- first.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
-
- let mut second = Workspace::test_new("two");
- let second_tab = second.test_add_tab(Some("logs"));
- let second_pane = second.tabs[second_tab].root_pane;
- second.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- app.workspaces = vec![first, second];
- app.active = Some(0);
- app.selected = 0;
- app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
-
- let entries = agent_panel_entries(&app);
- assert_eq!(entries[0].primary_label, "one");
- assert!(entries[0].primary_tab_label.is_none());
- assert_eq!(entries[0].agent_label.as_deref(), Some("pi"));
- assert_eq!(entries[1].primary_label, "two");
- assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs"));
- assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
- }
-
- #[test]
- fn all_workspaces_primary_label_truncates_workspace_and_tab() {
- let entry = AgentPanelEntry {
- ws_idx: 0,
- tab_idx: 0,
- pane_id: crate::layout::PaneId::from_raw(1),
- primary_label: "agent-browser".into(),
- primary_tab_label: Some("test-escalation".into()),
- agent_label: Some("claude".into()),
- state: AgentState::Idle,
- seen: true,
- };
-
- let label = format_agent_panel_primary_label(&entry, 18);
-
- assert_eq!(label, "agent-bro… · test…");
- }
-
- #[test]
- fn expanded_sidebar_sections_handle_tiny_heights() {
- let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
-
- assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
- assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
- }
-
- #[test]
- fn sidebar_section_divider_is_hidden_for_tiny_heights() {
- let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
-
- assert_eq!(divider, Rect::default());
- }
-
- #[test]
- fn pane_scrollbar_rect_uses_rightmost_inner_column() {
+ fn pane_scrollbar_rect_overlays_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),
+ inner_rect: Rect::new(1, 1, 10, 6),
scrollbar_rect: Some(Rect::new(10, 1, 1, 6)),
is_focused: true,
};
@@ -3844,6 +505,40 @@ mod tests {
assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6)));
}
+ #[tokio::test]
+ async fn compute_view_keeps_terminal_width_when_pane_scrollbar_is_visible() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ 12,
+ 4,
+ 4096,
+ b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n",
+ ),
+ );
+
+ app.workspaces = vec![ws];
+ app.active = Some(0);
+ app.selected = 0;
+
+ compute_view(&mut app, Rect::new(0, 0, 40, 12));
+
+ let info = app.view.pane_infos.first().expect("pane info");
+ assert_eq!(info.inner_rect.width, app.view.terminal_area.width);
+ assert_eq!(
+ info.scrollbar_rect,
+ Some(Rect::new(
+ info.inner_rect.x + info.inner_rect.width.saturating_sub(1),
+ info.inner_rect.y,
+ 1,
+ info.inner_rect.height,
+ ))
+ );
+ }
+
#[test]
fn scrollbar_stays_hidden_without_scrollback() {
let metrics = crate::pane::ScrollMetrics {
@@ -3915,6 +610,29 @@ mod tests {
.collect::<String>()
}
+ fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String {
+ (area.x..area.x + area.width)
+ .map(|x| buffer[(x, row)].symbol())
+ .collect::<String>()
+ .trim_end()
+ .to_string()
+ }
+
+ fn temp_git_repo(branch: &str) -> std::path::PathBuf {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("unix time")
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}"));
+ std::fs::create_dir_all(root.join(".git")).expect("create .git dir");
+ std::fs::write(
+ root.join(".git/HEAD"),
+ format!("ref: refs/heads/{branch}\n"),
+ )
+ .expect("write HEAD");
+ root
+ }
+
#[test]
fn release_notes_inline_code_spans_are_styled_without_backticks() {
let palette = Palette::catppuccin();
@@ -3933,10 +651,10 @@ mod tests {
let lines = release_notes_preview_lines("0.5.0", &palette);
assert_eq!(lines.len(), 2);
- assert_eq!(line_text(&lines[0]), "● update available");
+ assert_eq!(line_text(&lines[0]), "● update ready");
assert_eq!(
line_text(&lines[1]),
- "quit herdr and run herdr update to install"
+ "detach from this session, then run herdr update in your shell"
);
assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent));
assert_eq!(lines[0].spans[1].style.fg, Some(palette.text));