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 let max_chars = (available_px / px_per_char).floor() as usize;
31 gitkraft_core::truncate_str(s, max_chars)
32}
33
34pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
40 iced::widget::scrollable::Direction::Vertical(
41 iced::widget::scrollable::Scrollbar::new()
42 .width(6)
43 .scroller_width(4),
44 )
45}
46
47pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
51 iced::widget::container(iced::widget::Space::new().height(1))
52 .padding(iced::Padding {
53 top: 4.0,
54 right: 0.0,
55 bottom: 4.0,
56 left: 0.0,
57 })
58 .width(iced::Length::Fill)
59 .into()
60}
61
62pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
64 iced::widget::container(iced::widget::text(label).size(12).color(muted))
65 .padding(iced::Padding {
66 top: 8.0,
67 right: 14.0,
68 bottom: 6.0,
69 left: 14.0,
70 })
71 .width(iced::Length::Fill)
72 .into()
73}
74
75pub fn centered_placeholder<'a>(
79 icon_char: char,
80 icon_size: u32,
81 label_text: &str,
82 muted: iced::Color,
83) -> iced::Element<'a, crate::message::Message> {
84 use iced::widget::{column, container, text, Space};
85 use iced::{Alignment, Length};
86
87 let icon_widget = icon!(icon_char, icon_size, muted);
88 let label = text(label_text.to_string()).size(14).color(muted);
89
90 container(
91 column![icon_widget, Space::new().height(8), label]
92 .spacing(4)
93 .align_x(Alignment::Center),
94 )
95 .width(Length::Fill)
96 .height(Length::Fill)
97 .center_x(Length::Fill)
98 .center_y(Length::Fill)
99 .into()
100}
101
102pub fn on_press_maybe<'a>(
109 btn: iced::widget::Button<'a, crate::message::Message>,
110 msg: Option<crate::message::Message>,
111) -> iced::widget::Button<'a, crate::message::Message> {
112 match msg {
113 Some(m) => btn.on_press(m),
114 None => btn,
115 }
116}
117
118pub fn collapsible_header<'a>(
123 expanded: bool,
124 label: &'a str,
125 count: usize,
126 on_toggle: crate::message::Message,
127 muted: iced::Color,
128) -> iced::Element<'a, crate::message::Message> {
129 use iced::widget::{button, row, text, Space};
130 use iced::Alignment;
131
132 let chevron_char = if expanded {
133 crate::icons::CHEVRON_DOWN
134 } else {
135 crate::icons::CHEVRON_RIGHT
136 };
137 let chevron = icon!(chevron_char, 11, muted);
138
139 button(
140 row![
141 chevron,
142 Space::new().width(4),
143 text(label).size(11).color(muted),
144 Space::new().width(4),
145 text(format!("({count})")).size(10).color(muted),
146 ]
147 .align_y(Alignment::Center),
148 )
149 .padding([4, 8])
150 .width(iced::Length::Fill)
151 .style(crate::theme::ghost_button)
152 .on_press(on_toggle)
153 .into()
154}
155
156pub fn toolbar_btn<'a>(
161 icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
162 label: &'a str,
163 msg: crate::message::Message,
164) -> iced::widget::Button<'a, crate::message::Message> {
165 use iced::widget::{button, row, text, Space};
166 use iced::Alignment;
167
168 button(
169 row![
170 icon_widget.into(),
171 Space::new().width(4),
172 text(label).size(12)
173 ]
174 .align_y(Alignment::Center),
175 )
176 .padding([4, 10])
177 .style(crate::theme::toolbar_button)
178 .on_press(msg)
179}
180
181pub fn surface_panel<'a>(
185 content: impl Into<iced::Element<'a, crate::message::Message>>,
186 width: iced::Length,
187) -> iced::Element<'a, crate::message::Message> {
188 iced::widget::container(content)
189 .width(width)
190 .height(iced::Length::Fill)
191 .style(crate::theme::surface_style)
192 .into()
193}
194
195pub fn empty_list_hint<'a>(
199 label: &'a str,
200 muted: iced::Color,
201) -> iced::Element<'a, crate::message::Message> {
202 iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
203 .padding([12, 8])
204 .width(iced::Length::Fill)
205 .center_x(iced::Length::Fill)
206 .into()
207}
208
209pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
223 gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
224}
225
226#[cfg(test)]
229mod tests {
230 use super::truncate_to_fit;
231
232 #[test]
235 fn short_string_returned_unchanged() {
236 assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
237 }
238
239 #[test]
240 fn string_exactly_at_limit_is_not_truncated() {
241 assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
243 }
244
245 #[test]
246 fn empty_string_returned_unchanged() {
247 assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
248 }
249
250 #[test]
253 fn long_string_truncated_with_ellipsis() {
254 let result = truncate_to_fit("hello world", 30.0, 7.0);
256 assert_eq!(result, "hel…");
257 assert!(result.ends_with('…'));
258 }
259
260 #[test]
261 fn result_respects_max_char_budget() {
262 let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
264 assert_eq!(result.chars().count(), 5);
265 assert!(result.ends_with('…'));
266 }
267
268 #[test]
269 fn one_char_over_limit_gives_ellipsis_only() {
270 let result = truncate_to_fit("ab", 10.0, 10.0);
272 assert_eq!(result, "…");
273 }
274
275 #[test]
276 fn branch_name_with_slash_truncated_correctly() {
277 let name = "mario/MARIO-3924_global_design_system_library_publishing";
280 let result = truncate_to_fit(name, 120.0, 7.5);
281 assert_eq!(result.chars().count(), 16);
282 assert!(result.ends_with('…'));
283 assert!(result.starts_with("mario/MARIO-392"));
284 }
285
286 #[test]
287 fn commit_summary_short_enough_shows_fully() {
288 let summary = "Fix typo in README";
289 let result = truncate_to_fit(summary, 500.0, 7.0);
291 assert_eq!(result, summary);
292 }
293
294 #[test]
295 fn commit_summary_too_long_gets_ellipsis() {
296 let summary =
297 "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
298 let result = truncate_to_fit(summary, 300.0, 7.0);
300 assert_eq!(result.chars().count(), 42);
301 assert!(result.ends_with('…'));
302 }
303
304 #[test]
305 fn stash_message_truncated_correctly() {
306 let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
307 let result = truncate_to_fit(msg, 200.0, 6.5);
309 assert_eq!(result.chars().count(), 30);
310 assert!(result.ends_with('…'));
311 assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
312 }
313
314 #[test]
317 fn zero_available_px_returns_empty() {
318 assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
319 }
320
321 #[test]
322 fn negative_available_px_returns_empty() {
323 assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
324 }
325
326 #[test]
327 fn zero_px_per_char_returns_empty() {
328 assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
329 }
330
331 #[test]
332 fn single_char_string_fits_in_one_char_budget() {
333 assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
335 }
336
337 #[test]
338 fn unicode_multibyte_chars_counted_by_char_not_byte() {
339 let result = truncate_to_fit("héllo", 40.0, 10.0);
341 assert_eq!(result.chars().count(), 4);
342 assert_eq!(result, "hél…");
343 }
344
345 #[test]
346 fn unicode_ellipsis_in_source_not_duplicated() {
347 let s = "short…";
349 let result = truncate_to_fit(s, 200.0, 7.0);
350 assert_eq!(result, s);
351 }
352
353 #[test]
354 fn very_small_px_per_char_truncates_to_many_chars() {
355 let result = truncate_to_fit("helloworld", 200.0, 1.0);
357 assert_eq!(result, "helloworld");
358 }
359
360 #[test]
361 fn fractional_px_per_char_floors_correctly() {
362 let result = truncate_to_fit("abcd", 25.0, 7.5);
364 assert_eq!(result, "ab…");
365 }
366
367 #[test]
370 fn shift_click_range_with_anchor_below() {
371 assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
372 }
373
374 #[test]
375 fn shift_click_range_with_anchor_above() {
376 assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
377 }
378
379 #[test]
380 fn shift_click_range_same_index() {
381 assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
382 }
383
384 #[test]
385 fn shift_click_range_no_anchor() {
386 assert_eq!(super::shift_click_range(None, 5), vec![5]);
387 }
388}