diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-05 18:32:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-05 18:32:42 +0300 |
| commit | f7b0ee2f758440db3a3ff4cc727f879b420255d7 (patch) | |
| tree | 764149deada51f6196f3eecda46374ec45ff097a /src/ui | |
| parent | 6885c8873971d79332376137a14572f8440ef42e (diff) | |
feat: add mobile terminal layout
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/mobile.rs | 884 |
1 files changed, 884 insertions, 0 deletions
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs new file mode 100644 index 00000000..79f97136 --- /dev/null +++ b/src/ui/mobile.rs @@ -0,0 +1,884 @@ +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, + Frame, +}; + +use super::sidebar::agent_panel_entries; +use super::status::{agent_icon, state_dot}; +use crate::app::state::{Palette, ToastKind, ToastNotification}; +use crate::app::AppState; +use crate::detect::AgentState; +use crate::layout::PaneId; + +pub(crate) const MOBILE_WIDTH_THRESHOLD: u16 = 64; +const SWITCH_BUTTON_WIDTH: u16 = 10; + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MobileHeaderHitAreas { + pub menu: Rect, +} + +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct MobileSwitcherAreas { + pub close: Rect, + pub viewport: Rect, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MobileSwitcherTarget { + NewWorkspace, + Workspace(usize), + NewTab, + Tab(usize), + Agent { + ws_idx: usize, + tab_idx: usize, + pane_id: PaneId, + }, + Menu(usize), +} + +pub(crate) fn is_mobile_width(area: Rect) -> bool { + area.width > 0 && area.width <= MOBILE_WIDTH_THRESHOLD +} + +pub(crate) fn compute_mobile_header_hit_areas(_app: &AppState, area: Rect) -> MobileHeaderHitAreas { + if area.width == 0 || area.height == 0 { + return MobileHeaderHitAreas::default(); + } + + let width = SWITCH_BUTTON_WIDTH.min(area.width); + let switch = Rect::new( + area.x + area.width.saturating_sub(width), + area.y, + width, + area.height, + ); + + MobileHeaderHitAreas { menu: switch } +} + +pub(crate) fn mobile_switcher_areas(app: &AppState) -> MobileSwitcherAreas { + let screen = mobile_screen_rect(app); + if screen.width == 0 || screen.height <= 2 { + return MobileSwitcherAreas::default(); + } + + let header_h = screen.height.min(2); + let close_w = 10u16.min(screen.width); + let close = Rect::new( + screen.x + screen.width.saturating_sub(close_w), + screen.y, + close_w, + header_h, + ); + let viewport = Rect::new( + screen.x, + screen.y + header_h + 1, + screen.width, + screen.height.saturating_sub(header_h + 1), + ); + + MobileSwitcherAreas { close, viewport } +} + +pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_height: u16) -> usize { + mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) +} + +pub(crate) fn mobile_switcher_workspace_doc_range(idx: usize) -> std::ops::Range<usize> { + let start = 2 + idx * 2; + start..start + 2 +} + +pub(crate) fn mobile_switcher_max_scroll(app: &AppState) -> usize { + mobile_switcher_max_scroll_for_height(app, mobile_switcher_areas(app).viewport.height) +} + +pub(crate) fn mobile_switcher_target_at( + app: &AppState, + col: u16, + row: u16, +) -> Option<MobileSwitcherTarget> { + let areas = mobile_switcher_areas(app); + let content = inset_for_left_scrollbar(areas.viewport); + if !rect_contains(content, col, row) { + return None; + } + + let doc_row = app + .mobile_switcher_scroll + .saturating_add(row.saturating_sub(areas.viewport.y) as usize); + let mut cursor = 0usize; + + cursor += 1; // spaces title + if doc_row == cursor { + return Some(MobileSwitcherTarget::NewWorkspace); + } + cursor += 1; + let spaces_end = cursor + app.workspaces.len() * 2; + if doc_row >= cursor && doc_row < spaces_end { + return Some(MobileSwitcherTarget::Workspace((doc_row - cursor) / 2)); + } + cursor = spaces_end; + + if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { + cursor += 1; // tabs title + if doc_row == cursor { + return Some(MobileSwitcherTarget::NewTab); + } + cursor += 1; + let tabs_end = cursor + ws.tabs.len(); + if doc_row >= cursor && doc_row < tabs_end { + return Some(MobileSwitcherTarget::Tab(doc_row - cursor)); + } + cursor = tabs_end; + } + + cursor += 1; // agents title + let agents = agent_panel_entries(app); + let agents_end = cursor + agents.len() * 2; + if doc_row >= cursor && doc_row < agents_end { + let idx = (doc_row - cursor) / 2; + return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { + ws_idx: entry.ws_idx, + tab_idx: entry.tab_idx, + pane_id: entry.pane_id, + }); + } + cursor = agents_end; + + cursor += 1; // menu title + let menu_idx = doc_row.checked_sub(cursor)?; + (menu_idx < app.global_menu_labels().len()).then_some(MobileSwitcherTarget::Menu(menu_idx)) +} + +pub(crate) fn render_mobile_header(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.panel_bg)); + + let switch = app.view.mobile_menu_hit_area; + let status_w = switch.x.saturating_sub(area.x).saturating_sub(1); + let status = Rect::new(area.x, area.y, status_w, area.height); + + render_header_status(app, frame, status); + render_switch_button(app, frame, switch); +} + +pub(crate) fn render_mobile_toast_banner( + frame: &mut Frame, + area: Rect, + toast: &ToastNotification, + offset_for_warning: bool, + p: &Palette, +) { + if area.width == 0 || area.height == 0 { + return; + } + + let dot_color = match toast.kind { + ToastKind::NeedsAttention => p.red, + ToastKind::Finished => p.blue, + ToastKind::UpdateInstalled => p.accent, + }; + let y = area.y + + area + .height + .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); + let banner = Rect::new(area.x, y, area.width, 1); + let bg = p.surface0; + + frame.render_widget(Clear, banner); + fill_rect(frame, banner, Style::default().bg(bg)); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled("●", Style::default().fg(dot_color).bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + mobile_toast_title(toast), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(p.overlay0).bg(bg)), + Span::styled(&toast.context, Style::default().fg(p.overlay0).bg(bg)), + ])), + banner, + ); +} + +pub(crate) fn render_mobile_panel(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + + let p = &app.palette; + frame.render_widget(Clear, area); + fill_rect(frame, area, Style::default().bg(p.panel_bg)); + + let areas = mobile_switcher_areas(app); + frame.render_widget( + Paragraph::new(" switch").style( + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + Rect::new(area.x, area.y, areas.close.x.saturating_sub(area.x), 1), + ); + render_close_button(app, frame, areas.close); + + if area.height > areas.close.height { + draw_horizontal_rule( + frame, + Rect::new(area.x, area.y + areas.close.height, area.width, 1), + p, + ); + } + + render_mobile_switcher_content(app, frame, areas.viewport); +} + +fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { + frame.render_widget(Paragraph::new(" no workspace"), area); + return; + }; + + let (state, seen) = ws.aggregate_state(); + let (dot, dot_style) = if matches!(state, AgentState::Working) { + ( + super::spinner_frame(app.spinner_tick), + Style::default().fg(p.yellow), + ) + } else { + state_dot(state, seen, p) + }; + let tab_label = format!("tab {}/{}", ws.active_tab + 1, ws.tabs.len()); + let row1 = Rect::new(area.x, area.y, area.width, 1); + let tab_w = (tab_label.chars().count() as u16 + 1).min(area.width); + let name_w = area.width.saturating_sub(tab_w); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled(dot, dot_style.bg(p.panel_bg)), + Span::raw(" "), + Span::styled( + truncate(&ws.display_name(), name_w.saturating_sub(4) as usize), + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + ])), + Rect::new(row1.x, row1.y, name_w, 1), + ); + frame.render_widget( + Paragraph::new(tab_label) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)) + .alignment(Alignment::Right), + Rect::new(row1.x + name_w, row1.y, tab_w, 1), + ); + + if area.height > 1 { + frame.render_widget( + Paragraph::new(agent_priority_label(app)) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), + Rect::new(area.x, area.y + 1, area.width, 1), + ); + } +} + +fn render_switch_button(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.surface0)); + for y in area.y..area.y + area.height { + frame.buffer_mut()[(area.x, y)] + .set_symbol("│") + .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); + } + let label_y = if area.height > 1 { area.y + 1 } else { area.y }; + frame.render_widget( + Paragraph::new("switch") + .style( + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, label_y, area.width.saturating_sub(1), 1), + ); +} + +fn render_close_button(app: &AppState, frame: &mut Frame, area: Rect) { + if area.width == 0 || area.height == 0 { + return; + } + let p = &app.palette; + fill_rect(frame, area, Style::default().bg(p.surface0)); + for y in area.y..area.y + area.height { + frame.buffer_mut()[(area.x, y)] + .set_symbol("│") + .set_style(Style::default().fg(p.surface_dim).bg(p.surface0)); + } + frame.render_widget( + Paragraph::new("close") + .style( + Style::default() + .fg(p.overlay1) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, area.y, area.width.saturating_sub(1), 1), + ); + if area.height > 1 { + frame.render_widget( + Paragraph::new("×") + .style( + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + Rect::new(area.x + 1, area.y + 1, area.width.saturating_sub(1), 1), + ); + } +} + +fn mobile_switcher_content_height(app: &AppState) -> usize { + let spaces_h = 2 + app.workspaces.len() * 2; + let tabs_h = app + .active + .and_then(|idx| app.workspaces.get(idx)) + .map(|ws| 2 + ws.tabs.len()) + .unwrap_or(0); + let agents_h = 1 + agent_panel_entries(app).len() * 2; + let menu_h = 1 + app.global_menu_labels().len(); + spaces_h + tabs_h + agents_h + menu_h +} + +fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: Rect) { + if viewport.width == 0 || viewport.height == 0 { + return; + } + + let p = &app.palette; + let total_height = mobile_switcher_content_height(app); + render_left_scrollbar( + frame, + viewport, + total_height, + viewport.height as usize, + app.mobile_switcher_scroll, + p, + ); + let content = inset_for_left_scrollbar(viewport); + if content == Rect::default() { + return; + } + + let mut doc_y = 0usize; + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "spaces", + p, + ); + doc_y += 1; + render_action_row_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "+ new workspace", + p, + ); + doc_y += 1; + for (idx, ws) in app.workspaces.iter().enumerate() { + let active = Some(idx) == app.active; + let selected = idx == app.selected; + let bg = mobile_item_bg(selected, active, p); + let (state, seen) = ws.aggregate_state(); + let (dot, dot_style) = state_dot(state, seen, p); + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(dot, dot_style.bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate(&ws.display_name(), content.width.saturating_sub(5) as usize), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + let detail = format!( + " {} · tab {}/{}", + ws.branch().unwrap_or_else(|| "shell".into()), + ws.active_tab + 1, + ws.tabs.len() + ); + render_two_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + truncate(&detail, content.width as usize), + p.overlay0, + ); + doc_y += 2; + } + + if let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) { + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "tabs", + p, + ); + doc_y += 1; + render_action_row_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "+ new tab", + p, + ); + doc_y += 1; + for (idx, tab) in ws.tabs.iter().enumerate() { + let active = idx == ws.active_tab; + let bg = mobile_item_bg(false, active, p); + let label = if tab.is_auto_named() { + format!("tab {}", idx + 1) + } else { + format!("{} · {}", idx + 1, tab.display_name()) + }; + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate(&label, content.width.saturating_sub(3) as usize), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + render_one_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + ); + doc_y += 1; + } + } + + let focused_agent = app.active.and_then(|ws_idx| { + let ws = app.workspaces.get(ws_idx)?; + ws.focused_pane_id() + .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) + }); + let entries = agent_panel_entries(app); + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "agents", + p, + ); + doc_y += 1; + for entry in &entries { + let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { + entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id + }); + let bg = mobile_item_bg(false, active, p); + let (icon, icon_style) = agent_icon(entry.state, entry.seen, app.spinner_tick, p); + let title = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(icon, icon_style.bg(bg)), + Span::styled(" ", Style::default().bg(bg)), + Span::styled( + truncate( + &entry.primary_label, + content.width.saturating_sub(5) as usize, + ), + Style::default() + .fg(p.text) + .bg(bg) + .add_modifier(Modifier::BOLD), + ), + ]); + let detail = format!( + " {} · {}", + super::status::state_label(entry.state, entry.seen), + entry.agent_label.as_deref().unwrap_or("agent") + ); + render_two_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + bg, + title, + truncate(&detail, content.width as usize), + p.overlay0, + ); + doc_y += 2; + } + + render_section_title_at( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + "menu", + p, + ); + doc_y += 1; + for label in app.global_menu_labels() { + if let Some(y) = visible_y(viewport, app.mobile_switcher_scroll, doc_y) { + frame.render_widget( + Paragraph::new(format!(" {label}")) + .style(Style::default().fg(p.overlay1).bg(p.panel_bg)), + Rect::new(content.x, y, content.width, 1), + ); + } + doc_y += 1; + } +} + +fn render_section_title_at( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + title: &str, + p: &Palette, +) { + let Some(y) = visible_y(viewport, scroll, doc_y) else { + return; + }; + render_section_title( + frame, + Rect::new(content.x, y, content.width.saturating_sub(1), 1), + title, + p, + ); +} + +fn render_action_row_at( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + label: &str, + p: &Palette, +) { + let Some(y) = visible_y(viewport, scroll, doc_y) else { + return; + }; + render_action_row(frame, Rect::new(content.x, y, content.width, 1), label, p); +} + +fn render_one_line_item( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + bg: ratatui::style::Color, + title: Line<'_>, +) { + fill_visible_doc_rect( + frame, + viewport, + content, + doc_y, + 1, + Style::default().bg(bg), + scroll, + ); + if let Some(y) = visible_y(viewport, scroll, doc_y) { + frame.render_widget( + Paragraph::new(title), + Rect::new(content.x, y, content.width, 1), + ); + } +} + +fn render_two_line_item( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + scroll: usize, + bg: ratatui::style::Color, + title: Line<'_>, + detail: String, + detail_fg: ratatui::style::Color, +) { + fill_visible_doc_rect( + frame, + viewport, + content, + doc_y, + 2, + Style::default().bg(bg), + scroll, + ); + if let Some(y) = visible_y(viewport, scroll, doc_y) { + frame.render_widget( + Paragraph::new(title), + Rect::new(content.x, y, content.width, 1), + ); + } + if let Some(y) = visible_y(viewport, scroll, doc_y + 1) { + frame.render_widget( + Paragraph::new(detail).style(Style::default().fg(detail_fg).bg(bg)), + Rect::new(content.x, y, content.width, 1), + ); + } +} + +fn visible_y(viewport: Rect, scroll: usize, doc_y: usize) -> Option<u16> { + let offset = doc_y.checked_sub(scroll)?; + (offset < viewport.height as usize).then_some(viewport.y + offset as u16) +} + +fn fill_visible_doc_rect( + frame: &mut Frame, + viewport: Rect, + content: Rect, + doc_y: usize, + height: usize, + style: Style, + scroll: usize, +) { + for offset in 0..height { + if let Some(y) = visible_y(viewport, scroll, doc_y + offset) { + fill_rect(frame, Rect::new(content.x, y, content.width, 1), style); + } + } +} + +fn mobile_item_bg(selected: bool, active: bool, p: &Palette) -> ratatui::style::Color { + if selected { + p.surface0 + } else if active { + p.surface_dim + } else { + p.panel_bg + } +} + +fn inset_for_left_scrollbar(area: Rect) -> Rect { + if area.width <= 1 { + return Rect::default(); + } + Rect::new(area.x + 1, area.y, area.width - 1, area.height) +} + +fn render_left_scrollbar( + frame: &mut Frame, + area: Rect, + total_rows: usize, + visible_rows: usize, + scroll: usize, + p: &Palette, +) { + if area.width == 0 || area.height == 0 || visible_rows == 0 || total_rows <= visible_rows { + return; + } + + let track = Rect::new(area.x, area.y, 1, area.height); + let max_scroll = total_rows.saturating_sub(visible_rows); + let thumb_len = ((track.height as usize * visible_rows).div_ceil(total_rows)) + .max(1) + .min(track.height as usize) as u16; + let travel = track.height.saturating_sub(thumb_len); + let thumb_top = if max_scroll == 0 { + track.y + } else { + track.y + ((travel as usize * scroll.min(max_scroll)) / max_scroll) as u16 + }; + + for y in track.y..track.y + track.height { + let is_thumb = y >= thumb_top && y < thumb_top + thumb_len; + frame.buffer_mut()[(track.x, y)] + .set_symbol(if is_thumb { "▌" } else { "│" }) + .set_style( + Style::default() + .fg(if is_thumb { p.accent } else { p.surface_dim }) + .bg(p.panel_bg), + ); + } +} + +fn render_section_title(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { + frame.render_widget( + Paragraph::new(format!(" {title} ")).style( + Style::default() + .fg(p.overlay1) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ), + Rect::new(area.x, area.y, area.width, 1), + ); +} + +fn render_action_row(frame: &mut Frame, area: Rect, label: &str, p: &Palette) { + if area.width == 0 || area.height == 0 { + return; + } + frame.render_widget( + Paragraph::new(format!(" {label}")).style( + Style::default() + .fg(p.accent) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + rect.width > 0 + && rect.height > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height +} + +fn mobile_screen_rect(app: &AppState) -> Rect { + let header = app.view.mobile_header_rect; + let terminal = app.view.terminal_area; + let x = header.x.min(terminal.x); + let y = header.y.min(terminal.y); + let right = (header.x + header.width).max(terminal.x + terminal.width); + let bottom = (header.y + header.height).max(terminal.y + terminal.height); + Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y)) +} + +fn agent_priority_label(app: &AppState) -> String { + let Some(ws) = app.active.and_then(|idx| app.workspaces.get(idx)) else { + return " no agents".to_string(); + }; + let mut blocked = 0usize; + let mut working = 0usize; + let mut done = 0usize; + for detail in ws.pane_details() { + match (detail.state, detail.seen) { + (AgentState::Blocked, _) => blocked += 1, + (AgentState::Working, _) => working += 1, + (AgentState::Idle, false) => done += 1, + _ => {} + } + } + if blocked > 0 { + format!(" ◉ {blocked} blocked") + } else if working > 0 { + format!(" {working} working") + } else if done > 0 { + format!(" {done} done") + } else { + " all idle".to_string() + } +} + +fn mobile_toast_title(toast: &ToastNotification) -> String { + match toast.kind { + ToastKind::NeedsAttention => toast + .title + .strip_suffix(" needs attention") + .map(|agent| format!("{agent} waiting")) + .unwrap_or_else(|| toast.title.clone()), + ToastKind::Finished => toast + .title + .strip_suffix(" finished") + .map(|agent| format!("{agent} done")) + .unwrap_or_else(|| toast.title.clone()), + ToastKind::UpdateInstalled => "update ready".to_string(), + } +} + +fn fill_rect(frame: &mut Frame, area: Rect, style: Style) { + if area.width == 0 || area.height == 0 { + return; + } + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + buf[(x, y)].set_symbol(" "); + buf[(x, y)].set_style(style); + } + } +} + +fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { + if area.width == 0 || area.height == 0 { + return; + } + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, area.y)] + .set_symbol("─") + .set_style(Style::default().fg(p.surface_dim).bg(p.panel_bg)); + } +} + +fn truncate(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}…") +} |
