pub fn truncate_to_fit(s: &str, available_px: f32, px_per_char: f32) -> String {
if available_px <= 0.0 || px_per_char <= 0.0 {
return String::new();
}
let max_chars = (available_px / px_per_char).floor() as usize;
let char_count = s.chars().count();
if char_count <= max_chars {
s.to_string()
} else if max_chars <= 1 {
"…".to_string()
} else {
let mut out: String = s.chars().take(max_chars - 1).collect();
out.push('…');
out
}
}
pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
iced::widget::scrollable::Direction::Vertical(
iced::widget::scrollable::Scrollbar::new()
.width(6)
.scroller_width(4),
)
}
pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
iced::widget::container(iced::widget::Space::with_height(1))
.padding(iced::Padding {
top: 4.0,
right: 0.0,
bottom: 4.0,
left: 0.0,
})
.width(iced::Length::Fill)
.into()
}
pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
iced::widget::container(iced::widget::text(label).size(12).color(muted))
.padding(iced::Padding {
top: 8.0,
right: 14.0,
bottom: 6.0,
left: 14.0,
})
.width(iced::Length::Fill)
.into()
}
pub fn centered_placeholder<'a>(
icon_char: char,
icon_size: u16,
label_text: &str,
muted: iced::Color,
) -> iced::Element<'a, crate::message::Message> {
use iced::widget::{column, container, text, Space};
use iced::{Alignment, Length};
let icon_widget = icon!(icon_char, icon_size, muted);
let label = text(label_text.to_string()).size(14).color(muted);
container(
column![icon_widget, Space::with_height(8), label]
.spacing(4)
.align_x(Alignment::Center),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
}
pub fn on_press_maybe<'a>(
btn: iced::widget::Button<'a, crate::message::Message>,
msg: Option<crate::message::Message>,
) -> iced::widget::Button<'a, crate::message::Message> {
match msg {
Some(m) => btn.on_press(m),
None => btn,
}
}
pub fn collapsible_header<'a>(
expanded: bool,
label: &'a str,
count: usize,
on_toggle: crate::message::Message,
muted: iced::Color,
) -> iced::Element<'a, crate::message::Message> {
use iced::widget::{button, row, text, Space};
use iced::Alignment;
let chevron_char = if expanded {
crate::icons::CHEVRON_DOWN
} else {
crate::icons::CHEVRON_RIGHT
};
let chevron = icon!(chevron_char, 11, muted);
button(
row![
chevron,
Space::with_width(4),
text(label).size(11).color(muted),
Space::with_width(4),
text(format!("({count})")).size(10).color(muted),
]
.align_y(Alignment::Center),
)
.padding([4, 8])
.width(iced::Length::Fill)
.style(crate::theme::ghost_button)
.on_press(on_toggle)
.into()
}
pub fn toolbar_btn<'a>(
icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
label: &'a str,
msg: crate::message::Message,
) -> iced::widget::Button<'a, crate::message::Message> {
use iced::widget::{button, row, text, Space};
use iced::Alignment;
button(
row![
icon_widget.into(),
Space::with_width(4),
text(label).size(12)
]
.align_y(Alignment::Center),
)
.padding([4, 10])
.style(crate::theme::toolbar_button)
.on_press(msg)
}
pub fn surface_panel<'a>(
content: impl Into<iced::Element<'a, crate::message::Message>>,
width: iced::Length,
) -> iced::Element<'a, crate::message::Message> {
iced::widget::container(content)
.width(width)
.height(iced::Length::Fill)
.style(crate::theme::surface_style)
.into()
}
pub fn empty_list_hint<'a>(
label: &'a str,
muted: iced::Color,
) -> iced::Element<'a, crate::message::Message> {
iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
.padding([12, 8])
.width(iced::Length::Fill)
.center_x(iced::Length::Fill)
.into()
}
#[cfg(test)]
mod tests {
use super::truncate_to_fit;
#[test]
fn short_string_returned_unchanged() {
assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
}
#[test]
fn string_exactly_at_limit_is_not_truncated() {
assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
}
#[test]
fn empty_string_returned_unchanged() {
assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
}
#[test]
fn long_string_truncated_with_ellipsis() {
let result = truncate_to_fit("hello world", 30.0, 7.0);
assert_eq!(result, "hel…");
assert!(result.ends_with('…'));
}
#[test]
fn result_respects_max_char_budget() {
let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
assert_eq!(result.chars().count(), 5);
assert!(result.ends_with('…'));
}
#[test]
fn one_char_over_limit_gives_ellipsis_only() {
let result = truncate_to_fit("ab", 10.0, 10.0);
assert_eq!(result, "…");
}
#[test]
fn branch_name_with_slash_truncated_correctly() {
let name = "mario/MARIO-3924_global_design_system_library_publishing";
let result = truncate_to_fit(name, 120.0, 7.5);
assert_eq!(result.chars().count(), 16);
assert!(result.ends_with('…'));
assert!(result.starts_with("mario/MARIO-392"));
}
#[test]
fn commit_summary_short_enough_shows_fully() {
let summary = "Fix typo in README";
let result = truncate_to_fit(summary, 500.0, 7.0);
assert_eq!(result, summary);
}
#[test]
fn commit_summary_too_long_gets_ellipsis() {
let summary =
"CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
let result = truncate_to_fit(summary, 300.0, 7.0);
assert_eq!(result.chars().count(), 42);
assert!(result.ends_with('…'));
}
#[test]
fn stash_message_truncated_correctly() {
let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
let result = truncate_to_fit(msg, 200.0, 6.5);
assert_eq!(result.chars().count(), 30);
assert!(result.ends_with('…'));
assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
}
#[test]
fn zero_available_px_returns_empty() {
assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
}
#[test]
fn negative_available_px_returns_empty() {
assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
}
#[test]
fn zero_px_per_char_returns_empty() {
assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
}
#[test]
fn single_char_string_fits_in_one_char_budget() {
assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
}
#[test]
fn unicode_multibyte_chars_counted_by_char_not_byte() {
let result = truncate_to_fit("héllo", 40.0, 10.0);
assert_eq!(result.chars().count(), 4);
assert_eq!(result, "hél…");
}
#[test]
fn unicode_ellipsis_in_source_not_duplicated() {
let s = "short…";
let result = truncate_to_fit(s, 200.0, 7.0);
assert_eq!(result, s);
}
#[test]
fn very_small_px_per_char_truncates_to_many_chars() {
let result = truncate_to_fit("helloworld", 200.0, 1.0);
assert_eq!(result, "helloworld");
}
#[test]
fn fractional_px_per_char_floors_correctly() {
let result = truncate_to_fit("abcd", 25.0, 7.5);
assert_eq!(result, "ab…");
}
}