Skip to main content

gitkraft_gui/
view_utils.rs

1//! Shared view utilities for the GitKraft GUI.
2//!
3//! Helpers in this module are used by multiple feature views and are kept here
4//! to avoid duplication and make them easy to test in isolation.
5
6// ── Standardised font sizes ──────────────────────────────────────────────────
7// Keeping the number of distinct sizes low reduces pressure on Iced's glyph
8// atlas and prevents text rendering artifacts on macOS HiDPI displays.
9// When adding UI text, prefer one of these constants over a bare literal.
10
11/// Extra-small — badges, hints, validation errors, column headers.
12pub const FONT_XS: f32 = 10.0;
13/// Small — secondary text, button labels, list items.
14pub const FONT_SM: f32 = 12.0;
15/// Medium — primary body text, inputs. Matches the app's `default_text_size`.
16pub const FONT_MD: f32 = 13.0;
17/// Large — section headers, panel titles.
18pub const FONT_LG: f32 = 14.0;
19/// Extra-large — toolbar titles, emphasis.
20pub const FONT_XL: f32 = 16.0;
21
22// ── Text truncation ───────────────────────────────────────────────────────────
23
24/// Truncate `s` to fit within `available_px` pixels at the given average
25/// `px_per_char` rate, appending "…" when the string is shortened.
26///
27/// # Behaviour
28/// - If `available_px` is zero or negative the string is truncated to `""`.
29/// - If the string already fits it is returned unchanged (no allocation when
30///   ownership is not needed — callers that already own a `String` can pass
31///   `s.as_str()` and get the original value back as a new `String`; the cost
32///   is one clone at most).
33/// - The "…" counts as **one character** in the budget, so the returned string
34///   always fits within `available_px`.
35///
36/// # Example
37/// ```
38/// # use gitkraft_gui::view_utils::truncate_to_fit;
39/// assert_eq!(truncate_to_fit("hello", 100.0, 7.0), "hello");
40/// assert_eq!(truncate_to_fit("hello world", 30.0, 7.0), "hel…");
41/// ```
42pub 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
50// ── Scrollbar helper ──────────────────────────────────────────────────────
51
52/// Standard thin vertical scrollbar direction used across all sidebar panels.
53///
54/// Apply as: `scrollable(content).direction(thin_scrollbar()).style(overlay_scrollbar)`
55pub 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
63// ── Context menu helpers ──────────────────────────────────────────────────
64
65/// Thin horizontal separator line for context menus.
66pub 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
78/// Header label for a context menu panel.
79pub 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
91// ── Centered placeholder ──────────────────────────────────────────────────
92
93/// A centered placeholder with an icon and a label, used for empty/loading states.
94pub 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
118// ── Button helpers ────────────────────────────────────────────────────────
119
120/// Conditionally attach an `on_press` handler to a button.
121///
122/// Replaces the common pattern of building the same button twice in an
123/// if/else just to add or omit `.on_press(msg)`.
124pub 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
134// ── Collapsible section header ────────────────────────────────────────────
135
136/// A collapsible section header with a chevron, label, count, and toggle message.
137/// Used for "Local (N)" / "Remote (N)" in the branches sidebar.
138pub 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
172// ── Toolbar button ────────────────────────────────────────────────────────
173
174/// A toolbar button with an icon and label text.
175/// Used in the header toolbar for Refresh, Open, Close, etc.
176pub 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
197/// Like [`toolbar_btn`], but drops the text label when `icon_only` is set.
198pub 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
215// ── Panel wrapper ─────────────────────────────────────────────────────────
216
217/// Wrap content in a full-size container with the surface background style.
218pub 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
229// ── Empty list hint ───────────────────────────────────────────────────────
230
231/// Centered muted text shown when a list has no items.
232pub 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
243// ── Shift-click range selection ───────────────────────────────────────────────
244
245/// Compute a shift-click range selection.
246///
247/// Compute a shift-click range selection.
248///
249/// Given an optional anchor index (from the last non-shift click) and the
250/// currently clicked index, returns an ascending `Vec<usize>` covering
251/// `[min(anchor, clicked)..=max(anchor, clicked)]`.  If `anchor` is `None`
252/// the clicked index is used as both endpoints, yielding a single-element vec.
253///
254/// This is GUI-only logic — the TUI uses `ascending_range` directly with
255/// its own keyboard-driven anchor handling.
256pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
257    gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
258}
259
260// ── Tests ─────────────────────────────────────────────────────────────────────
261
262#[cfg(test)]
263mod tests {
264    use super::truncate_to_fit;
265
266    // ── fits without truncation ───────────────────────────────────────────
267
268    #[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        // 3 chars × 10 px/char = 30 px → max_chars = 3, char_count = 3 → fits
276        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    // ── truncation with ellipsis ──────────────────────────────────────────
285
286    #[test]
287    fn long_string_truncated_with_ellipsis() {
288        // 30px / 7px = 4 chars max; keeps 3 + "…"
289        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        // 50px / 10px = 5 chars max; result must be ≤ 5 chars (counting "…" as 1)
297        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        // 10px / 10px = 1 char max → only room for "…"
305        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        // Typical sidebar scenario: long branch name at 7.5 px/char, 120 px
312        // available → max 16 chars; keeps 15 + "…"
313        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        // 500 px available, 7 px/char → 71 chars max — summary (18 chars) fits
324        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        // 300 px / 7 px = 42 chars max; keeps 41 + "…"
333        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        // 200 px / 6.5 px = 30 chars max; keeps 29 + "…"
342        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    // ── edge / boundary cases ─────────────────────────────────────────────
349
350    #[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        // 10px / 10px = 1 char max, string is 1 char → fits
368        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        // "héllo" = 5 chars; 40px / 10px = 4 max → keeps 3 + "…"
374        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        // String already short enough — no extra "…" appended
382        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        // 200px / 1px = 200 chars max; 10-char string fits
390        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        // 25px / 7.5px = 3.33 → floor to 3; string "abcd" (4) → truncate
397        let result = truncate_to_fit("abcd", 25.0, 7.5);
398        assert_eq!(result, "ab…");
399    }
400
401    // ── shift_click_range ─────────────────────────────────────────────────
402
403    #[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}