fix: measure ui labels by display width

refs #810
This commit is contained in:
Ogulcan Celik 2026-06-26 14:11:27 +03:00
parent 14d8e93391
commit b44ca3b39e
9 changed files with 187 additions and 137 deletions

View File

@ -18,6 +18,7 @@ mod settings;
mod sidebar;
mod status;
mod tabs;
mod text;
mod widgets;
use self::dialogs::{

View File

@ -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::<String>()
)
}
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,
))

View File

@ -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::*;

View File

@ -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::<Vec<_>>()
.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}")
}

View File

@ -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<String> {
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<Stri
}
if focused {
let max_label_width = pane_width.saturating_sub(5) as usize;
Some(format!("{} ", truncate_label(label, max_label_width)))
Some(format!("{} ", truncate_end(label, max_label_width)))
} else {
let max_label_width = pane_width.saturating_sub(4) as usize;
Some(format!(" {} ", truncate_label(label, max_label_width)))
Some(format!(" {} ", truncate_end(label, max_label_width)))
}
}
@ -843,7 +821,7 @@ mod tests {
let title = pane_border_title("1 模块组织(已定)", 12, false).unwrap();
assert_eq!(title, " 1 模块… ");
assert!(UnicodeWidthStr::width(title.as_str()) <= 10);
assert!(display_width(title.as_str()) <= 10);
}
#[test]

View File

@ -8,6 +8,7 @@ use ratatui::{
use super::scrollbar::{render_scrollbar, should_show_scrollbar};
use super::status::{agent_icon, state_dot, state_label, state_label_color};
use super::text::{display_width, display_width_u16, truncate_end};
use crate::app::state::{AgentPanelSort, Palette};
use crate::app::{AppState, Mode};
use crate::detect::AgentState;
@ -82,7 +83,7 @@ pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> 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 {

View File

@ -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);

View File

@ -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:?}");
}
}

89
src/ui/text.rs Normal file
View File

@ -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);
}
}