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
31    let max_chars = (available_px / px_per_char).floor() as usize;
32    let char_count = s.chars().count();
33
34    if char_count <= max_chars {
35        // Fits as-is.
36        s.to_string()
37    } else if max_chars <= 1 {
38        // Only room for the ellipsis itself.
39        "…".to_string()
40    } else {
41        // Take (max_chars - 1) characters then append "…".
42        let mut out: String = s.chars().take(max_chars - 1).collect();
43        out.push('…');
44        out
45    }
46}
47
48// ── Scrollbar helper ──────────────────────────────────────────────────────
49
50/// Standard thin vertical scrollbar direction used across all sidebar panels.
51///
52/// Apply as: `scrollable(content).direction(thin_scrollbar()).style(overlay_scrollbar)`
53pub fn thin_scrollbar() -> iced::widget::scrollable::Direction {
54    iced::widget::scrollable::Direction::Vertical(
55        iced::widget::scrollable::Scrollbar::new()
56            .width(6)
57            .scroller_width(4),
58    )
59}
60
61// ── Context menu helpers ──────────────────────────────────────────────────
62
63/// Thin horizontal separator line for context menus.
64pub fn context_menu_separator<'a, M: 'a>() -> iced::Element<'a, M> {
65    iced::widget::container(iced::widget::Space::new().height(1))
66        .padding(iced::Padding {
67            top: 4.0,
68            right: 0.0,
69            bottom: 4.0,
70            left: 0.0,
71        })
72        .width(iced::Length::Fill)
73        .into()
74}
75
76/// Header label for a context menu panel.
77pub fn context_menu_header<'a, M: 'a>(label: String, muted: iced::Color) -> iced::Element<'a, M> {
78    iced::widget::container(iced::widget::text(label).size(12).color(muted))
79        .padding(iced::Padding {
80            top: 8.0,
81            right: 14.0,
82            bottom: 6.0,
83            left: 14.0,
84        })
85        .width(iced::Length::Fill)
86        .into()
87}
88
89// ── Centered placeholder ──────────────────────────────────────────────────
90
91/// A centered placeholder with an icon and a label, used for empty/loading states.
92pub fn centered_placeholder<'a>(
93    icon_char: char,
94    icon_size: u32,
95    label_text: &str,
96    muted: iced::Color,
97) -> iced::Element<'a, crate::message::Message> {
98    use iced::widget::{column, container, text, Space};
99    use iced::{Alignment, Length};
100
101    let icon_widget = icon!(icon_char, icon_size, muted);
102    let label = text(label_text.to_string()).size(14).color(muted);
103
104    container(
105        column![icon_widget, Space::new().height(8), label]
106            .spacing(4)
107            .align_x(Alignment::Center),
108    )
109    .width(Length::Fill)
110    .height(Length::Fill)
111    .center_x(Length::Fill)
112    .center_y(Length::Fill)
113    .into()
114}
115
116// ── Button helpers ────────────────────────────────────────────────────────
117
118/// Conditionally attach an `on_press` handler to a button.
119///
120/// Replaces the common pattern of building the same button twice in an
121/// if/else just to add or omit `.on_press(msg)`.
122pub fn on_press_maybe<'a>(
123    btn: iced::widget::Button<'a, crate::message::Message>,
124    msg: Option<crate::message::Message>,
125) -> iced::widget::Button<'a, crate::message::Message> {
126    match msg {
127        Some(m) => btn.on_press(m),
128        None => btn,
129    }
130}
131
132// ── Collapsible section header ────────────────────────────────────────────
133
134/// A collapsible section header with a chevron, label, count, and toggle message.
135/// Used for "Local (N)" / "Remote (N)" in the branches sidebar.
136pub fn collapsible_header<'a>(
137    expanded: bool,
138    label: &'a str,
139    count: usize,
140    on_toggle: crate::message::Message,
141    muted: iced::Color,
142) -> iced::Element<'a, crate::message::Message> {
143    use iced::widget::{button, row, text, Space};
144    use iced::Alignment;
145
146    let chevron_char = if expanded {
147        crate::icons::CHEVRON_DOWN
148    } else {
149        crate::icons::CHEVRON_RIGHT
150    };
151    let chevron = icon!(chevron_char, 11, muted);
152
153    button(
154        row![
155            chevron,
156            Space::new().width(4),
157            text(label).size(11).color(muted),
158            Space::new().width(4),
159            text(format!("({count})")).size(10).color(muted),
160        ]
161        .align_y(Alignment::Center),
162    )
163    .padding([4, 8])
164    .width(iced::Length::Fill)
165    .style(crate::theme::ghost_button)
166    .on_press(on_toggle)
167    .into()
168}
169
170// ── Toolbar button ────────────────────────────────────────────────────────
171
172/// A toolbar button with an icon and label text.
173/// Used in the header toolbar for Refresh, Open, Close, etc.
174pub fn toolbar_btn<'a>(
175    icon_widget: impl Into<iced::Element<'a, crate::message::Message>>,
176    label: &'a str,
177    msg: crate::message::Message,
178) -> iced::widget::Button<'a, crate::message::Message> {
179    use iced::widget::{button, row, text, Space};
180    use iced::Alignment;
181
182    button(
183        row![
184            icon_widget.into(),
185            Space::new().width(4),
186            text(label).size(12)
187        ]
188        .align_y(Alignment::Center),
189    )
190    .padding([4, 10])
191    .style(crate::theme::toolbar_button)
192    .on_press(msg)
193}
194
195// ── Panel wrapper ─────────────────────────────────────────────────────────
196
197/// Wrap content in a full-size container with the surface background style.
198pub fn surface_panel<'a>(
199    content: impl Into<iced::Element<'a, crate::message::Message>>,
200    width: iced::Length,
201) -> iced::Element<'a, crate::message::Message> {
202    iced::widget::container(content)
203        .width(width)
204        .height(iced::Length::Fill)
205        .style(crate::theme::surface_style)
206        .into()
207}
208
209// ── Empty list hint ───────────────────────────────────────────────────────
210
211/// Centered muted text shown when a list has no items.
212pub fn empty_list_hint<'a>(
213    label: &'a str,
214    muted: iced::Color,
215) -> iced::Element<'a, crate::message::Message> {
216    iced::widget::container(iced::widget::text(label.to_string()).size(12).color(muted))
217        .padding([12, 8])
218        .width(iced::Length::Fill)
219        .center_x(iced::Length::Fill)
220        .into()
221}
222
223// ── Shift-click range selection ───────────────────────────────────────────────
224
225/// Compute a shift-click range selection.
226///
227/// Given an optional anchor index (from the last non-shift click) and the
228/// currently clicked index, returns an ascending `Vec<usize>` covering
229/// `[min(anchor, clicked)..=max(anchor, clicked)]`.  If `anchor` is `None`
230/// the clicked index is used as both endpoints, yielding a single-element vec.
231///
232/// This is the shared logic behind shift-click range selection in the commit
233/// log, diff file list, and staging area — call it instead of duplicating
234/// the anchor + ascending_range pattern.
235pub fn shift_click_range(anchor: Option<usize>, clicked: usize) -> Vec<usize> {
236    let anchor = anchor.unwrap_or(clicked);
237    gitkraft_core::ascending_range(anchor, clicked)
238}
239
240// ── Tests ─────────────────────────────────────────────────────────────────────
241
242#[cfg(test)]
243mod tests {
244    use super::truncate_to_fit;
245
246    // ── fits without truncation ───────────────────────────────────────────
247
248    #[test]
249    fn short_string_returned_unchanged() {
250        assert_eq!(truncate_to_fit("hi", 100.0, 7.0), "hi");
251    }
252
253    #[test]
254    fn string_exactly_at_limit_is_not_truncated() {
255        // 3 chars × 10 px/char = 30 px → max_chars = 3, char_count = 3 → fits
256        assert_eq!(truncate_to_fit("abc", 30.0, 10.0), "abc");
257    }
258
259    #[test]
260    fn empty_string_returned_unchanged() {
261        assert_eq!(truncate_to_fit("", 100.0, 7.0), "");
262    }
263
264    // ── truncation with ellipsis ──────────────────────────────────────────
265
266    #[test]
267    fn long_string_truncated_with_ellipsis() {
268        // 30px / 7px = 4 chars max; keeps 3 + "…"
269        let result = truncate_to_fit("hello world", 30.0, 7.0);
270        assert_eq!(result, "hel…");
271        assert!(result.ends_with('…'));
272    }
273
274    #[test]
275    fn result_respects_max_char_budget() {
276        // 50px / 10px = 5 chars max; result must be ≤ 5 chars (counting "…" as 1)
277        let result = truncate_to_fit("abcdefghij", 50.0, 10.0);
278        assert_eq!(result.chars().count(), 5);
279        assert!(result.ends_with('…'));
280    }
281
282    #[test]
283    fn one_char_over_limit_gives_ellipsis_only() {
284        // 10px / 10px = 1 char max → only room for "…"
285        let result = truncate_to_fit("ab", 10.0, 10.0);
286        assert_eq!(result, "…");
287    }
288
289    #[test]
290    fn branch_name_with_slash_truncated_correctly() {
291        // Typical sidebar scenario: long branch name at 7.5 px/char, 120 px
292        // available → max 16 chars; keeps 15 + "…"
293        let name = "mario/MARIO-3924_global_design_system_library_publishing";
294        let result = truncate_to_fit(name, 120.0, 7.5);
295        assert_eq!(result.chars().count(), 16);
296        assert!(result.ends_with('…'));
297        assert!(result.starts_with("mario/MARIO-392"));
298    }
299
300    #[test]
301    fn commit_summary_short_enough_shows_fully() {
302        let summary = "Fix typo in README";
303        // 500 px available, 7 px/char → 71 chars max — summary (18 chars) fits
304        let result = truncate_to_fit(summary, 500.0, 7.0);
305        assert_eq!(result, summary);
306    }
307
308    #[test]
309    fn commit_summary_too_long_gets_ellipsis() {
310        let summary =
311            "CARTS-2149: Serialize MediaPickerOptions nav args as URI-encoded JSON strings";
312        // 300 px / 7 px = 42 chars max; keeps 41 + "…"
313        let result = truncate_to_fit(summary, 300.0, 7.0);
314        assert_eq!(result.chars().count(), 42);
315        assert!(result.ends_with('…'));
316    }
317
318    #[test]
319    fn stash_message_truncated_correctly() {
320        let msg = "WIP on mario/MARIO-3869_fix_icons_svg_parsing: f51116a10d4";
321        // 200 px / 6.5 px = 30 chars max; keeps 29 + "…"
322        let result = truncate_to_fit(msg, 200.0, 6.5);
323        assert_eq!(result.chars().count(), 30);
324        assert!(result.ends_with('…'));
325        assert!(result.starts_with("WIP on mario/MARIO-3869_fix_i"));
326    }
327
328    // ── edge / boundary cases ─────────────────────────────────────────────
329
330    #[test]
331    fn zero_available_px_returns_empty() {
332        assert_eq!(truncate_to_fit("hello", 0.0, 7.0), "");
333    }
334
335    #[test]
336    fn negative_available_px_returns_empty() {
337        assert_eq!(truncate_to_fit("hello", -10.0, 7.0), "");
338    }
339
340    #[test]
341    fn zero_px_per_char_returns_empty() {
342        assert_eq!(truncate_to_fit("hello", 100.0, 0.0), "");
343    }
344
345    #[test]
346    fn single_char_string_fits_in_one_char_budget() {
347        // 10px / 10px = 1 char max, string is 1 char → fits
348        assert_eq!(truncate_to_fit("a", 10.0, 10.0), "a");
349    }
350
351    #[test]
352    fn unicode_multibyte_chars_counted_by_char_not_byte() {
353        // "héllo" = 5 chars; 40px / 10px = 4 max → keeps 3 + "…"
354        let result = truncate_to_fit("héllo", 40.0, 10.0);
355        assert_eq!(result.chars().count(), 4);
356        assert_eq!(result, "hél…");
357    }
358
359    #[test]
360    fn unicode_ellipsis_in_source_not_duplicated() {
361        // String already short enough — no extra "…" appended
362        let s = "short…";
363        let result = truncate_to_fit(s, 200.0, 7.0);
364        assert_eq!(result, s);
365    }
366
367    #[test]
368    fn very_small_px_per_char_truncates_to_many_chars() {
369        // 200px / 1px = 200 chars max; 10-char string fits
370        let result = truncate_to_fit("helloworld", 200.0, 1.0);
371        assert_eq!(result, "helloworld");
372    }
373
374    #[test]
375    fn fractional_px_per_char_floors_correctly() {
376        // 25px / 7.5px = 3.33 → floor to 3; string "abcd" (4) → truncate
377        let result = truncate_to_fit("abcd", 25.0, 7.5);
378        assert_eq!(result, "ab…");
379    }
380
381    // ── shift_click_range ─────────────────────────────────────────────────
382
383    #[test]
384    fn shift_click_range_with_anchor_below() {
385        assert_eq!(super::shift_click_range(Some(1), 4), vec![1, 2, 3, 4]);
386    }
387
388    #[test]
389    fn shift_click_range_with_anchor_above() {
390        assert_eq!(super::shift_click_range(Some(4), 1), vec![1, 2, 3, 4]);
391    }
392
393    #[test]
394    fn shift_click_range_same_index() {
395        assert_eq!(super::shift_click_range(Some(3), 3), vec![3]);
396    }
397
398    #[test]
399    fn shift_click_range_no_anchor() {
400        assert_eq!(super::shift_click_range(None, 5), vec![5]);
401    }
402}