1pub fn truncate_to_fit(s: &str, available_px: f32, px_per_char: f32) -> String {
27 gitkraft_core::truncate_to_fit(s, available_px, px_per_char)
28}
29
30pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
36 iced::widget::scrollable::Direction::Vertical(
37 iced::widget::scrollable::Scrollbar::new()
38 .width(6)
39 .scroller_width(4),
40 )
41}
42
43pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
47 iced::widget::container(iced::widget::Space::new().height(1))
48 .padding(iced::Padding {
49 top: 4.0,
50 right: 0.0,
51 bottom: 4.0,
52 left: 0.0,
53 })
54 .width(iced::Length::Fill)
55 .into()
56}
57
58pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
60 iced::widget::container(iced::widget::text(label).size(12).color(muted))
61 .padding(iced::Padding {
62 top: 8.0,
63 right: 14.0,
64 bottom: 6.0,
65 left: 14.0,
66 })
67 .width(iced::Length::Fill)
68 .into()
69}
70
71pub fn centered_placeholder<'a>(
75 icon_char: char,
76 icon_size: u32,
77 label_text: &str,
78 muted: iced::Color,
79) -> iced::Element<'a, crate::message::Message> {
80 use iced::widget::{column, container, text, Space};
81 use iced::{Alignment, Length};
82
83 let icon_widget = icon!(icon_char, icon_size, muted);
84 let label = text(label_text.to_string()).size(14).color(muted);
85
86 container(
87 column![icon_widget, Space::new().height(8), label]
88 .spacing(4)
89 .align_x(Alignment::Center),
90 )
91 .width(Length::Fill)
92 .height(Length::Fill)
93 .center_x(Length::Fill)
94 .center_y(Length::Fill)
95 .into()
96}
97
98pub fn on_press_maybe<'a>(
105 btn: iced::widget::Button<'a, crate::message::Message>,
106 msg: Option<crate::message::Message>,
107) -> iced::widget::Button<'a, crate::message::Message> {
108 match msg {
109 Some(m) => btn.on_press(m),
110 None => btn,
111 }
112}
113
114pub fn collapsible_header<'a>(
119 expanded: bool,
120 label: &'a str,
121 count: usize,
122 on_toggle: crate::message::Message,
123 muted: iced::Color,
124) -> iced::Element<'a, crate::message::Message> {
125 use iced::widget::{button, row, text, Space};
126 use iced::Alignment;
127
128 let chevron_char = if expanded {
129 crate::icons::CHEVRON_DOWN
130 } else {
131 crate::icons::CHEVRON_RIGHT
132 };
133 let chevron = icon!(chevron_char, 11, muted);
134
135 button(
136 row![
137 chevron,
138 Space::new().width(4),
139 text(label).size(11).color(muted),
140 Space::new().width(4),
141 text(format!("({count})")).size(10).color(muted),
142 ]
143 .align_y(Alignment::Center),
144 )
145 .padding([4, 8])
146 .width(iced::Length::Fill)
147 .style(crate::theme::ghost_button)
148 .on_press(on_toggle)
149 .into()
150}
151
152pub fn toolbar_btn<'a>(
157 icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
158 label: &'a str,
159 msg: crate::message::Message,
160) -> iced::widget::Button<'a, crate::message::Message> {
161 use iced::widget::{button, row, text, Space};
162 use iced::Alignment;
163
164 button(
165 row![
166 icon_widget.into(),
167 Space::new().width(4),
168 text(label).size(12)
169 ]
170 .align_y(Alignment::Center),
171 )
172 .padding([4, 10])
173 .style(crate::theme::toolbar_button)
174 .on_press(msg)
175}
176
177pub fn surface_panel<'a>(
181 content: impl Into<iced::Element<'a, crate::message::Message>>,
182 width: iced::Length,
183) -> iced::Element<'a, crate::message::Message> {
184 iced::widget::container(content)
185 .width(width)
186 .height(iced::Length::Fill)
187 .style(crate::theme::surface_style)
188 .into()
189}
190
191pub fn empty_list_hint<'a>(
195 label: &'a str,
196 muted: iced::Color,
197) -> iced::Element<'a, crate::message::Message> {
198 iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
199 .padding([12, 8])
200 .width(iced::Length::Fill)
201 .center_x(iced::Length::Fill)
202 .into()
203}
204
205pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
219 gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
220}
221
222#[cfg(test)]
225mod tests {
226 use super::truncate_to_fit;
227
228 #[test]
231 fn short_string_returned_unchanged() {
232 assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
233 }
234
235 #[test]
236 fn string_exactly_at_limit_is_not_truncated() {
237 assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
239 }
240
241 #[test]
242 fn empty_string_returned_unchanged() {
243 assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
244 }
245
246 #[test]
249 fn long_string_truncated_with_ellipsis() {
250 let result = truncate_to_fit("hello world", 30.0, 7.0);
252 assert_eq!(result, "hel…");
253 assert!(result.ends_with('…'));
254 }
255
256 #[test]
257 fn result_respects_max_char_budget() {
258 let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
260 assert_eq!(result.chars().count(), 5);
261 assert!(result.ends_with('…'));
262 }
263
264 #[test]
265 fn one_char_over_limit_gives_ellipsis_only() {
266 let result = truncate_to_fit("ab", 10.0, 10.0);
268 assert_eq!(result, "…");
269 }
270
271 #[test]
272 fn branch_name_with_slash_truncated_correctly() {
273 let name = "mario/MARIO-3924_global_design_system_library_publishing";
276 let result = truncate_to_fit(name, 120.0, 7.5);
277 assert_eq!(result.chars().count(), 16);
278 assert!(result.ends_with('…'));
279 assert!(result.starts_with("mario/MARIO-392"));
280 }
281
282 #[test]
283 fn commit_summary_short_enough_shows_fully() {
284 let summary = "Fix typo in README";
285 let result = truncate_to_fit(summary, 500.0, 7.0);
287 assert_eq!(result, summary);
288 }
289
290 #[test]
291 fn commit_summary_too_long_gets_ellipsis() {
292 let summary =
293 "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
294 let result = truncate_to_fit(summary, 300.0, 7.0);
296 assert_eq!(result.chars().count(), 42);
297 assert!(result.ends_with('…'));
298 }
299
300 #[test]
301 fn stash_message_truncated_correctly() {
302 let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
303 let result = truncate_to_fit(msg, 200.0, 6.5);
305 assert_eq!(result.chars().count(), 30);
306 assert!(result.ends_with('…'));
307 assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
308 }
309
310 #[test]
313 fn zero_available_px_returns_empty() {
314 assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
315 }
316
317 #[test]
318 fn negative_available_px_returns_empty() {
319 assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
320 }
321
322 #[test]
323 fn zero_px_per_char_returns_empty() {
324 assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
325 }
326
327 #[test]
328 fn single_char_string_fits_in_one_char_budget() {
329 assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
331 }
332
333 #[test]
334 fn unicode_multibyte_chars_counted_by_char_not_byte() {
335 let result = truncate_to_fit("héllo", 40.0, 10.0);
337 assert_eq!(result.chars().count(), 4);
338 assert_eq!(result, "hél…");
339 }
340
341 #[test]
342 fn unicode_ellipsis_in_source_not_duplicated() {
343 let s = "short…";
345 let result = truncate_to_fit(s, 200.0, 7.0);
346 assert_eq!(result, s);
347 }
348
349 #[test]
350 fn very_small_px_per_char_truncates_to_many_chars() {
351 let result = truncate_to_fit("helloworld", 200.0, 1.0);
353 assert_eq!(result, "helloworld");
354 }
355
356 #[test]
357 fn fractional_px_per_char_floors_correctly() {
358 let result = truncate_to_fit("abcd", 25.0, 7.5);
360 assert_eq!(result, "ab…");
361 }
362
363 #[test]
366 fn shift_click_range_with_anchor_below() {
367 assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
368 }
369
370 #[test]
371 fn shift_click_range_with_anchor_above() {
372 assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
373 }
374
375 #[test]
376 fn shift_click_range_same_index() {
377 assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
378 }
379
380 #[test]
381 fn shift_click_range_no_anchor() {
382 assert_eq!(super::shift_click_range(None, 5), vec![5]);
383 }
384}