parent
14d8e93391
commit
b44ca3b39e
|
|
@ -18,6 +18,7 @@ mod settings;
|
|||
mod sidebar;
|
||||
mod status;
|
||||
mod tabs;
|
||||
mod text;
|
||||
mod widgets;
|
||||
|
||||
use self::dialogs::{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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}…")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue