diff options
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() +} |
