use ratatui::{ layout::{Constraint, Layout, Margin, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs, }, Frame, }; use tui_term::widget::PseudoTerminal; use crate::app::state::{ToastKind, ToastNotification}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; use crate::layout::PaneInfo; const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18; pub(crate) const MAX_SIDEBAR_WIDTH: u16 = 36; // Braille spinner frames — smooth rotation const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame. /// We want ~8 updates/sec so divide by 8. fn spinner_frame(tick: u32) -> &'static str { SPINNERS[(tick as usize / 8) % SPINNERS.len()] } use crate::app::state::Palette; /// Compute view geometry and reconcile pane sizes. /// Called before render to separate mutation from drawing. pub fn compute_view(app: &mut AppState, area: Rect) { let sidebar_w = if app.sidebar_collapsed { COLLAPSED_WIDTH } else if app.sidebar_width_auto { compute_sidebar_width(app) } else { app.sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) }; let [sidebar_area, main_area] = Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area); let terminal_area = main_area; // Compute split borders let split_borders = app .active .and_then(|i| app.workspaces.get(i)) .map(|ws| ws.layout.splits(terminal_area)) .unwrap_or_default(); // Compute pane layout + reconcile sizes let pane_infos = compute_pane_infos(app, terminal_area); app.view = crate::app::ViewState { sidebar_rect: sidebar_area, terminal_area, pane_infos, split_borders, }; } /// Render the UI — reads AppState but does not mutate it. pub fn render(app: &AppState, frame: &mut Frame) { let sidebar_area = app.view.sidebar_rect; let terminal_area = app.view.terminal_area; if app.sidebar_collapsed { render_sidebar_collapsed(app, frame, sidebar_area); } else { render_sidebar(app, frame, sidebar_area); } render_panes(app, frame, terminal_area); match app.mode { Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), Mode::Resize => render_resize_overlay(app, frame, terminal_area), Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area), Mode::ContextMenu => { render_navigate_overlay(app, frame, terminal_area); render_context_menu(app, frame); } Mode::Settings => render_settings_overlay(app, frame, frame.area()), Mode::RenameSession => {} Mode::Terminal => {} } // Notifications (rendered on top of everything) if let Some(version) = &app.update_available { if !app.update_dismissed { render_update_notification(frame, terminal_area, version, &app.palette); } } let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { render_config_diagnostic(frame, terminal_area, message, &app.palette); } if let Some(toast) = &app.toast { render_toast_notification( frame, terminal_area, toast, has_config_diagnostic, &app.palette, ); } } /// Compute pane layout info and resize pane runtimes to match. fn compute_pane_infos(app: &AppState, area: Rect) -> Vec { let Some(ws_idx) = app.active else { return Vec::new(); }; let Some(ws) = app.workspaces.get(ws_idx) else { return Vec::new(); }; if ws.zoomed { let focused_id = ws.layout.focused(); if let Some(rt) = ws.runtimes.get(&focused_id) { rt.resize(area.height, area.width); } return vec![PaneInfo { id: focused_id, rect: area, inner_rect: area, is_focused: true, }]; } let multi_pane = ws.layout.pane_count() > 1; let terminal_active = app.mode == Mode::Terminal; let mut pane_infos = ws.layout.panes(area); for info in &mut pane_infos { let 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 }; info.inner_rect = inner; if let Some(rt) = ws.runtimes.get(&info.id) { rt.resize(inner.height, inner.width); } } pane_infos } /// Auto-scale sidebar width based on workspace identity + agent summary. fn compute_sidebar_width(app: &AppState) -> u16 { if app.workspaces.is_empty() { return app .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); } let max_line = app .workspaces .iter() .enumerate() .map(|(i, ws)| { let name_len = ws.display_name().len(); let number_len = (i + 1).to_string().len(); // marker + number + space + name + spaces + aggregate dot let line1 = 3 + number_len + name_len + 3; // branch line: " branch" let line2 = ws.branch().map(|b| 3 + b.len()).unwrap_or(0); line1.max(line2) }) .max() .unwrap_or(12); ((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) } /// Collapsed sidebar: pure glance mode. fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu | Mode::Settings ); let p = &app.palette; let sep_style = if is_navigating { Style::default().fg(p.accent) } else { Style::default().fg(p.surface_dim) }; let sep_x = area.x + area.width.saturating_sub(1); let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { buf[(sep_x, y)].set_symbol("│"); buf[(sep_x, y)].set_style(sep_style); } let content_w = area.width.saturating_sub(1); let bottom_y = area.y + area.height.saturating_sub(1); for (i, ws) in app.workspaces.iter().enumerate() { let y = area.y + i as u16; if y >= bottom_y { break; } let (agg_state, agg_seen) = ws.aggregate_state(); let (icon, icon_style) = state_dot(agg_state, agg_seen, p); let is_selected = i == app.selected && is_navigating; let row_style = if is_selected { Style::default().bg(p.surface0) } else { Style::default() }; let num_style = if is_selected { Style::default().fg(p.overlay1).bg(p.surface0) } else { Style::default().fg(p.overlay0) }; if is_selected { let buf = frame.buffer_mut(); for x in area.x..area.x + content_w { buf[(x, y)].set_style(row_style); } } frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(format!("{}", i + 1), num_style), Span::styled(" ", row_style), Span::styled(icon, icon_style), ])), Rect::new(area.x, y, content_w, 1), ); } render_sidebar_toggle(frame, area, true, p); } fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let is_navigating = matches!( app.mode, Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu | Mode::Settings ); let sep_style = if is_navigating { Style::default().fg(p.accent) } else { Style::default().fg(p.surface_dim) }; // Right border let sep_x = area.x + area.width.saturating_sub(1); let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { buf[(sep_x, y)].set_symbol("│"); buf[(sep_x, y)].set_style(sep_style); } let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); // Determine which workspace to show in the detail panel let detail_ws_idx = if is_navigating { Some(app.selected) } else { app.active }; // Split sidebar in half: workspaces on top, agents on bottom let total_h = content.height as usize; let ws_h = (total_h + 1) / 2; // top half (ceiling) let detail_h = total_h.saturating_sub(ws_h); let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); let detail_area = Rect::new( content.x, content.y + ws_h as u16, content.width, detail_h as u16, ); // --- Top section: Workspaces --- render_workspace_list(app, frame, ws_area, is_navigating); // --- Bottom section: Agent detail --- if let Some(ws_idx) = detail_ws_idx { if let Some(ws) = app.workspaces.get(ws_idx) { render_agent_detail(app, frame, detail_area, ws); } } render_sidebar_toggle(frame, area, false, p); } /// Render the workspace list in the top section of the sidebar. fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) { let p = &app.palette; // Reserve last row for "new" button let list_bottom = area.y + area.height.saturating_sub(1); let mut row_y = area.y; for (i, ws) in app.workspaces.iter().enumerate() { if row_y + 1 >= list_bottom { break; } let selected = i == app.selected && is_navigating; let is_active = Some(i) == app.active; let highlighted = selected || is_active; // active always gets a bg let (agg_state, agg_seen) = ws.aggregate_state(); // Determine row height for background fill let has_second_line = ws.branch().is_some() || (app.mode == Mode::RenameSession && i == app.selected); let row_height: u16 = if has_second_line { 2 } else { 1 }; // Background fill: selected gets brighter surface, active gets subtle surface if highlighted { let bg = if selected { p.surface0 } else { p.surface_dim }; let buf = frame.buffer_mut(); for y in row_y..row_y + row_height { if y >= list_bottom { break; } for x in area.x..area.x + area.width { buf[(x, y)].set_style(Style::default().bg(bg)); } } } // Accent bar on the left edge for active workspace if is_active { let buf = frame.buffer_mut(); for y in row_y..row_y + row_height { if y >= list_bottom { break; } buf[(area.x, y)].set_symbol("▌"); buf[(area.x, y)].set_style(Style::default().fg(p.accent)); } } // Styles — active workspace is always visible, selected (nav mode) is brightest let name_style = if selected { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else if is_active { Style::default().fg(p.text).add_modifier(Modifier::BOLD) } else { Style::default().fg(p.subtext0) }; let num_style = if selected || is_active { Style::default().fg(p.overlay1) } else { Style::default().fg(p.overlay0) }; // Line 1: marker-space + number + name + aggregate state dot let marker = if is_active { " " } else { " " }; // accent bar handles active indicator let mut line1 = vec![ Span::styled(marker, Style::default()), Span::styled(format!("{} ", i + 1), num_style), Span::styled(ws.display_name(), name_style), Span::styled(" ", Style::default()), ]; let (icon, icon_style) = state_dot(agg_state, agg_seen, p); line1.push(Span::styled(icon, icon_style)); frame.render_widget( Paragraph::new(Line::from(line1)), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; // Line 2: branch or rename input if row_y < list_bottom { if app.mode == Mode::RenameSession && i == app.selected { let text = format!(" {}\u{2588}", app.name_input); frame.render_widget(Clear, Rect::new(area.x, row_y, area.width, 1)); frame.render_widget( Paragraph::new(text).style(Style::default().fg(p.yellow)), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; } else 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::() + parts.len() }) .unwrap_or(0); let max_branch_len = (area.width as usize).saturating_sub(5 + reserved); let branch_display = if branch.len() > max_branch_len { format!("{}…", &branch[..max_branch_len.saturating_sub(1)]) } else { branch }; let branch_color = if selected || is_active { p.mauve } else { p.overlay0 }; let mut spans = vec![ Span::styled(" ", Style::default()), Span::styled(branch_display, Style::default().fg(branch_color)), ]; if let Some(parts) = upstream_label { spans.push(Span::styled(" ", Style::default())); for (idx, (label, color)) in parts.into_iter().enumerate() { if idx > 0 { spans.push(Span::styled(" ", Style::default())); } spans.push(Span::styled(label, Style::default().fg(color))); } } let line2 = Line::from(spans); frame.render_widget( Paragraph::new(line2), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; } } // Spacing between workspace cards row_y += 1; } // "new" at the bottom of workspace section if list_bottom > area.y { frame.render_widget( Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))), Rect::new(area.x, list_bottom, area.width, 1), ); } } /// Render the agent detail panel in the bottom section of the sidebar. fn render_agent_detail( app: &AppState, frame: &mut Frame, area: Rect, ws: &crate::workspace::Workspace, ) { let p = &app.palette; if area.height < 3 { return; } let mut row_y = area.y; // Horizontal separator let sep_line = "─".repeat(area.width as usize); frame.render_widget( Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; // Section header frame.render_widget( Paragraph::new(Line::from(vec![Span::styled( " agents", Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), )])), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; // Blank line for breathing room row_y += 1; // Per-pane agent entries, sorted by urgency let details = ws.pane_details(); for detail in &details { if row_y >= area.y + area.height { break; } let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p); let label_color = state_label_color(detail.state, detail.seen, p); let label = state_label(detail.state, detail.seen); // Agent name in soft white, state label right-aligned in its color let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD); // Calculate padding to right-align state label let used = 3 + detail.label.len() + 1; // " icon name " let label_len = label.len(); let avail = (area.width as usize).saturating_sub(used + label_len); let padding = " ".repeat(avail); let line = Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(icon, icon_style), Span::styled(" ", Style::default()), Span::styled(&detail.label, name_style), Span::styled(padding, Style::default()), Span::styled( label, Style::default().fg(label_color).add_modifier(Modifier::DIM), ), ]); frame.render_widget( Paragraph::new(line), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; } } fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Palette) { // Toggle button not needed when sidebar has content — skip for now // to avoid conflicting with the agent detail panel at the bottom. if !collapsed { return; } let bottom_y = area.y + area.height.saturating_sub(1); let content_w = area.width.saturating_sub(1); if content_w == 0 || area.height == 0 { return; } let icon = "»"; let x = area.x + content_w / 2; let toggle_area = Rect::new(x, bottom_y, 1, 1); frame.render_widget( Paragraph::new(Span::styled(icon, Style::default().fg(p.overlay0))), toggle_area, ); } fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let Some(ws_idx) = app.active else { render_empty(app, frame, area); return; }; let Some(ws) = app.workspaces.get(ws_idx) else { render_empty(app, frame, area); return; }; let multi_pane = ws.layout.pane_count() > 1; let terminal_active = app.mode == Mode::Terminal; for info in &app.view.pane_infos { if let Some(rt) = ws.runtimes.get(&info.id) { // Draw borders for multi-pane layouts if multi_pane { let (border_style, border_set) = if info.is_focused && terminal_active { ( Style::default().fg(app.palette.accent), ratatui::symbols::border::THICK, ) } else if info.is_focused { ( Style::default().fg(app.palette.accent), ratatui::symbols::border::PLAIN, ) } else { ( Style::default().fg(app.palette.overlay0), ratatui::symbols::border::PLAIN, ) }; let block = Block::default() .borders(Borders::ALL) .border_style(border_style) .border_set(border_set); frame.render_widget(block, info.rect); } // Draw terminal content if let Ok(parser) = rt.parser.read() { let pt = PseudoTerminal::new(parser.screen()); frame.render_widget(pt, info.inner_rect); } render_pane_scrollbar(app, frame, info, rt); // Dim unfocused panes only in navigate mode let should_dim = !info.is_focused && multi_pane && !terminal_active; if should_dim { let inner = info.inner_rect; let buf = frame.buffer_mut(); for y in inner.y..inner.y + inner.height { for x in inner.x..inner.x + inner.width { let cell = &mut buf[(x, y)]; let style = cell.style(); let fg = style.fg.unwrap_or(Color::White); let dimmed_fg = dim_color(fg); cell.set_style(style.fg(dimmed_fg)); } } } // Selection highlight render_selection_highlight( &app.selection, frame, info.id, info.inner_rect, &app.palette, ); } } } /// Render selection highlight for a pane by inverting fg/bg colors. /// Reduce a color's brightness by blending it toward black. fn dim_color(color: Color) -> Color { match color { Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3), Color::White => Color::DarkGray, Color::Gray => Color::DarkGray, Color::DarkGray => Color::Rgb(30, 30, 30), Color::Red => Color::Rgb(60, 0, 0), Color::Green => Color::Rgb(0, 60, 0), Color::Yellow => Color::Rgb(60, 60, 0), Color::Blue => Color::Rgb(0, 0, 60), Color::Magenta => Color::Rgb(60, 0, 60), Color::Cyan => Color::Rgb(0, 60, 60), Color::LightRed => Color::Rgb(80, 30, 30), Color::LightGreen => Color::Rgb(30, 80, 30), Color::LightYellow => Color::Rgb(80, 80, 30), Color::LightBlue => Color::Rgb(30, 30, 80), Color::LightMagenta => Color::Rgb(80, 30, 80), Color::LightCyan => Color::Rgb(30, 80, 80), // Indexed colors and others: just use DIM modifier as fallback _ => Color::DarkGray, } } pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option { let inner = info.inner_rect; if inner.width == 0 || inner.height == 0 { return None; } Some(Rect::new( inner.x + inner.width.saturating_sub(1), inner.y, 1, inner.height, )) } pub(crate) fn scrollbar_offset_from_row( metrics: crate::pane::ScrollMetrics, track: Rect, row: u16, ) -> usize { if metrics.max_offset_from_bottom == 0 || track.height <= 1 { return metrics.max_offset_from_bottom; } let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1)); let row_offset = clamped_row.saturating_sub(track.y) as f32; let max_row = track.height.saturating_sub(1) as f32; let ratio = if max_row == 0.0 { 0.0 } else { row_offset / max_row }; let top_position = (ratio * metrics.max_offset_from_bottom as f32).round() as usize; metrics.max_offset_from_bottom.saturating_sub(top_position) } 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; }; if metrics.max_offset_from_bottom == 0 { return; } let content_length = metrics.max_offset_from_bottom + metrics.viewport_rows; let position = metrics .max_offset_from_bottom .saturating_sub(metrics.offset_from_bottom); let mut scrollbar_state = ScrollbarState::new(content_length) .position(position) .viewport_content_length(metrics.viewport_rows); 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, "▕") }; let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None) .track_symbol(Some("▕")) .track_style(Style::default().fg(track_color)) .thumb_symbol(thumb_symbol) .thumb_style(Style::default().fg(thumb_color)); frame.render_stateful_widget( scrollbar, track.inner(Margin { vertical: 0, horizontal: 0, }), &mut scrollbar_state, ); } fn render_selection_highlight( selection: &Option, frame: &mut Frame, pane_id: crate::layout::PaneId, inner: Rect, p: &Palette, ) { if let Some(sel) = selection { if sel.is_visible() && sel.pane_id == pane_id { let buf = frame.buffer_mut(); for y in 0..inner.height { for x in 0..inner.width { if sel.contains(y, x) { let cell = &mut buf[(inner.x + x, inner.y + y)]; cell.set_style(Style::default().fg(p.panel_bg).bg(p.blue)); } } } } } } fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let lines = vec![ Line::from(""), Line::from(""), Line::from(Span::styled( " No workspaces yet", Style::default().fg(p.overlay0), )), Line::from(""), Line::from(Span::styled( " A workspace is one project context.", Style::default().fg(p.overlay1), )), Line::from(Span::styled( " Its root pane (top-left) sets the default repo or folder name.", Style::default().fg(p.overlay1), )), Line::from(""), Line::from(vec![ Span::styled(" Press ", Style::default().fg(p.overlay0)), Span::styled( format!("{}", app.keybinds.new_workspace_label), Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(p.overlay0)), ]), ]; frame.render_widget( Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(p.surface_dim)), ), area, ); } const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; fn dim_background(frame: &mut Frame, area: Rect) { let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { let cell = &mut buf[(x, y)]; cell.set_style(cell.style().add_modifier(Modifier::DIM)); } } } fn render_panel_shell( frame: &mut Frame, area: Rect, border_color: Color, bg: Color, ) -> Option { if area.width < 2 || area.height < 2 { return None; } let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .border_set(ratatui::symbols::border::PLAIN) .style(Style::default().bg(bg)); let inner = block.inner(area); frame.render_widget(Clear, area); frame.render_widget(block, area); Some(inner) } fn render_modal_shell( frame: &mut Frame, area: Rect, popup_w: u16, popup_h: u16, p: &Palette, ) -> Option { 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; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); render_panel_shell(frame, popup, p.accent, p.panel_bg) } fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { frame.render_widget( Paragraph::new(Span::styled( format!(" {title} "), Style::default().fg(p.text).add_modifier(Modifier::BOLD), )), area, ); } fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { dim_background(frame, area); match app.onboarding_step { 0 => render_onboarding_welcome(app, frame, area), _ => render_onboarding_notifications(app, frame, area), } } fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else { return; }; if inner.height < 10 { return; } let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ]) .areas::<11>(inner); frame.render_widget( Paragraph::new(" herdr").style( Style::default() .fg(app.palette.text) .add_modifier(Modifier::BOLD), ), rows[1], ); frame.render_widget( Paragraph::new(" workspace manager for coding agents") .style(Style::default().fg(app.palette.overlay0)), rows[2], ); let line1 = Line::from(vec![ Span::styled( format!(" {}", ONBOARDING_PREFIX_LABEL), Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled(" navigate ", Style::default().fg(app.palette.overlay1)), Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled(" click sidebar ", Style::default().fg(app.palette.overlay1)), Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled(" scroll panes", Style::default().fg(app.palette.overlay1)), ]); frame.render_widget(Paragraph::new(line1), rows[4]); let line2 = Line::from(vec![ Span::styled( " ↑↓", Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled( " switch workspace ", Style::default().fg(app.palette.overlay1), ), Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled(" drag borders ", Style::default().fg(app.palette.overlay1)), Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled( " ⇥", Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled(" pane", Style::default().fg(app.palette.overlay1)), ]); frame.render_widget(Paragraph::new(line2), rows[5]); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" ● ", Style::default().fg(app.palette.red)), Span::styled("needs you ", Style::default().fg(app.palette.overlay1)), Span::styled("○ ", Style::default().fg(app.palette.yellow)), Span::styled("working ", Style::default().fg(app.palette.overlay1)), Span::styled("◌ ", Style::default().fg(app.palette.overlay0)), Span::styled("no agent", Style::default().fg(app.palette.overlay1)), ])), rows[7], ); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled( " continue ", Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled( " readme has config and more", Style::default().fg(app.palette.overlay0), ), ])), rows[9], ); } fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { let Some(inner) = render_modal_shell(frame, area, 52, 10, &app.palette) else { return; }; if inner.height < 7 { return; } let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .areas::<7>(inner); render_modal_header(frame, rows[0], "choose notification style", &app.palette); frame.render_widget( Paragraph::new(" herdr can alert you when background work needs attention or finishes.") .style(Style::default().fg(app.palette.overlay1)), rows[1], ); let options = [ "quiet no sound, no visual toasts", "visual only top-right toasts, no sound", "sound only sound alerts, no toasts", "both sound and visual toasts", ]; for (idx, option) in options.iter().enumerate() { let selected = idx == app.onboarding_selected; let prefix = if selected { "›" } else { " " }; let style = if selected { Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) } else { Style::default().fg(app.palette.text) }; frame.render_widget( Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), rows[idx + 2], ); } frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" [ back ] ", Style::default().fg(app.palette.overlay0)), Span::raw(" "), Span::styled( " [ save ] ", Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ), ])), rows[6], ); } /// Floating overlay for navigate mode — appears at bottom of terminal area. fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let key = Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD); let dim = Style::default().fg(app.palette.overlay0); let label = Style::default().fg(app.palette.text); let kb = &app.keybinds; let line1 = Line::from(vec![ Span::styled(format!(" {}", kb.new_workspace_label), key), Span::styled(" new ", dim), Span::styled(kb.rename_workspace_label.as_str(), key), Span::styled(" rename ", dim), Span::styled(kb.close_workspace_label.as_str(), key), Span::styled(" close ws ", 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 pane ", dim), Span::styled(kb.fullscreen_label.as_str(), key), Span::styled(" fullscreen", dim), ]); let ws_name = app .active .and_then(|i| app.workspaces.get(i)) .map(|ws| ws.display_name()) .unwrap_or_else(|| "—".to_string()); let pane_info = app .active .and_then(|i| app.workspaces.get(i)) .filter(|ws| ws.layout.pane_count() > 1) .map(|ws| { let ids = ws.layout.pane_ids(); let pos = ids .iter() .position(|id| *id == ws.layout.focused()) .unwrap_or(0); format!(" [{}/{}]", pos + 1, ids.len()) }) .unwrap_or_default(); let mode_style = Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD); let line2 = Line::from(vec![ Span::styled(" NAVIGATE ", mode_style), Span::raw(" "), Span::styled(ws_name, label), Span::styled(&pane_info, dim), 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.resize_mode_label.as_str(), key), Span::styled(" resize ", dim), Span::styled(kb.toggle_sidebar_label.as_str(), key), Span::styled(" sidebar ", dim), Span::styled("s", key), Span::styled(" settings ", dim), Span::styled("⏎", key), Span::styled(" open ", dim), Span::styled("q", key), Span::styled(" quit", dim), ]); let overlay_height = 2; let overlay_y = area.y + area.height.saturating_sub(overlay_height); let overlay_area = Rect::new(area.x, overlay_y, area.width, overlay_height); // Clear the area behind the overlay frame.render_widget(Clear, overlay_area); let bg = Style::default().bg(app.palette.panel_bg); let buf = frame.buffer_mut(); for y in overlay_area.y..overlay_area.y + overlay_area.height { for x in overlay_area.x..overlay_area.x + overlay_area.width { buf[(x, y)].set_style(bg); } } let [row1, row2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(overlay_area); frame.render_widget(Paragraph::new(line1), row1); frame.render_widget(Paragraph::new(line2), row2); } /// Floating overlay for resize mode. fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let key = Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD); let dim = Style::default().fg(app.palette.overlay0); let mode_style = Style::default() .fg(app.palette.panel_bg) .bg(app.palette.mauve) .add_modifier(Modifier::BOLD); let line = Line::from(vec![ Span::styled(" RESIZE ", mode_style), Span::raw(" "), Span::styled("h/l", key), Span::styled(" width ", dim), Span::styled("j/k", key), Span::styled(" height ", dim), Span::styled("esc", key), Span::styled(" done", dim), ]); let overlay_y = area.y + area.height.saturating_sub(1); let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); frame.render_widget(Clear, overlay_area); let bg = Style::default().bg(app.palette.panel_bg); let buf = frame.buffer_mut(); for x in overlay_area.x..overlay_area.x + overlay_area.width { buf[(x, overlay_y)].set_style(bg); } frame.render_widget(Paragraph::new(line), overlay_area); } /// Centered popup confirmation dialog with dimmed background. fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let ws_name = app .workspaces .get(app.selected) .map(|ws| ws.display_name()) .unwrap_or_else(|| "?".to_string()); let pane_count = app .workspaces .get(app.selected) .map(|ws| ws.layout.pane_count()) .unwrap_or(0); let pane_text = if pane_count == 1 { "1 pane".to_string() } else { format!("{pane_count} panes") }; // Dim the entire background let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { for x in area.x..area.x + area.width { let cell = &mut buf[(x, y)]; cell.set_style(cell.style().add_modifier(Modifier::DIM)); } } // Centered popup let popup_w = 44u16.min(area.width.saturating_sub(4)); let popup_h = 5u16; let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); 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); let confirm_selected = app.confirm_close_selected_confirm; frame.render_widget( Paragraph::new(" confirm ").style(if confirm_selected { Style::default() .fg(app.palette.panel_bg) .bg(app.palette.red) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) .add_modifier(Modifier::BOLD) }), confirm_rect, ); frame.render_widget( Paragraph::new(" cancel ").style(if confirm_selected { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) .add_modifier(Modifier::BOLD) }), cancel_rect, ); } } fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) -> Vec { let total_w = widths .iter() .copied() .sum::() .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() } fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { let rects = centered_button_row(inner, &[9, 8], 2, 2); (rects[0], rects[1]) } fn settings_button_rects(inner: Rect) -> (Rect, Rect) { let rects = centered_button_row(inner, &[7, 7], 2, inner.height.saturating_sub(1)); (rects[0], rects[1]) } /// Right-click context menu popup anchored near the click position. // --------------------------------------------------------------------------- // Settings overlay // --------------------------------------------------------------------------- fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { use crate::app::state::SettingsSection; let p = &app.palette; let popup_w: u16 = 56; let popup_h: u16 = 20; 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 < 20 || popup_h < 10 { return; } let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); // Dim everything behind the modal dim_background(frame, area); let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else { return; }; if inner.height < 4 || inner.width < 10 { return; } let mut y = inner.y; // Title frame.render_widget( Paragraph::new(Line::from(vec![Span::styled( " settings", Style::default().fg(p.text).add_modifier(Modifier::BOLD), )])), Rect::new(inner.x, y, inner.width, 1), ); y += 1; // Tab bar let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label())) .select( SettingsSection::ALL .iter() .position(|section| *section == app.settings.section) .unwrap_or(0), ) .style(Style::default().fg(p.overlay1)) .highlight_style( Style::default() .fg(p.panel_bg) .bg(p.accent) .add_modifier(Modifier::BOLD), ) .divider(" ") .padding(" ", " "); frame.render_widget(tabs, Rect::new(inner.x, y, inner.width, 1)); y += 1; // Separator let sep = "─".repeat(inner.width as usize); frame.render_widget( Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), Rect::new(inner.x, y, inner.width, 1), ); y += 1; // Section content let content_area = Rect::new(inner.x, y, inner.width, inner.y + inner.height - y); match app.settings.section { SettingsSection::Theme => { render_settings_theme(app, frame, content_area); } SettingsSection::Sound => { render_settings_toggle( frame, content_area, p, "sound alerts", "play sounds when agents change state in background", app.sound_enabled(), app.settings.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.selected, ); } } // Footer buttons + hints let footer_y = inner.y + inner.height - 1; if footer_y > y { let (apply_rect, close_rect) = settings_button_rects(inner); frame.render_widget( Paragraph::new(" apply ").style( Style::default() .fg(p.panel_bg) .bg(p.accent) .add_modifier(Modifier::BOLD), ), apply_rect, ); frame.render_widget( Paragraph::new(" close ").style( Style::default() .fg(p.text) .bg(p.surface0) .add_modifier(Modifier::BOLD), ), close_rect, ); let hint_area = Rect::new(inner.x, footer_y.saturating_sub(1), inner.width, 1); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" ↑↓", Style::default().fg(p.overlay0)), Span::styled(" select ", Style::default().fg(p.overlay1)), Span::styled("tab", Style::default().fg(p.overlay0)), Span::styled(" section", Style::default().fg(p.overlay1)), ])), hint_area, ); } } /// Render the theme picker list inside the settings panel. fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { use crate::app::state::THEME_NAMES; let p = &app.palette; let items: Vec = 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.selected)); frame.render_stateful_widget(list, area, &mut state); } /// Reusable toggle widget for boolean settings (sound, toast). fn render_settings_toggle( frame: &mut Frame, area: Rect, p: &crate::app::state::Palette, title: &str, description: &str, current_value: bool, selected_idx: usize, ) { let [desc_area, _, list_area] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Min(2), ]) .areas::<3>(area); let max_desc_len = (desc_area.width as usize).saturating_sub(2); let desc_text = if description.len() > max_desc_len { format!(" {}…", &description[..max_desc_len.saturating_sub(2)]) } else { format!(" {description}") }; frame.render_widget( Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))), desc_area, ); let items: Vec = ["on", "off"] .into_iter() .map(|label| { let is_active = (label == "on") == current_value; let marker = if is_active { " ✓" } else { "" }; ListItem::new(Line::from(vec![ Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)), Span::styled(marker, Style::default().fg(p.green)), ])) }) .collect(); let list = List::new(items) .highlight_style( Style::default() .bg(p.surface0) .fg(p.text) .add_modifier(Modifier::BOLD), ) .highlight_symbol(" ▸ "); let mut state = ListState::default().with_selected(Some(selected_idx.min(1))); frame.render_stateful_widget(list, list_area, &mut state); } fn render_context_menu(app: &AppState, frame: &mut Frame) { let Some(menu) = &app.context_menu else { return; }; let p = &app.palette; let Some(menu_rect) = app.context_menu_rect() else { return; }; let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else { return; }; let items: Vec = menu .items() .iter() .map(|item| ListItem::new(Line::from(*item))) .collect(); let list = List::new(items) .style(Style::default().fg(p.text)) .highlight_style( Style::default() .bg(p.accent) .fg(p.panel_bg) .add_modifier(Modifier::BOLD), ) .highlight_symbol(" "); let mut state = ListState::default().with_selected(Some(menu.selected)); frame.render_stateful_widget(list, inner, &mut state); } fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, p: &Palette) { let text = format!(" ✦ herdr v{version} installed — restart to update "); let width = text.len() as u16 + 2; let x = area.x + area.width.saturating_sub(width) / 2; let y = area.y + area.height.saturating_sub(3); let notif_area = Rect::new(x, y, width.min(area.width), 1); frame.render_widget(Clear, notif_area); frame.render_widget( Paragraph::new(Span::styled( text, Style::default() .fg(p.panel_bg) .bg(p.accent) .add_modifier(Modifier::BOLD), )), notif_area, ); } 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, }; 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 + if offset_for_warning { 1 } else { 0 }; let toast_area = Rect::new(x, y, width, height); frame.render_widget(Clear, toast_area); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(p.overlay0)) .style(Style::default().bg(p.panel_bg)); let inner = block.inner(toast_area); frame.render_widget(block, toast_area); if inner.height < 2 { return; } let [title_row, context_row] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); let title = Line::from(vec![ Span::styled("●", Style::default().fg(dot_color)), Span::raw(" "), Span::styled( &toast.title, Style::default().fg(p.text).add_modifier(Modifier::BOLD), ), ]); let context = Line::from(vec![ Span::styled(" ", Style::default().fg(p.overlay0)), Span::styled(&toast.context, Style::default().fg(p.overlay0)), ]); frame.render_widget(Paragraph::new(title), title_row); frame.render_widget(Paragraph::new(context), context_row); } /// Visual badge for a pane's state + seen flag. /// /// | State | Icon | Color | /// |--------------------|------|--------| /// | Blocked | ● | Red | /// | Working | ● | Yellow | /// | Done (idle+unseen) | ● | Blue | /// | Idle (seen) | ○ | Green | /// | Unknown | · | Gray | /// /// Filled dot = needs attention (blocked/working, or finished unseen). /// Hollow dot = nothing to do here. fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { let text = format!(" config warning: {message} "); let width = text.len() as u16 + 2; let notif_area = Rect::new( area.x + area.width.saturating_sub(width.min(area.width)), area.y, width.min(area.width), 1, ); frame.render_widget(Clear, notif_area); frame.render_widget( Paragraph::new(Span::styled( text, Style::default() .fg(p.panel_bg) .bg(p.yellow) .add_modifier(Modifier::BOLD), )), notif_area, ); } /// Compact dot icon for workspace-level aggregate state (top section). fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) { match (state, seen) { (AgentState::Blocked, _) => ("●", Style::default().fg(p.red)), (AgentState::Working, _) => ("●", Style::default().fg(p.yellow)), (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), (AgentState::Idle, true) => ("○", Style::default().fg(p.green)), (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)), } } /// Rich icon for per-pane agent detail (bottom section). /// Uses animated spinner for the working state. fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) { match (state, seen) { (AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)), (AgentState::Working, _) => (spinner_frame(tick), Style::default().fg(p.yellow)), (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)), (AgentState::Idle, true) => ("✓", Style::default().fg(p.green)), (AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)), } } /// State label for the agent detail panel. fn state_label(state: AgentState, seen: bool) -> &'static str { match (state, seen) { (AgentState::Blocked, _) => "blocked", (AgentState::Working, _) => "working", (AgentState::Idle, false) => "done", (AgentState::Idle, true) => "idle", (AgentState::Unknown, _) => "idle", } } /// Color for the state label text. fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color { match (state, seen) { (AgentState::Blocked, _) => p.red, (AgentState::Working, _) => p.yellow, (AgentState::Idle, false) => p.teal, (AgentState::Idle, true) => p.green, (AgentState::Unknown, _) => p.overlay0, } } fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec> { let mut spans = Vec::new(); spans.push(Span::raw(" ")); for (i, (k, desc)) in items.iter().enumerate() { if i > 0 { spans.push(Span::styled(" ", dim_style)); } spans.push(Span::styled(k.to_string(), key_style)); spans.push(Span::styled(format!(" {desc}"), dim_style)); } spans } #[cfg(test)] mod tests { use super::*; #[test] fn pane_scrollbar_rect_uses_rightmost_inner_column() { let info = PaneInfo { id: crate::layout::PaneId::from_raw(1), rect: Rect::new(0, 0, 12, 8), inner_rect: Rect::new(1, 1, 10, 6), is_focused: true, }; assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6))); } #[test] fn scrollbar_offset_mapping_hits_top_middle_and_bottom() { let metrics = crate::pane::ScrollMetrics { offset_from_bottom: 0, max_offset_from_bottom: 20, viewport_rows: 5, }; let track = Rect::new(9, 4, 1, 5); assert_eq!(scrollbar_offset_from_row(metrics, track, 4), 20); assert_eq!(scrollbar_offset_from_row(metrics, track, 6), 10); assert_eq!(scrollbar_offset_from_row(metrics, track, 8), 0); } }