diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/ui/status.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/ui/status.rs')
| -rw-r--r-- | src/ui/status.rs | 294 |
1 files changed, 42 insertions, 252 deletions
diff --git a/src/ui/status.rs b/src/ui/status.rs index 62646482..e47e0f90 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -1,17 +1,15 @@ use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, - Frame, + widgets::{Block, Borders, Clear, Paragraph, Widget}, }; -use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ - app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, - config::{StatusIndicatorStyle, ToastClipboardPosition, ToastHerdrPosition}, - detect::AgentState, + app::state::{CopyFeedback, Palette}, + config::ToastClipboardPosition, }; pub(crate) fn copy_feedback_rect( @@ -20,7 +18,7 @@ pub(crate) fn copy_feedback_rect( offset_rows: u16, position: ToastClipboardPosition, ) -> Rect { - if area.width == 0 || area.height == 0 { + if area.is_empty() { return Rect::default(); } @@ -49,129 +47,56 @@ pub(crate) fn copy_feedback_rect( Rect::new(x, y, width, height) } -pub(crate) fn toast_notification_rect( - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, -) -> Rect { - let content_width = display_width_u16(&toast.title) - .max(display_width_u16(&toast.context)) - .saturating_add(4); - let width = content_width.saturating_add(2).min(area.width); - let content_height = if toast.context.is_empty() { 1 } else { 2 }; - let height = (content_height + 2).min(area.height); - let x = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::BottomLeft => area.x, - ToastHerdrPosition::TopRight | ToastHerdrPosition::BottomRight => { - area.x + area.width.saturating_sub(width) - } - }; - let warning_offset = u16::from(offset_for_warning); - let y = match position { - ToastHerdrPosition::TopLeft | ToastHerdrPosition::TopRight => { - area.y + warning_offset.min(area.height) - } - ToastHerdrPosition::BottomLeft | ToastHerdrPosition::BottomRight => { - area.y + area.height.saturating_sub(height + warning_offset) - } - }; - Rect::new(x, y, width, height) -} - -pub(super) fn render_toast_notification( - frame: &mut Frame, - area: Rect, - toast: &ToastNotification, - offset_for_warning: bool, - position: ToastHerdrPosition, - p: &Palette, -) { - let dot_color = match toast.kind { - ToastKind::NeedsAttention => p.red, - ToastKind::Finished => p.blue, - ToastKind::UpdateInstalled => p.accent, - }; - let toast_area = toast_notification_rect(area, toast, offset_for_warning, position); - - 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 < 1 { - 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); - if !toast.context.is_empty() && inner.height >= 2 { - frame.render_widget(Paragraph::new(context), context_row); - } -} - -pub(super) fn render_copy_feedback( - frame: &mut Frame, +pub(crate) fn render_copy_feedback_buffer( + buffer: &mut Buffer, area: Rect, feedback: &CopyFeedback, offset_rows: u16, position: ToastClipboardPosition, - p: &Palette, + palette: &Palette, ) { let feedback_area = copy_feedback_rect(area, feedback, offset_rows, position); if feedback_area.is_empty() { return; } - frame.render_widget(Clear, feedback_area); + Clear.render(feedback_area, buffer); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(p.green)) - .style(Style::default().bg(p.panel_bg)); + .border_style(Style::default().fg(palette.green)) + .style(Style::default().bg(palette.panel_bg)); let inner = block.inner(feedback_area); - frame.render_widget(block, feedback_area); + block.render(feedback_area, buffer); if inner.height == 0 { return; } let text = Line::from(vec![ - Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)), + Span::styled("●", Style::default().fg(palette.green).bg(palette.panel_bg)), Span::raw(" "), Span::styled( &feedback.message, Style::default() - .fg(p.text) - .bg(p.panel_bg) + .fg(palette.text) + .bg(palette.panel_bg) .add_modifier(Modifier::BOLD), ), ]); - frame.render_widget(Paragraph::new(text), inner); + Paragraph::new(text).render(inner, buffer); } -pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { +pub(crate) fn render_config_diagnostic_buffer( + buffer: &mut Buffer, + area: Rect, + message: &str, + palette: &Palette, +) -> u16 { let style = Style::default() - .fg(panel_contrast_fg(p)) - .bg(p.yellow) + .fg(panel_contrast_fg(palette)) + .bg(palette.yellow) .add_modifier(Modifier::BOLD); + let mut rendered_rows = 0u16; for (row, line) in message .lines() @@ -181,176 +106,41 @@ pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: & { let text = format!(" {line} "); let width = (text.len() as u16).min(area.width); - let notif_area = Rect::new( + let diagnostic_area = Rect::new( area.x + area.width.saturating_sub(width), area.y + row as u16, width, 1, ); - frame.render_widget(Clear, notif_area); - frame.render_widget(Paragraph::new(Span::styled(text, style)), notif_area); + Clear.render(diagnostic_area, buffer); + Paragraph::new(Span::styled(text, style)).render(diagnostic_area, buffer); + rendered_rows = rendered_rows.saturating_add(1); } -} -pub(super) fn state_icon_symbol( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, -) -> &'static str { - match (indicator_style, state, seen) { - (StatusIndicatorStyle::Dots, AgentState::Blocked, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Working, _) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, false) => "●", - (StatusIndicatorStyle::Dots, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Dots, AgentState::Unknown, _) => "·", - (StatusIndicatorStyle::Symbols, AgentState::Blocked, _) => "×", - (StatusIndicatorStyle::Symbols, AgentState::Working, _) => "◐", - (StatusIndicatorStyle::Symbols, AgentState::Idle, false) => "✓", - (StatusIndicatorStyle::Symbols, AgentState::Idle, true) => "○", - (StatusIndicatorStyle::Symbols, AgentState::Unknown, _) => "·", - } -} - -pub(super) fn state_icon( - state: AgentState, - seen: bool, - indicator_style: StatusIndicatorStyle, - p: &Palette, -) -> (&'static str, Style) { - ( - state_icon_symbol(state, seen, indicator_style), - Style::default().fg(state_label_color(state, seen, p)), - ) -} - -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, - } + rendered_rows } #[cfg(test)] mod tests { use super::*; - use crate::config::{ToastClipboardPosition, ToastHerdrPosition}; - - fn toast() -> ToastNotification { - ToastNotification { - kind: ToastKind::Finished, - title: "done".to_string(), - context: "workspace".to_string(), - position: None, - target: None, - } - } - - fn feedback() -> CopyFeedback { - CopyFeedback { - message: "copied to clipboard".to_string(), - } - } - - #[test] - fn state_icons_support_dot_and_distinct_symbol_styles() { - let palette = Palette::catppuccin(); - for (indicator_style, expected_symbols) in [ - (StatusIndicatorStyle::Dots, ["●", "●", "●", "○", "·"]), - (StatusIndicatorStyle::Symbols, ["×", "◐", "✓", "○", "·"]), - ] { - for ((state, seen, color), expected_symbol) in [ - (AgentState::Blocked, true, palette.red), - (AgentState::Working, true, palette.yellow), - (AgentState::Idle, false, palette.teal), - (AgentState::Idle, true, palette.green), - (AgentState::Unknown, true, palette.overlay0), - ] - .into_iter() - .zip(expected_symbols) - { - let (actual_symbol, style) = state_icon(state, seen, indicator_style, &palette); - assert_eq!(actual_symbol, expected_symbol); - assert_eq!(display_width_u16(actual_symbol), 1); - assert_eq!(style.fg, Some(color)); - } - } - } - - #[test] - fn toast_rect_uses_configured_corner() { - let area = Rect::new(10, 20, 100, 40); - let toast = toast(); - - let top_left = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopLeft); - assert_eq!(top_left.x, area.x); - assert_eq!(top_left.y, area.y); - - let top_right = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - assert_eq!(top_right.x + top_right.width, area.x + area.width); - assert_eq!(top_right.y, area.y); - - let bottom_left = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomLeft); - assert_eq!(bottom_left.x, area.x); - assert_eq!(bottom_left.y + bottom_left.height, area.y + area.height); - - let bottom_right = - toast_notification_rect(area, &toast, false, ToastHerdrPosition::BottomRight); - assert_eq!(bottom_right.x + bottom_right.width, area.x + area.width); - assert_eq!(bottom_right.y + bottom_right.height, area.y + area.height); - } - - #[test] - fn toast_rect_uses_display_width_for_cjk_labels() { - let area = Rect::new(0, 0, 100, 20); - let toast = ToastNotification { - kind: ToastKind::NeedsAttention, - title: "重构用户认证模块".to_string(), - context: "提交 herdr 的反馈".to_string(), - position: None, - target: None, - }; - - let rect = toast_notification_rect(area, &toast, false, ToastHerdrPosition::TopRight); - - let expected_content_width = - display_width_u16(&toast.title).max(display_width_u16(&toast.context)) + 6; - assert_eq!(rect.width, expected_content_width); - assert_eq!(rect.x + rect.width, area.x + area.width); - } #[test] fn copy_feedback_rect_uses_configured_position() { let area = Rect::new(10, 20, 100, 40); - let feedback = feedback(); + let feedback = CopyFeedback { + message: "copied to clipboard".to_owned(), + }; - let top_center = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); - assert_eq!(top_center.y, area.y); - assert_eq!( - top_center.x, - area.x + area.width.saturating_sub(top_center.width) / 2 - ); + let top = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::TopCenter); + assert_eq!(top.y, area.y); + assert_eq!(top.x, area.x + area.width.saturating_sub(top.width) / 2); - let bottom_center = - copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); - assert_eq!(bottom_center.y + bottom_center.height, area.y + area.height); + let bottom = copy_feedback_rect(area, &feedback, 0, ToastClipboardPosition::BottomCenter); + assert_eq!(bottom.bottom(), area.bottom()); assert_eq!( - bottom_center.x, - area.x + area.width.saturating_sub(bottom_center.width) / 2 + bottom.x, + area.x + area.width.saturating_sub(bottom.width) / 2 ); } } |
