diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/ui | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/dialogs.rs | 213 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 240 | ||||
| -rw-r--r-- | src/ui/menus.rs | 210 | ||||
| -rw-r--r-- | src/ui/onboarding.rs | 215 | ||||
| -rw-r--r-- | src/ui/panes.rs | 252 | ||||
| -rw-r--r-- | src/ui/release_notes.rs | 345 | ||||
| -rw-r--r-- | src/ui/scrollbar.rs | 191 | ||||
| -rw-r--r-- | src/ui/settings.rs | 238 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 940 | ||||
| -rw-r--r-- | src/ui/status.rs | 135 | ||||
| -rw-r--r-- | src/ui/tabs.rs | 356 | ||||
| -rw-r--r-- | src/ui/widgets.rs | 209 |
12 files changed, 3544 insertions, 0 deletions
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs new file mode 100644 index 00000000..78f1d1cb --- /dev/null +++ b/src/ui/dialogs.rs @@ -0,0 +1,213 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; + +use super::widgets::{ + action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button, + render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec, +}; +use crate::app::{AppState, Mode}; + +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]) +} + +pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + super::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), + ); +} + +pub(super) 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") + }; + + super::dim_background(frame, area); + + 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 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]) +} diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs new file mode 100644 index 00000000..ccff95ab --- /dev/null +++ b/src/ui/keybind_help.rs @@ -0,0 +1,240 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Wrap}, + Frame, +}; + +use super::release_notes::release_notes_close_button_rect; +use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; +use super::widgets::{ + modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header, + render_modal_shell, +}; +use crate::app::AppState; + +fn optional_keybind_label(label: &Option<String>) -> String { + label.clone().unwrap_or_else(|| "unset".to_string()) +} + +pub(super) 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 mut 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"), + ]; + if let Some(label) = &kb.detach_label { + workspace_tab.insert(3, (label.clone(), "detach from server")); + } + 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 +} + +pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) { + super::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(), + ); +} diff --git a/src/ui/menus.rs b/src/ui/menus.rs new file mode 100644 index 00000000..537ac9a1 --- /dev/null +++ b/src/ui/menus.rs @@ -0,0 +1,210 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use super::widgets::{panel_contrast_fg, render_panel_shell}; +use crate::app::AppState; + +fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) { + frame.render_widget(Clear, area); + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, area.y)].set_style(Style::default().bg(bg)); + } + frame.render_widget(Paragraph::new(line), area); +} + +pub(super) 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); + render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); + + if app.update_available.is_some() { + let status = Line::from(vec![Span::styled( + " update ready", + 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, + ); + } +} + +pub(super) 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 ready" { + let line = if selected { + Line::from(vec![ + Span::styled( + " update ready ", + 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 ready ", 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, + ); + } +} + +pub(super) 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); + render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); +} + +pub(super) 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); +} diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs new file mode 100644 index 00000000..6f35c1a6 --- /dev/null +++ b/src/ui/onboarding.rs @@ -0,0 +1,215 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::widgets::{ + action_button_row_rects, action_button_width, modal_stack_areas, panel_contrast_fg, + render_action_button, render_modal_header, render_modal_shell, ActionButtonSpec, +}; +use crate::app::AppState; + +const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; + +pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + super::dim_background(frame, area); + + match app.onboarding_step { + 0 => render_onboarding_welcome(app, frame, area), + _ => render_onboarding_notifications(app, frame, area), + } +} + +pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { + Rect::new( + area.x, + area.y, + action_button_width(Some("↵"), "continue"), + 1, + ) +} + +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), + ); +} diff --git a/src/ui/panes.rs b/src/ui/panes.rs new file mode 100644 index 00000000..7e1490db --- /dev/null +++ b/src/ui/panes.rs @@ -0,0 +1,252 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar}; +use super::widgets::panel_contrast_fg; +use crate::app::state::Palette; +use crate::app::{AppState, Mode}; +use crate::layout::PaneInfo; + +/// Compute pane layout info and optionally resize pane runtimes to match. +pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> 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 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 > 0) + { + scrollbar_rect = Some(Rect::new( + area.x + area.width.saturating_sub(1), + area.y, + 1, + area.height, + )); + } + if resize_panes { + 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 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 > 0) + { + scrollbar_rect = Some(Rect::new( + pane_inner.x + pane_inner.width.saturating_sub(1), + pane_inner.y, + 1, + pane_inner.height, + )); + } + if resize_panes { + rt.resize(inner_rect.height, inner_rect.width); + } + } + + info.inner_rect = inner_rect; + info.scrollbar_rect = scrollbar_rect; + } + + pane_infos +} + +pub(super) 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) { + 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); + } + + rt.render(frame, info.inner_rect, info.is_focused && terminal_active); + render_pane_scrollbar(app, frame, info, rt); + + 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)); + } + } + } + + 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), + _ => Color::DarkGray, + } +} + +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, + ); +} diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs new file mode 100644 index 00000000..346b06d5 --- /dev/null +++ b/src/ui/release_notes.rs @@ -0,0 +1,345 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Wrap}, + Frame, +}; + +use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar}; +use super::widgets::{ + action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, + render_modal_header, render_modal_shell, +}; +use crate::app::{ + state::{Palette, ReleaseNotesState}, + AppState, +}; + +pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24); + +pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(notes) = &app.release_notes else { + return; + }; + + super::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 { + "update ready" + } 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, ¬es.version, &app.palette); + } + + let body = Paragraph::new( + release_notes_display_lines(notes, &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, + } + } +} + +pub(super) 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 ready", title_style), + ]), + Line::from(vec![ + Span::styled("detach from this session, then run ", text_style), + Span::styled("herdr update", code_style), + Span::styled(" in your shell", 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_display_lines<'a>( + notes: &'a ReleaseNotesState, + p: &Palette, +) -> Vec<(usize, Line<'a>)> { + release_notes_lines(notes.body.as_str(), p) +} + +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) +} diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs new file mode 100644 index 00000000..34e739d0 --- /dev/null +++ b/src/ui/scrollbar.rs @@ -0,0 +1,191 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + Frame, +}; + +use crate::app::AppState; +use crate::layout::PaneInfo; + +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) +} + +pub(super) 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)); + } +} + +pub(super) 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, + ); +} diff --git a/src/ui/settings.rs b/src/ui/settings.rs new file mode 100644 index 00000000..1123f408 --- /dev/null +++ b/src/ui/settings.rs @@ -0,0 +1,238 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{List, ListItem, ListState, Paragraph, Tabs}, + Frame, +}; + +use super::widgets::{ + action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, + render_action_button, render_panel_shell, ActionButtonSpec, +}; +use crate::app::{state::Palette, AppState}; + +pub(super) 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; + }; + + super::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); + + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + " settings", + Style::default().fg(p.text).add_modifier(Modifier::BOLD), + )])), + header_rows[0], + ); + + 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]); + + let sep = "─".repeat(inner.width as usize); + frame.render_widget( + Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), + header_rows[2], + ); + + 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, + ); + } + } + + 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], + ); + } +} + +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]) +} + +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); +} + +fn render_settings_toggle( + frame: &mut Frame, + area: Rect, + p: &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); +} diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs new file mode 100644 index 00000000..36d1385a --- /dev/null +++ b/src/ui/sidebar.rs @@ -0,0 +1,940 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +use super::scrollbar::{render_scrollbar, should_show_scrollbar}; +use super::status::{agent_icon, state_dot, state_label, state_label_color}; +use crate::app::state::{AgentPanelScope, Palette}; +use crate::app::{AppState, Mode}; +use crate::detect::AgentState; + +const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; +const AGENT_PANEL_HEADER_ROWS: u16 = 3; + +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}…") +} + +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) + ) +} + +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 +} + +/// 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. +pub(super) 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) +} + +pub(super) 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) + }; + + 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); + + render_workspace_list(app, frame, ws_area, is_navigating); + render_agent_detail(app, frame, detail_area); + render_sidebar_toggle(app, frame, area, false, p); +} + +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, + }; + + 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 (icon, icon_style) = state_dot(agg_state, agg_seen, p); + let line1 = vec![ + Span::styled(" ", Style::default()), + Span::styled(icon, icon_style), + Span::styled(" ", Style::default()), + Span::styled(ws.display_name(), name_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, "▕"); + } + + 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, + ); + } +} + +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, +) { + 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); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{detect::Agent, workspace::Workspace}; + + #[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()); + } +} diff --git a/src/ui/status.rs b/src/ui/status.rs new file mode 100644 index 00000000..2eece8a0 --- /dev/null +++ b/src/ui/status.rs @@ -0,0 +1,135 @@ +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use super::widgets::panel_contrast_fg; +use crate::{ + app::state::{Palette, ToastKind, ToastNotification}, + detect::AgentState, +}; + +pub(super) 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); +} + +pub(super) 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, + ); +} + +pub(super) 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)), + } +} + +pub(super) 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, _) => (super::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)), + } +} + +pub(super) 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", + } +} + +pub(super) 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, + } +} diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs new file mode 100644 index 00000000..400ee65f --- /dev/null +++ b/src/ui/tabs.rs @@ -0,0 +1,356 @@ +use ratatui::{ + layout::Rect, + style::{Modifier, Style}, + widgets::Paragraph, + Frame, +}; + +use super::widgets::panel_contrast_fg; +use crate::app::AppState; + +const MIN_TAB_WIDTH: u16 = 8; +const NEW_TAB_WIDTH: u16 = 3; +const TAB_SCROLL_BUTTON_WIDTH: u16 = 3; + +#[derive(Debug, Clone, 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 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 +} + +pub(super) 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)); + } + } +} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 00000000..354b7884 --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1,209 @@ +use ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +use crate::app::state::Palette; + +pub(super) fn render_panel_shell( + frame: &mut Frame, + area: Rect, + border_color: Color, + bg: Color, +) -> Option<Rect> { + if area.width < 2 || area.height < 2 { + return None; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .border_set(ratatui::symbols::border::PLAIN) + .style(Style::default().bg(bg)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + Some(inner) +} + +pub(super) fn panel_contrast_fg(p: &Palette) -> Color { + match p.panel_bg { + Color::Reset => p.surface_dim, + color => color, + } +} + +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)) +} + +pub(super) 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) +} + +pub(super) 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, + } +} + +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 +} + +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(super) 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, + ); +} + +pub(super) 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() +} |
