1pub const FONT_XS: f32 = 10.0;
13pub const FONT_SM: f32 = 12.0;
15pub const FONT_MD: f32 = 13.0;
17pub const FONT_LG: f32 = 14.0;
19pub const FONT_XL: f32 = 16.0;
21
22pub fn truncate_to_fit(s: &str, available_px: f32, px_per_char: f32) -> String {
43 if available_px <= 0.0 || px_per_char <= 0.0 {
44 return String::new();
45 }
46 let max_chars = (available_px / px_per_char).floor() as usize;
47 gitkraft_core::truncate_str(s, max_chars)
48}
49
50pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
56 iced::widget::scrollable::Direction::Vertical(
57 iced::widget::scrollable::Scrollbar::new()
58 .width(6)
59 .scroller_width(4),
60 )
61}
62
63pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
67 iced::widget::container(iced::widget::Space::new().height(1))
68 .padding(iced::Padding {
69 top: 4.0,
70 right: 0.0,
71 bottom: 4.0,
72 left: 0.0,
73 })
74 .width(iced::Length::Fill)
75 .into()
76}
77
78pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
80 iced::widget::container(iced::widget::text(label).size(12).color(muted))
81 .padding(iced::Padding {
82 top: 8.0,
83 right: 14.0,
84 bottom: 6.0,
85 left: 14.0,
86 })
87 .width(iced::Length::Fill)
88 .into()
89}
90
91pub fn centered_placeholder<'a>(
95 icon_char: char,
96 icon_size: u32,
97 label_text: &str,
98 muted: iced::Color,
99) -> iced::Element<'a, crate::message::Message> {
100 use iced::widget::{column, container, text, Space};
101 use iced::{Alignment, Length};
102
103 let icon_widget = icon!(icon_char, icon_size, muted);
104 let label = text(label_text.to_string()).size(14).color(muted);
105
106 container(
107 column![icon_widget, Space::new().height(8), label]
108 .spacing(4)
109 .align_x(Alignment::Center),
110 )
111 .width(Length::Fill)
112 .height(Length::Fill)
113 .center_x(Length::Fill)
114 .center_y(Length::Fill)
115 .into()
116}
117
118pub fn on_press_maybe<'a>(
125 btn: iced::widget::Button<'a, crate::message::Message>,
126 msg: Option<crate::message::Message>,
127) -> iced::widget::Button<'a, crate::message::Message> {
128 match msg {
129 Some(m) => btn.on_press(m),
130 None => btn,
131 }
132}
133
134pub fn collapsible_header<'a>(
139 expanded: bool,
140 label: &'a str,
141 count: usize,
142 on_toggle: crate::message::Message,
143 muted: iced::Color,
144) -> iced::Element<'a, crate::message::Message> {
145 use iced::widget::{button, row, text, Space};
146 use iced::Alignment;
147
148 let chevron_char = if expanded {
149 crate::icons::CHEVRON_DOWN
150 } else {
151 crate::icons::CHEVRON_RIGHT
152 };
153 let chevron = icon!(chevron_char, 11, muted);
154
155 button(
156 row![
157 chevron,
158 Space::new().width(4),
159 text(label).size(11).color(muted),
160 Space::new().width(4),
161 text(format!("({count})")).size(10).color(muted),
162 ]
163 .align_y(Alignment::Center),
164 )
165 .padding([4, 8])
166 .width(iced::Length::Fill)
167 .style(crate::theme::ghost_button)
168 .on_press(on_toggle)
169 .into()
170}
171
172pub fn toolbar_btn<'a>(
177 icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
178 label: &'a str,
179 msg: crate::message::Message,
180) -> iced::widget::Button<'a, crate::message::Message> {
181 use iced::widget::{button, row, text, Space};
182 use iced::Alignment;
183
184 button(
185 row![
186 icon_widget.into(),
187 Space::new().width(4),
188 text(label).size(12)
189 ]
190 .align_y(Alignment::Center),
191 )
192 .padding([4, 10])
193 .style(crate::theme::toolbar_button)
194 .on_press(msg)
195}
196
197pub fn toolbar_btn_adaptive<'a>(
199 icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
200 label: &'a str,
201 msg: crate::message::Message,
202 icon_only: bool,
203) -> iced::widget::Button<'a, crate::message::Message> {
204 use iced::widget::button;
205
206 if !icon_only {
207 return toolbar_btn(icon_widget, label, msg);
208 }
209 button(icon_widget.into())
210 .padding([4, 8])
211 .style(crate::theme::toolbar_button)
212 .on_press(msg)
213}
214
215pub fn surface_panel<'a>(
219 content: impl Into<iced::Element<'a, crate::message::Message>>,
220 width: iced::Length,
221) -> iced::Element<'a, crate::message::Message> {
222 iced::widget::container(content)
223 .width(width)
224 .height(iced::Length::Fill)
225 .style(crate::theme::surface_style)
226 .into()
227}
228
229pub fn empty_list_hint<'a>(
233 label: &'a str,
234 muted: iced::Color,
235) -> iced::Element<'a, crate::message::Message> {
236 iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
237 .padding([12, 8])
238 .width(iced::Length::Fill)
239 .center_x(iced::Length::Fill)
240 .into()
241}
242
243pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
257 gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
258}
259
260#[cfg(test)]
263mod tests {
264 use super::truncate_to_fit;
265
266 #[test]
269 fn short_string_returned_unchanged() {
270 assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
271 }
272
273 #[test]
274 fn string_exactly_at_limit_is_not_truncated() {
275 assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
277 }
278
279 #[test]
280 fn empty_string_returned_unchanged() {
281 assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
282 }
283
284 #[test]
287 fn long_string_truncated_with_ellipsis() {
288 let result = truncate_to_fit("hello world", 30.0, 7.0);
290 assert_eq!(result, "hel…");
291 assert!(result.ends_with('…'));
292 }
293
294 #[test]
295 fn result_respects_max_char_budget() {
296 let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
298 assert_eq!(result.chars().count(), 5);
299 assert!(result.ends_with('…'));
300 }
301
302 #[test]
303 fn one_char_over_limit_gives_ellipsis_only() {
304 let result = truncate_to_fit("ab", 10.0, 10.0);
306 assert_eq!(result, "…");
307 }
308
309 #[test]
310 fn branch_name_with_slash_truncated_correctly() {
311 let name = "mario/MARIO-3924_global_design_system_library_publishing";
314 let result = truncate_to_fit(name, 120.0, 7.5);
315 assert_eq!(result.chars().count(), 16);
316 assert!(result.ends_with('…'));
317 assert!(result.starts_with("mario/MARIO-392"));
318 }
319
320 #[test]
321 fn commit_summary_short_enough_shows_fully() {
322 let summary = "Fix typo in README";
323 let result = truncate_to_fit(summary, 500.0, 7.0);
325 assert_eq!(result, summary);
326 }
327
328 #[test]
329 fn commit_summary_too_long_gets_ellipsis() {
330 let summary =
331 "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
332 let result = truncate_to_fit(summary, 300.0, 7.0);
334 assert_eq!(result.chars().count(), 42);
335 assert!(result.ends_with('…'));
336 }
337
338 #[test]
339 fn stash_message_truncated_correctly() {
340 let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
341 let result = truncate_to_fit(msg, 200.0, 6.5);
343 assert_eq!(result.chars().count(), 30);
344 assert!(result.ends_with('…'));
345 assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
346 }
347
348 #[test]
351 fn zero_available_px_returns_empty() {
352 assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
353 }
354
355 #[test]
356 fn negative_available_px_returns_empty() {
357 assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
358 }
359
360 #[test]
361 fn zero_px_per_char_returns_empty() {
362 assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
363 }
364
365 #[test]
366 fn single_char_string_fits_in_one_char_budget() {
367 assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
369 }
370
371 #[test]
372 fn unicode_multibyte_chars_counted_by_char_not_byte() {
373 let result = truncate_to_fit("héllo", 40.0, 10.0);
375 assert_eq!(result.chars().count(), 4);
376 assert_eq!(result, "hél…");
377 }
378
379 #[test]
380 fn unicode_ellipsis_in_source_not_duplicated() {
381 let s = "short…";
383 let result = truncate_to_fit(s, 200.0, 7.0);
384 assert_eq!(result, s);
385 }
386
387 #[test]
388 fn very_small_px_per_char_truncates_to_many_chars() {
389 let result = truncate_to_fit("helloworld", 200.0, 1.0);
391 assert_eq!(result, "helloworld");
392 }
393
394 #[test]
395 fn fractional_px_per_char_floors_correctly() {
396 let result = truncate_to_fit("abcd", 25.0, 7.5);
398 assert_eq!(result, "ab…");
399 }
400
401 #[test]
404 fn shift_click_range_with_anchor_below() {
405 assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
406 }
407
408 #[test]
409 fn shift_click_range_with_anchor_above() {
410 assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
411 }
412
413 #[test]
414 fn shift_click_range_same_index() {
415 assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
416 }
417
418 #[test]
419 fn shift_click_range_no_anchor() {
420 assert_eq!(super::shift_click_range(None, 5), vec![5]);
421 }
422}