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// ── Text truncation ───────────────────────────────────────────────────────────
7
8/// Truncate `s` to fit within `available_px` pixels at the given average
9/// `px_per_char` rate, appending "…" when the string is shortened.
10///
11/// # Behaviour
12/// - If `available_px` is zero or negative the string is truncated to `""`.
13/// - If the string already fits it is returned unchanged (no allocation when
14///   ownership is not needed — callers that already own a `String` can pass
15///   `s.as_str()` and get the original value back as a new `String`; the cost
16///   is one clone at most).
17/// - The "…" counts as **one character** in the budget, so the returned string
18///   always fits within `available_px`.
19///
20/// # Example
21/// ```
22/// # use gitkraft_gui::view_utils::truncate_to_fit;
23/// assert_eq!(truncate_to_fit("hello", 100.0, 7.0), "hello");
24/// assert_eq!(truncate_to_fit("hello world", 30.0, 7.0), "hel…");
25/// ```
26pub 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
30// ── Scrollbar helper ──────────────────────────────────────────────────────
31
32/// Standard thin vertical scrollbar direction used across all sidebar panels.
33///
34/// Apply as: `scrollable(content).direction(thin_scrollbar()).style(overlay_scrollbar)`
35pub 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
43// ── Context menu helpers ──────────────────────────────────────────────────
44
45/// Thin horizontal separator line for context menus.
46pub 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
58/// Header label for a context menu panel.
59pub 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
71// ── Centered placeholder ──────────────────────────────────────────────────
72
73/// A centered placeholder with an icon and a label, used for empty/loading states.
74pub 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
98// ── Button helpers ────────────────────────────────────────────────────────
99
100/// Conditionally attach an `on_press` handler to a button.
101///
102/// Replaces the common pattern of building the same button twice in an
103/// if/else just to add or omit `.on_press(msg)`.
104pub 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
114// ── Collapsible section header ────────────────────────────────────────────
115
116/// A collapsible section header with a chevron, label, count, and toggle message.
117/// Used for "Local (N)" / "Remote (N)" in the branches sidebar.
118pub 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
152// ── Toolbar button ────────────────────────────────────────────────────────
153
154/// A toolbar button with an icon and label text.
155/// Used in the header toolbar for Refresh, Open, Close, etc.
156pub 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
177// ── Panel wrapper ─────────────────────────────────────────────────────────
178
179/// Wrap content in a full-size container with the surface background style.
180pub 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
191// ── Empty list hint ───────────────────────────────────────────────────────
192
193/// Centered muted text shown when a list has no items.
194pub 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
205// ── Shift-click range selection ───────────────────────────────────────────────
206
207/// Compute a shift-click range selection.
208///
209/// Compute a shift-click range selection.
210///
211/// Given an optional anchor index (from the last non-shift click) and the
212/// currently clicked index, returns an ascending `Vec<usize>` covering
213/// `[min(anchor, clicked)..=max(anchor, clicked)]`.  If `anchor` is `None`
214/// the clicked index is used as both endpoints, yielding a single-element vec.
215///
216/// This is GUI-only logic — the TUI uses `ascending_range` directly with
217/// its own keyboard-driven anchor handling.
218pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
219    gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
220}
221
222// ── Tests ─────────────────────────────────────────────────────────────────────
223
224#[cfg(test)]
225mod tests {
226    use super::truncate_to_fit;
227
228    // ── fits without truncation ───────────────────────────────────────────
229
230    #[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        // 3 chars × 10 px/char = 30 px → max_chars = 3, char_count = 3 → fits
238        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    // ── truncation with ellipsis ──────────────────────────────────────────
247
248    #[test]
249    fn long_string_truncated_with_ellipsis() {
250        // 30px / 7px = 4 chars max; keeps 3 + "…"
251        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        // 50px / 10px = 5 chars max; result must be ≤ 5 chars (counting "…" as 1)
259        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        // 10px / 10px = 1 char max → only room for "…"
267        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        // Typical sidebar scenario: long branch name at 7.5 px/char, 120 px
274        // available → max 16 chars; keeps 15 + "…"
275        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        // 500 px available, 7 px/char → 71 chars max — summary (18 chars) fits
286        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        // 300 px / 7 px = 42 chars max; keeps 41 + "…"
295        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        // 200 px / 6.5 px = 30 chars max; keeps 29 + "…"
304        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    // ── edge / boundary cases ─────────────────────────────────────────────
311
312    #[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        // 10px / 10px = 1 char max, string is 1 char → fits
330        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        // "héllo" = 5 chars; 40px / 10px = 4 max → keeps 3 + "…"
336        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        // String already short enough — no extra "…" appended
344        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        // 200px / 1px = 200 chars max; 10-char string fits
352        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        // 25px / 7.5px = 3.33 → floor to 3; string "abcd" (4) → truncate
359        let result = truncate_to_fit("abcd", 25.0, 7.5);
360        assert_eq!(result, "ab…");
361    }
362
363    // ── shift_click_range ─────────────────────────────────────────────────
364
365    #[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}