From b44ca3b39ea4e9d90cc85dfc4a749ceddc09d19e Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 26 Jun 2026 14:11:27 +0300 Subject: [PATCH] fix: measure ui labels by display width refs #810 --- src/ui.rs | 1 + src/ui/dialogs.rs | 29 ++++----------- src/ui/mobile.rs | 31 +++++----------- src/ui/navigator.rs | 44 ++-------------------- src/ui/panes.rs | 34 +++-------------- src/ui/sidebar.rs | 34 +++++------------ src/ui/status.rs | 24 +++++++++++- src/ui/tabs.rs | 38 ++++++++++++++++++- src/ui/text.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 137 deletions(-) create mode 100644 src/ui/text.rs diff --git a/src/ui.rs b/src/ui.rs index cbaf48cc..71c11482 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -18,6 +18,7 @@ mod settings; mod sidebar; mod status; mod tabs; +mod text; mod widgets; use self::dialogs::{ diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 51e5fb15..e86edd8c 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -6,6 +6,7 @@ use ratatui::{ Frame, }; +use super::text::{display_width_u16, truncate_end}; 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, @@ -15,22 +16,6 @@ use crate::app::{state::WorktreeOpenState, AppState, Mode}; const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68; const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12; -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 <= 1 { - return "…".into(); - } - format!( - "{}…", - text.chars() - .take(max_width.saturating_sub(1)) - .collect::() - ) -} - pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) { let rects = action_button_row_rects( inner, @@ -497,27 +482,27 @@ pub(super) fn render_open_existing_worktree_overlay(app: &AppState, frame: &mut let status = entry.status_label(); let title_width = inner .width - .saturating_sub(status.len() as u16) + .saturating_sub(display_width_u16(status)) .saturating_sub(4) as usize; let mut title = format!( "{marker} {}", - truncate_text(&entry.display_name(), title_width) + truncate_end(&entry.display_name(), title_width) ); if !status.is_empty() { let pad = inner .width - .saturating_sub(title.chars().count() as u16) - .saturating_sub(status.len() as u16) + .saturating_sub(display_width_u16(&title)) + .saturating_sub(display_width_u16(status)) .max(1); title.push_str(&" ".repeat(pad as usize)); title.push_str(status); } frame.render_widget( - Paragraph::new(truncate_text(&title, inner.width as usize)).style(row_style), + Paragraph::new(truncate_end(&title, inner.width as usize)).style(row_style), Rect::new(inner.x, y, inner.width, 1), ); frame.render_widget( - Paragraph::new(truncate_text( + Paragraph::new(truncate_end( &format!(" {}", entry.path.display()), inner.width as usize, )) diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 71d39364..f1aae316 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -12,6 +12,7 @@ use super::sidebar::{ WorkspaceListEntry, }; use super::status::{agent_icon, state_dot}; +use super::text::{display_width_u16, truncate_end}; use crate::app::state::{Palette, ToastKind, ToastNotification}; use crate::app::AppState; use crate::detect::AgentState; @@ -329,7 +330,9 @@ fn render_header_status( }; let tab_label = mobile_tab_status(ws); 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 tab_w = display_width_u16(&tab_label) + .saturating_add(1) + .min(area.width); let name_w = area.width.saturating_sub(tab_w); frame.render_widget( @@ -338,7 +341,7 @@ fn render_header_status( Span::styled(dot, dot_style.bg(p.panel_bg)), Span::raw(" "), Span::styled( - truncate( + truncate_end( &ws.display_name_from(&app.terminals, terminal_runtimes), name_w.saturating_sub(4) as usize, ), @@ -516,7 +519,7 @@ fn render_mobile_switcher_content( Span::styled(icon, icon_style.bg(bg)), Span::styled(" ", Style::default().bg(bg)), Span::styled( - truncate( + truncate_end( &entry.primary_label, content.width.saturating_sub(5) as usize, ), @@ -535,7 +538,7 @@ fn render_mobile_switcher_content( app.mobile_switcher_scroll, bg, title, - truncate(&detail, content.width as usize), + truncate_end(&detail, content.width as usize), p.overlay0, ); doc_y += 2; @@ -608,7 +611,7 @@ fn render_mobile_switcher_content( }; let name_budget = content.width.saturating_sub(if *indented { 8 } else { 5 }) as usize; title_spans.push(Span::styled( - truncate(&name, name_budget), + truncate_end(&name, name_budget), Style::default() .fg(p.text) .bg(bg) @@ -628,7 +631,7 @@ fn render_mobile_switcher_content( app.mobile_switcher_scroll, bg, Line::from(title_spans), - truncate(&detail, content.width as usize), + truncate_end(&detail, content.width as usize), p.overlay0, ); doc_y += 2; @@ -669,7 +672,7 @@ fn render_mobile_switcher_content( let title = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), Span::styled( - truncate(&label, content.width.saturating_sub(3) as usize), + truncate_end(&label, content.width.saturating_sub(3) as usize), Style::default() .fg(p.text) .bg(bg) @@ -1126,20 +1129,6 @@ fn draw_horizontal_rule(frame: &mut Frame, area: Rect, p: &Palette) { } } -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}…") -} #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs index 882f5c10..5b7b2f37 100644 --- a/src/ui/navigator.rs +++ b/src/ui/navigator.rs @@ -9,6 +9,7 @@ use ratatui::{ use super::{ scrollbar::{render_scrollbar, should_show_scrollbar}, status::{agent_icon, state_label_color}, + text::{display_width_u16, middle_elide, truncate_end}, widgets::{panel_contrast_fg, render_panel_shell}, }; use crate::app::state::{AppState, NavigatorRow, NavigatorStateFilter, NavigatorTarget}; @@ -201,9 +202,9 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, let left_budget = rect .width .saturating_sub(meta_width) - .saturating_sub(left_fixed.chars().count() as u16) + .saturating_sub(display_width_u16(&left_fixed)) .saturating_sub(3) as usize; - let title = truncate_text(&row.label, left_budget); + let title = truncate_end(&row.label, left_budget); let spans = vec![ Span::styled(left_fixed, dim_style), @@ -220,7 +221,7 @@ fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, meta_width, 1, ); - let meta = truncate_text(&row.meta, meta_width.saturating_sub(2) as usize); + let meta = truncate_end(&row.meta, meta_width.saturating_sub(2) as usize); let meta_style = if selected { base_style } else if row.is_workspace || row.is_tab { @@ -471,28 +472,6 @@ fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str { } } -fn middle_elide(text: &str, max_width: usize) -> String { - let len = text.chars().count(); - if len <= max_width { - return text.to_string(); - } - if max_width <= 1 { - return "…".to_string(); - } - let left = max_width.saturating_sub(1) / 2; - let right = max_width.saturating_sub(1).saturating_sub(left); - let prefix: String = text.chars().take(left).collect(); - let suffix: String = text - .chars() - .rev() - .take(right) - .collect::>() - .into_iter() - .rev() - .collect(); - format!("{prefix}…{suffix}") -} - fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { if area.height == 0 { return; @@ -514,18 +493,3 @@ fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) { ]); frame.render_widget(Paragraph::new(line), area); } - -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}…") -} diff --git a/src/ui/panes.rs b/src/ui/panes.rs index b11462d7..aa7bf0cd 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -5,9 +5,11 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, Frame, }; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar}; +#[cfg(test)] +use super::text::display_width; +use super::text::truncate_end; use super::widgets::panel_contrast_fg; use crate::app::state::Palette; use crate::app::{AppState, Mode}; @@ -19,30 +21,6 @@ pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool { .is_some_and(|metrics| metrics.offset_from_bottom > 0) } -fn truncate_label(text: &str, max_width: usize) -> String { - if UnicodeWidthStr::width(text) <= max_width { - return text.to_string(); - } - if max_width == 0 { - return String::new(); - } - if max_width == 1 { - return "…".to_string(); - } - let mut prefix = String::new(); - let mut width = 0; - let prefix_width = max_width.saturating_sub(1); - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if width + ch_width > prefix_width { - break; - } - prefix.push(ch); - width += ch_width; - } - format!("{prefix}…") -} - fn pane_border_title(label: &str, pane_width: u16, focused: bool) -> Option { let label = label.trim(); if label.is_empty() || pane_width <= 4 { @@ -50,10 +28,10 @@ fn pane_border_title(label: &str, pane_width: u16, focused: bool) -> Option Rect } let label = agent_panel_sort_label(sort); - let width = label.chars().count() as u16; + let width = display_width_u16(label); Rect::new( area.x + area.width.saturating_sub(width), area.y + 1, @@ -162,30 +163,15 @@ pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static } } -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); + return truncate_end(&entry.primary_label, max_width); }; let separator = " · "; - let separator_width = separator.chars().count(); + let separator_width = display_width(separator); if max_width <= separator_width + 2 { - return truncate_text( + return truncate_end( &format!("{}{}{}", entry.primary_label, separator, tab_label), max_width, ); @@ -199,8 +185,8 @@ fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) - .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(); + let workspace_len = display_width(&entry.primary_label); + let tab_len = display_width(tab_label); if workspace_len < workspace_budget { let spare = workspace_budget - workspace_len; @@ -215,9 +201,9 @@ fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) - format!( "{}{}{}", - truncate_text(&entry.primary_label, workspace_budget), + truncate_end(&entry.primary_label, workspace_budget), separator, - truncate_text(tab_label, tab_budget) + truncate_end(tab_label, tab_budget) ) } @@ -956,7 +942,7 @@ fn render_workspace_list( }) .unwrap_or(0); let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved); - let branch_display = truncate_text(&branch, max_branch_len); + let branch_display = truncate_end(&branch, max_branch_len); let branch_color = if selected || is_active { p.mauve } else { diff --git a/src/ui/status.rs b/src/ui/status.rs index a3aa78fa..a49a53f6 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -6,6 +6,7 @@ use ratatui::{ Frame, }; +use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::{ app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, @@ -54,7 +55,9 @@ pub(crate) fn toast_notification_rect( offset_for_warning: bool, position: ToastHerdrPosition, ) -> Rect { - let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; + 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); @@ -280,6 +283,25 @@ mod tests { 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); diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs index f20e22f9..a587c233 100644 --- a/src/ui/tabs.rs +++ b/src/ui/tabs.rs @@ -5,6 +5,7 @@ use ratatui::{ Frame, }; +use super::text::display_width_u16; use super::widgets::panel_contrast_fg; use crate::app::AppState; @@ -22,7 +23,9 @@ pub(crate) struct TabBarView { } fn tab_width(ws: &crate::workspace::Workspace, tab_idx: usize) -> u16 { - (tab_chrome_label(ws, tab_idx).chars().count() as u16 + 4).max(MIN_TAB_WIDTH) + display_width_u16(&tab_chrome_label(ws, tab_idx)) + .saturating_add(4) + .max(MIN_TAB_WIDTH) } fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String { @@ -444,4 +447,37 @@ mod tests { assert_eq!(tab_width(&ws, 0), 14); } + + #[test] + fn tab_width_uses_display_width_for_cjk_labels() { + let mut ws = Workspace::test_new("test"); + ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); + + assert_eq!( + tab_width(&ws, 0), + display_width_u16("提交 herdr 的反馈") + 4 + ); + } + + #[test] + fn tab_bar_renders_trailing_cjk_character() { + let mut app = AppState::test_new(); + let mut ws = Workspace::test_new("test"); + ws.tabs[0].set_custom_name("提交 herdr 的反馈".into()); + + app.active = Some(0); + app.workspaces = vec![ws]; + app.view.tab_bar_rect = Rect::new(0, 0, 30, 1); + let view = compute_tab_bar_view(&app.workspaces[0], app.view.tab_bar_rect, 0, true, false); + app.view.tab_hit_areas = view.tab_hit_areas; + + let backend = TestBackend::new(30, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect)) + .unwrap(); + + let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0); + assert!(row.contains('馈'), "tab row: {row:?}"); + } } diff --git a/src/ui/text.rs b/src/ui/text.rs new file mode 100644 index 00000000..45f7983b --- /dev/null +++ b/src/ui/text.rs @@ -0,0 +1,89 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub(crate) fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +pub(crate) fn display_width_u16(text: &str) -> u16 { + display_width(text).min(u16::MAX as usize) as u16 +} + +pub(crate) fn truncate_end(text: &str, max_width: usize) -> String { + if display_width(text) <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + + let prefix = take_prefix_width(text, max_width.saturating_sub(1)); + format!("{prefix}…") +} + +pub(crate) fn middle_elide(text: &str, max_width: usize) -> String { + if display_width(text) <= max_width { + return text.to_string(); + } + if max_width <= 1 { + return "…".to_string(); + } + + let content_width = max_width.saturating_sub(1); + let left_width = content_width / 2; + let right_width = content_width.saturating_sub(left_width); + let prefix = take_prefix_width(text, left_width); + let suffix = take_suffix_width(text, right_width); + format!("{prefix}…{suffix}") +} + +fn take_prefix_width(text: &str, max_width: usize) -> String { + let mut output = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + output.push(ch); + width += ch_width; + } + output +} + +fn take_suffix_width(text: &str, max_width: usize) -> String { + let mut output = Vec::new(); + let mut width = 0usize; + for ch in text.chars().rev() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + output.push(ch); + width += ch_width; + } + output.into_iter().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_end_uses_display_width() { + let text = truncate_end("提交 herdr 的反馈", 16); + + assert_eq!(text, "提交 herdr 的反…"); + assert!(display_width(&text) <= 16); + } + + #[test] + fn middle_elide_uses_display_width() { + let text = middle_elide("重构用户认证模块并迁移到统一登录服务", 12); + + assert!(text.contains('…')); + assert!(display_width(&text) <= 12); + } +}