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// ── Panel wrapper ─────────────────────────────────────────────────────────
198
199/// Wrap content in a full-size container with the surface background style.
200pub fn surface_panel<'a>(
201    content: impl Into<iced::Element<'a, crate::message::Message>>,
202    width: iced::Length,
203) -> iced::Element<'a, crate::message::Message> {
204    iced::widget::container(content)
205        .width(width)
206        .height(iced::Length::Fill)
207        .style(crate::theme::surface_style)
208        .into()
209}
210
211// ── Empty list hint ───────────────────────────────────────────────────────
212
213/// Centered muted text shown when a list has no items.
214pub fn empty_list_hint<'a>(
215    label: &'a str,
216    muted: iced::Color,
217) -> iced::Element<'a, crate::message::Message> {
218    iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
219        .padding([12, 8])
220        .width(iced::Length::Fill)
221        .center_x(iced::Length::Fill)
222        .into()
223}
224
225// ── Shift-click range selection ───────────────────────────────────────────────
226
227/// Compute a shift-click range selection.
228///
229/// Compute a shift-click range selection.
230///
231/// Given an optional anchor index (from the last non-shift click) and the
232/// currently clicked index, returns an ascending `Vec<usize>` covering
233/// `[min(anchor, clicked)..=max(anchor, clicked)]`.  If `anchor` is `None`
234/// the clicked index is used as both endpoints, yielding a single-element vec.
235///
236/// This is GUI-only logic — the TUI uses `ascending_range` directly with
237/// its own keyboard-driven anchor handling.
238pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
239    gitkraft_core::ascending_range(anchor.unwrap_or(clicked), clicked)
240}
241
242// ── Tests ─────────────────────────────────────────────────────────────────────
243
244#[cfg(test)]
245mod tests {
246    use super::truncate_to_fit;
247
248    // ── fits without truncation ───────────────────────────────────────────
249
250    #[test]
251    fn short_string_returned_unchanged() {
252        assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
253    }
254
255    #[test]
256    fn string_exactly_at_limit_is_not_truncated() {
257        // 3 chars × 10 px/char = 30 px → max_chars = 3, char_count = 3 → fits
258        assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
259    }
260
261    #[test]
262    fn empty_string_returned_unchanged() {
263        assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
264    }
265
266    // ── truncation with ellipsis ──────────────────────────────────────────
267
268    #[test]
269    fn long_string_truncated_with_ellipsis() {
270        // 30px / 7px = 4 chars max; keeps 3 + "…"
271        let result = truncate_to_fit("hello world", 30.0, 7.0);
272        assert_eq!(result, "hel…");
273        assert!(result.ends_with('…'));
274    }
275
276    #[test]
277    fn result_respects_max_char_budget() {
278        // 50px / 10px = 5 chars max; result must be ≤ 5 chars (counting "…" as 1)
279        let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
280        assert_eq!(result.chars().count(), 5);
281        assert!(result.ends_with('…'));
282    }
283
284    #[test]
285    fn one_char_over_limit_gives_ellipsis_only() {
286        // 10px / 10px = 1 char max → only room for "…"
287        let result = truncate_to_fit("ab", 10.0, 10.0);
288        assert_eq!(result, "…");
289    }
290
291    #[test]
292    fn branch_name_with_slash_truncated_correctly() {
293        // Typical sidebar scenario: long branch name at 7.5 px/char, 120 px
294        // available → max 16 chars; keeps 15 + "…"
295        let name = "mario/MARIO-3924_global_design_system_library_publishing";
296        let result = truncate_to_fit(name, 120.0, 7.5);
297        assert_eq!(result.chars().count(), 16);
298        assert!(result.ends_with('…'));
299        assert!(result.starts_with("mario/MARIO-392"));
300    }
301
302    #[test]
303    fn commit_summary_short_enough_shows_fully() {
304        let summary = "Fix typo in README";
305        // 500 px available, 7 px/char → 71 chars max — summary (18 chars) fits
306        let result = truncate_to_fit(summary, 500.0, 7.0);
307        assert_eq!(result, summary);
308    }
309
310    #[test]
311    fn commit_summary_too_long_gets_ellipsis() {
312        let summary =
313            "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
314        // 300 px / 7 px = 42 chars max; keeps 41 + "…"
315        let result = truncate_to_fit(summary, 300.0, 7.0);
316        assert_eq!(result.chars().count(), 42);
317        assert!(result.ends_with('…'));
318    }
319
320    #[test]
321    fn stash_message_truncated_correctly() {
322        let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
323        // 200 px / 6.5 px = 30 chars max; keeps 29 + "…"
324        let result = truncate_to_fit(msg, 200.0, 6.5);
325        assert_eq!(result.chars().count(), 30);
326        assert!(result.ends_with('…'));
327        assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
328    }
329
330    // ── edge / boundary cases ─────────────────────────────────────────────
331
332    #[test]
333    fn zero_available_px_returns_empty() {
334        assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
335    }
336
337    #[test]
338    fn negative_available_px_returns_empty() {
339        assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
340    }
341
342    #[test]
343    fn zero_px_per_char_returns_empty() {
344        assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
345    }
346
347    #[test]
348    fn single_char_string_fits_in_one_char_budget() {
349        // 10px / 10px = 1 char max, string is 1 char → fits
350        assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
351    }
352
353    #[test]
354    fn unicode_multibyte_chars_counted_by_char_not_byte() {
355        // "héllo" = 5 chars; 40px / 10px = 4 max → keeps 3 + "…"
356        let result = truncate_to_fit("héllo", 40.0, 10.0);
357        assert_eq!(result.chars().count(), 4);
358        assert_eq!(result, "hél…");
359    }
360
361    #[test]
362    fn unicode_ellipsis_in_source_not_duplicated() {
363        // String already short enough — no extra "…" appended
364        let s = "short…";
365        let result = truncate_to_fit(s, 200.0, 7.0);
366        assert_eq!(result, s);
367    }
368
369    #[test]
370    fn very_small_px_per_char_truncates_to_many_chars() {
371        // 200px / 1px = 200 chars max; 10-char string fits
372        let result = truncate_to_fit("helloworld", 200.0, 1.0);
373        assert_eq!(result, "helloworld");
374    }
375
376    #[test]
377    fn fractional_px_per_char_floors_correctly() {
378        // 25px / 7.5px = 3.33 → floor to 3; string "abcd" (4) → truncate
379        let result = truncate_to_fit("abcd", 25.0, 7.5);
380        assert_eq!(result, "ab…");
381    }
382
383    // ── shift_click_range ─────────────────────────────────────────────────
384
385    #[test]
386    fn shift_click_range_with_anchor_below() {
387        assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
388    }
389
390    #[test]
391    fn shift_click_range_with_anchor_above() {
392        assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
393    }
394
395    #[test]
396    fn shift_click_range_same_index() {
397        assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
398    }
399
400    #[test]
401    fn shift_click_range_no_anchor() {
402        assert_eq!(super::shift_click_range(None, 5), vec![5]);
403    }
404}