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    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
34// ── Scrollbar helper ──────────────────────────────────────────────────────
35
36/// Standard thin vertical scrollbar direction used across all sidebar panels.
37///
38/// Apply as: `scrollable(content).direction(thin_scrollbar()).style(overlay_scrollbar)`
39pub 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
47// ── Context menu helpers ──────────────────────────────────────────────────
48
49/// Thin horizontal separator line for context menus.
50pub 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
62/// Header label for a context menu panel.
63pub 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
75// ── Centered placeholder ──────────────────────────────────────────────────
76
77/// A centered placeholder with an icon and a label, used for empty/loading states.
78pub 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
102// ── Button helpers ────────────────────────────────────────────────────────
103
104/// Conditionally attach an `on_press` handler to a button.
105///
106/// Replaces the common pattern of building the same button twice in an
107/// if/else just to add or omit `.on_press(msg)`.
108pub 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
118// ── Collapsible section header ────────────────────────────────────────────
119
120/// A collapsible section header with a chevron, label, count, and toggle message.
121/// Used for "Local (N)" / "Remote (N)" in the branches sidebar.
122pub 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
156// ── Toolbar button ────────────────────────────────────────────────────────
157
158/// A toolbar button with an icon and label text.
159/// Used in the header toolbar for Refresh, Open, Close, etc.
160pub 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
181// ── Panel wrapper ─────────────────────────────────────────────────────────
182
183/// Wrap content in a full-size container with the surface background style.
184pub 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
195// ── Empty list hint ───────────────────────────────────────────────────────
196
197/// Centered muted text shown when a list has no items.
198pub 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
209// ── Shift-click range selection ───────────────────────────────────────────────
210
211/// Compute a shift-click range selection.
212///
213/// Compute a shift-click range selection.
214///
215/// Given an optional anchor index (from the last non-shift click) and the
216/// currently clicked index, returns an ascending `Vec<usize>` covering
217/// `[min(anchor, clicked)..=max(anchor, clicked)]`.  If `anchor` is `None`
218/// the clicked index is used as both endpoints, yielding a single-element vec.
219///
220/// This is GUI-only logic — the TUI uses `ascending_range` directly with
221/// its own keyboard-driven anchor handling.
222pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
223    gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
224}
225
226// ── Tests ─────────────────────────────────────────────────────────────────────
227
228#[cfg(test)]
229mod tests {
230    use super::truncate_to_fit;
231
232    // ── fits without truncation ───────────────────────────────────────────
233
234    #[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        // 3 chars × 10 px/char = 30 px → max_chars = 3, char_count = 3 → fits
242        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    // ── truncation with ellipsis ──────────────────────────────────────────
251
252    #[test]
253    fn long_string_truncated_with_ellipsis() {
254        // 30px / 7px = 4 chars max; keeps 3 + "…"
255        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        // 50px / 10px = 5 chars max; result must be ≤ 5 chars (counting "…" as 1)
263        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        // 10px / 10px = 1 char max → only room for "…"
271        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        // Typical sidebar scenario: long branch name at 7.5 px/char, 120 px
278        // available → max 16 chars; keeps 15 + "…"
279        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        // 500 px available, 7 px/char → 71 chars max — summary (18 chars) fits
290        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        // 300 px / 7 px = 42 chars max; keeps 41 + "…"
299        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        // 200 px / 6.5 px = 30 chars max; keeps 29 + "…"
308        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    // ── edge / boundary cases ─────────────────────────────────────────────
315
316    #[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        // 10px / 10px = 1 char max, string is 1 char → fits
334        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        // "héllo" = 5 chars; 40px / 10px = 4 max → keeps 3 + "…"
340        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        // String already short enough — no extra "…" appended
348        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        // 200px / 1px = 200 chars max; 10-char string fits
356        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        // 25px / 7.5px = 3.33 → floor to 3; string "abcd" (4) → truncate
363        let result = truncate_to_fit("abcd", 25.0, 7.5);
364        assert_eq!(result, "ab…");
365    }
366
367    // ── shift_click_range ─────────────────────────────────────────────────
368
369    #[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}