aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/dialogs.rs29
-rw-r--r--src/ui/mobile.rs31
-rw-r--r--src/ui/navigator.rs44
-rw-r--r--src/ui/panes.rs34
-rw-r--r--src/ui/sidebar.rs34
-rw-r--r--src/ui/status.rs24
-rw-r--r--src/ui/tabs.rs38
-rw-r--r--src/ui/text.rs89
8 files changed, 186 insertions, 137 deletions
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::<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,
))
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::<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}…")
-}
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<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]
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index ffb93f22..a7b0ca92 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -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 {
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);
@@ -281,6 +284,25 @@ mod tests {
}
#[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();
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);
+ }
+}