1pub fn truncate_to_fit(s: &str, available_px: f32, px_per_char: f32) -> String {
27 if available_px <= 0.0 || px_per_char <= 0.0 {
28 return String::new();
29 }
30
31 let max_chars = (available_px / px_per_char).floor() as usize;
32 let char_count = s.chars().count();
33
34 if char_count <= max_chars {
35 s.to_string()
37 } else if max_chars <= 1 {
38 "…".to_string()
40 } else {
41 let mut out: String = s.chars().take(max_chars - 1).collect();
43 out.push('…');
44 out
45 }
46}
47
48pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
54 iced::widget::scrollable::Direction::Vertical(
55 iced::widget::scrollable::Scrollbar::new()
56 .width(6)
57 .scroller_width(4),
58 )
59}
60
61pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
65 iced::widget::container(iced::widget::Space::new().height(1))
66 .padding(iced::Padding {
67 top: 4.0,
68 right: 0.0,
69 bottom: 4.0,
70 left: 0.0,
71 })
72 .width(iced::Length::Fill)
73 .into()
74}
75
76pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
78 iced::widget::container(iced::widget::text(label).size(12).color(muted))
79 .padding(iced::Padding {
80 top: 8.0,
81 right: 14.0,
82 bottom: 6.0,
83 left: 14.0,
84 })
85 .width(iced::Length::Fill)
86 .into()
87}
88
89pub fn centered_placeholder<'a>(
93 icon_char: char,
94 icon_size: u32,
95 label_text: &str,
96 muted: iced::Color,
97) -> iced::Element<'a, crate::message::Message> {
98 use iced::widget::{column, container, text, Space};
99 use iced::{Alignment, Length};
100
101 let icon_widget = icon!(icon_char, icon_size, muted);
102 let label = text(label_text.to_string()).size(14).color(muted);
103
104 container(
105 column![icon_widget, Space::new().height(8), label]
106 .spacing(4)
107 .align_x(Alignment::Center),
108 )
109 .width(Length::Fill)
110 .height(Length::Fill)
111 .center_x(Length::Fill)
112 .center_y(Length::Fill)
113 .into()
114}
115
116pub fn on_press_maybe<'a>(
123 btn: iced::widget::Button<'a, crate::message::Message>,
124 msg: Option<crate::message::Message>,
125) -> iced::widget::Button<'a, crate::message::Message> {
126 match msg {
127 Some(m) => btn.on_press(m),
128 None => btn,
129 }
130}
131
132pub fn collapsible_header<'a>(
137 expanded: bool,
138 label: &'a str,
139 count: usize,
140 on_toggle: crate::message::Message,
141 muted: iced::Color,
142) -> iced::Element<'a, crate::message::Message> {
143 use iced::widget::{button, row, text, Space};
144 use iced::Alignment;
145
146 let chevron_char = if expanded {
147 crate::icons::CHEVRON_DOWN
148 } else {
149 crate::icons::CHEVRON_RIGHT
150 };
151 let chevron = icon!(chevron_char, 11, muted);
152
153 button(
154 row![
155 chevron,
156 Space::new().width(4),
157 text(label).size(11).color(muted),
158 Space::new().width(4),
159 text(format!("({count})")).size(10).color(muted),
160 ]
161 .align_y(Alignment::Center),
162 )
163 .padding([4, 8])
164 .width(iced::Length::Fill)
165 .style(crate::theme::ghost_button)
166 .on_press(on_toggle)
167 .into()
168}
169
170pub fn toolbar_btn<'a>(
175 icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
176 label: &'a str,
177 msg: crate::message::Message,
178) -> iced::widget::Button<'a, crate::message::Message> {
179 use iced::widget::{button, row, text, Space};
180 use iced::Alignment;
181
182 button(
183 row![
184 icon_widget.into(),
185 Space::new().width(4),
186 text(label).size(12)
187 ]
188 .align_y(Alignment::Center),
189 )
190 .padding([4, 10])
191 .style(crate::theme::toolbar_button)
192 .on_press(msg)
193}
194
195pub fn surface_panel<'a>(
199 content: impl Into<iced::Element<'a, crate::message::Message>>,
200 width: iced::Length,
201) -> iced::Element<'a, crate::message::Message> {
202 iced::widget::container(content)
203 .width(width)
204 .height(iced::Length::Fill)
205 .style(crate::theme::surface_style)
206 .into()
207}
208
209pub fn empty_list_hint<'a>(
213 label: &'a str,
214 muted: iced::Color,
215) -> iced::Element<'a, crate::message::Message> {
216 iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
217 .padding([12, 8])
218 .width(iced::Length::Fill)
219 .center_x(iced::Length::Fill)
220 .into()
221}
222
223pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
236 let anchor = anchor.unwrap_or(clicked);
237 gitkraft_core::ascending_range(anchor, clicked)
238}
239
240#[cfg(test)]
243mod tests {
244 use super::truncate_to_fit;
245
246 #[test]
249 fn short_string_returned_unchanged() {
250 assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
251 }
252
253 #[test]
254 fn string_exactly_at_limit_is_not_truncated() {
255 assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
257 }
258
259 #[test]
260 fn empty_string_returned_unchanged() {
261 assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
262 }
263
264 #[test]
267 fn long_string_truncated_with_ellipsis() {
268 let result = truncate_to_fit("hello world", 30.0, 7.0);
270 assert_eq!(result, "hel…");
271 assert!(result.ends_with('…'));
272 }
273
274 #[test]
275 fn result_respects_max_char_budget() {
276 let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
278 assert_eq!(result.chars().count(), 5);
279 assert!(result.ends_with('…'));
280 }
281
282 #[test]
283 fn one_char_over_limit_gives_ellipsis_only() {
284 let result = truncate_to_fit("ab", 10.0, 10.0);
286 assert_eq!(result, "…");
287 }
288
289 #[test]
290 fn branch_name_with_slash_truncated_correctly() {
291 let name = "mario/MARIO-3924_global_design_system_library_publishing";
294 let result = truncate_to_fit(name, 120.0, 7.5);
295 assert_eq!(result.chars().count(), 16);
296 assert!(result.ends_with('…'));
297 assert!(result.starts_with("mario/MARIO-392"));
298 }
299
300 #[test]
301 fn commit_summary_short_enough_shows_fully() {
302 let summary = "Fix typo in README";
303 let result = truncate_to_fit(summary, 500.0, 7.0);
305 assert_eq!(result, summary);
306 }
307
308 #[test]
309 fn commit_summary_too_long_gets_ellipsis() {
310 let summary =
311 "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
312 let result = truncate_to_fit(summary, 300.0, 7.0);
314 assert_eq!(result.chars().count(), 42);
315 assert!(result.ends_with('…'));
316 }
317
318 #[test]
319 fn stash_message_truncated_correctly() {
320 let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
321 let result = truncate_to_fit(msg, 200.0, 6.5);
323 assert_eq!(result.chars().count(), 30);
324 assert!(result.ends_with('…'));
325 assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
326 }
327
328 #[test]
331 fn zero_available_px_returns_empty() {
332 assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
333 }
334
335 #[test]
336 fn negative_available_px_returns_empty() {
337 assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
338 }
339
340 #[test]
341 fn zero_px_per_char_returns_empty() {
342 assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
343 }
344
345 #[test]
346 fn single_char_string_fits_in_one_char_budget() {
347 assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
349 }
350
351 #[test]
352 fn unicode_multibyte_chars_counted_by_char_not_byte() {
353 let result = truncate_to_fit("héllo", 40.0, 10.0);
355 assert_eq!(result.chars().count(), 4);
356 assert_eq!(result, "hél…");
357 }
358
359 #[test]
360 fn unicode_ellipsis_in_source_not_duplicated() {
361 let s = "short…";
363 let result = truncate_to_fit(s, 200.0, 7.0);
364 assert_eq!(result, s);
365 }
366
367 #[test]
368 fn very_small_px_per_char_truncates_to_many_chars() {
369 let result = truncate_to_fit("helloworld", 200.0, 1.0);
371 assert_eq!(result, "helloworld");
372 }
373
374 #[test]
375 fn fractional_px_per_char_floors_correctly() {
376 let result = truncate_to_fit("abcd", 25.0, 7.5);
378 assert_eq!(result, "ab…");
379 }
380
381 #[test]
384 fn shift_click_range_with_anchor_below() {
385 assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
386 }
387
388 #[test]
389 fn shift_click_range_with_anchor_above() {
390 assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
391 }
392
393 #[test]
394 fn shift_click_range_same_index() {
395 assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
396 }
397
398 #[test]
399 fn shift_click_range_no_anchor() {
400 assert_eq!(super::shift_click_range(None, 5), vec![5]);
401 }
402}