pacsea/ui/helpers/
format.rs

1//! Formatting utilities for UI display.
2//!
3//! This module provides functions for formatting package details, byte sizes, and other
4//! UI elements into human-readable strings and ratatui lines.
5
6use ratatui::{
7    style::{Modifier, Style},
8    text::{Line, Span},
9};
10
11use crate::{i18n, state::AppState, theme::Theme};
12
13/// What: Format the current [`AppState::details`] into themed `ratatui` lines.
14///
15/// Inputs:
16/// - `app`: Read-only application state; uses `app.details` to render fields
17/// - `_area_width`: Reserved for future wrapping/layout needs (currently unused)
18/// - `th`: Active theme for colors/styles
19///
20/// Output:
21/// - Vector of formatted lines for the Details pane, ending with a Show/Hide PKGBUILD action line.
22///
23/// Details:
24/// - Applies repo-specific heuristics, formats numeric sizes via `human_bytes`, and appends a
25///   clickable PKGBUILD toggle line using accent styling.
26pub fn format_details_lines(app: &AppState, _area_width: u16, th: &Theme) -> Vec<Line<'static>> {
27    /// What: Build a themed key-value line for the details pane.
28    ///
29    /// Inputs:
30    /// - `key`: Label to display (styled in accent color)
31    /// - `val`: Value text rendered in primary color
32    /// - `th`: Active theme for colors/modifiers
33    ///
34    /// Output:
35    /// - `Line` combining the key/value segments with appropriate styling.
36    ///
37    /// Details:
38    /// - Renders the key in bold accent with a trailing colon and the value in standard text color.
39    fn kv(key: &str, val: String, th: &Theme) -> Line<'static> {
40        Line::from(vec![
41            Span::styled(
42                format!("{key}: "),
43                Style::default()
44                    .fg(th.sapphire)
45                    .add_modifier(Modifier::BOLD),
46            ),
47            Span::styled(val, Style::default().fg(th.text)),
48        ])
49    }
50    let d = &app.details;
51    // Compute display repository using unified Manjaro detection (name prefix or owner).
52    let repo_display = if crate::index::is_manjaro_name_or_owner(&d.name, &d.owner) {
53        "manjaro".to_string()
54    } else {
55        d.repository.clone()
56    };
57    // Each line is a label/value pair derived from the current details view.
58    let mut lines = vec![
59        kv(
60            &i18n::t(app, "app.details.fields.repository"),
61            repo_display,
62            th,
63        ),
64        kv(
65            &i18n::t(app, "app.details.fields.package_name"),
66            d.name.clone(),
67            th,
68        ),
69        kv(
70            &i18n::t(app, "app.details.fields.version"),
71            d.version.clone(),
72            th,
73        ),
74        kv(
75            &i18n::t(app, "app.details.fields.description"),
76            d.description.clone(),
77            th,
78        ),
79        kv(
80            &i18n::t(app, "app.details.fields.architecture"),
81            d.architecture.clone(),
82            th,
83        ),
84        kv(&i18n::t(app, "app.details.fields.url"), d.url.clone(), th),
85        kv(
86            &i18n::t(app, "app.details.fields.licences"),
87            join(&d.licenses),
88            th,
89        ),
90        kv(
91            &i18n::t(app, "app.details.fields.provides"),
92            join(&d.provides),
93            th,
94        ),
95        kv(
96            &i18n::t(app, "app.details.fields.depends_on"),
97            join(&d.depends),
98            th,
99        ),
100        kv(
101            &i18n::t(app, "app.details.fields.optional_dependencies"),
102            join(&d.opt_depends),
103            th,
104        ),
105        kv(
106            &i18n::t(app, "app.details.fields.required_by"),
107            join(&d.required_by),
108            th,
109        ),
110        kv(
111            &i18n::t(app, "app.details.fields.optional_for"),
112            join(&d.optional_for),
113            th,
114        ),
115        kv(
116            &i18n::t(app, "app.details.fields.conflicts_with"),
117            join(&d.conflicts),
118            th,
119        ),
120        kv(
121            &i18n::t(app, "app.details.fields.replaces"),
122            join(&d.replaces),
123            th,
124        ),
125        kv(
126            &i18n::t(app, "app.details.fields.download_size"),
127            d.download_size.map_or_else(
128                || i18n::t(app, "app.details.fields.not_available"),
129                human_bytes,
130            ),
131            th,
132        ),
133        kv(
134            &i18n::t(app, "app.details.fields.install_size"),
135            d.install_size.map_or_else(
136                || i18n::t(app, "app.details.fields.not_available"),
137                human_bytes,
138            ),
139            th,
140        ),
141        kv(
142            &i18n::t(app, "app.details.fields.package_owner"),
143            d.owner.clone(),
144            th,
145        ),
146        kv(
147            &i18n::t(app, "app.details.fields.build_date"),
148            d.build_date.clone(),
149            th,
150        ),
151    ];
152    // Add a clickable helper line to Show/Hide PKGBUILD below Build date
153    let pkgb_label = if app.pkgb_visible {
154        i18n::t(app, "app.details.hide_pkgbuild")
155    } else {
156        i18n::t(app, "app.details.show_pkgbuild")
157    };
158    lines.push(Line::from(vec![Span::styled(
159        pkgb_label,
160        Style::default()
161            .fg(th.mauve)
162            .add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
163    )]));
164
165    // Add a clickable helper line to Show/Hide Comments below PKGBUILD button (AUR packages only)
166    let is_aur = app
167        .results
168        .get(app.selected)
169        .is_some_and(|item| matches!(item.source, crate::state::Source::Aur));
170    if is_aur {
171        let comments_label = if app.comments_visible {
172            i18n::t(app, "app.details.hide_comments")
173        } else {
174            i18n::t(app, "app.details.show_comments")
175        };
176        lines.push(Line::from(vec![Span::styled(
177            comments_label,
178            Style::default()
179                .fg(th.mauve)
180                .add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
181        )]));
182    }
183    lines
184}
185
186/// What: Join a slice of strings with `", "`, falling back to "-" when empty.
187///
188/// Inputs:
189/// - `list`: Slice of strings to format
190///
191/// Output:
192/// - Joined string or "-" when no entries are present.
193///
194/// Details:
195/// - Keeps the details pane compact by representing empty lists with a single dash.
196pub(crate) fn join(list: &[String]) -> String {
197    if list.is_empty() {
198        "-".into()
199    } else {
200        list.join(", ")
201    }
202}
203
204/// What: Format bytes into human-readable string with appropriate unit.
205///
206/// Inputs:
207/// - `value`: Number of bytes to format.
208///
209/// Output:
210/// - Returns a formatted string like "1.5 MiB" or "1024 B".
211///
212/// Details:
213/// - Uses binary units (KiB, MiB, GiB, etc.) and shows integer for bytes < 1024, otherwise 1 decimal place.
214#[must_use]
215pub fn format_bytes(value: u64) -> String {
216    const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
217    #[allow(clippy::cast_precision_loss)]
218    let mut size = value as f64;
219    let mut unit_index = 0usize;
220    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
221        size /= 1024.0;
222        unit_index += 1;
223    }
224    if unit_index == 0 {
225        format!("{value} {}", UNITS[unit_index])
226    } else {
227        format!("{size:.1} {}", UNITS[unit_index])
228    }
229}
230
231/// What: Format signed bytes into human-readable string with +/- prefix.
232///
233/// Inputs:
234/// - `value`: Signed number of bytes to format.
235///
236/// Output:
237/// - Returns a formatted string like "+1.5 MiB" or "-512 KiB" or "0 B".
238///
239/// Details:
240/// - Uses `format_bytes` for magnitude and adds +/- prefix based on sign.
241#[must_use]
242pub fn format_signed_bytes(value: i64) -> String {
243    if value == 0 {
244        return "0 B".to_string();
245    }
246    let magnitude = value.unsigned_abs();
247    if value > 0 {
248        format!("+{}", format_bytes(magnitude))
249    } else {
250        format!("-{}", format_bytes(magnitude))
251    }
252}
253
254/// What: Format a byte count using binary units with one decimal place.
255///
256/// Inputs:
257/// - `n`: Raw byte count to format
258///
259/// Output:
260/// - Size string such as "1.5 KiB" using 1024-based units.
261///
262/// Details:
263/// - Iteratively divides by 1024 up to PiB, retaining one decimal place for readability.
264/// - Always shows decimal place (unlike `format_bytes` which shows integer for bytes < 1024).
265#[must_use]
266pub fn human_bytes(n: u64) -> String {
267    const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
268    #[allow(clippy::cast_precision_loss)]
269    let mut v = n as f64;
270    let mut i = 0;
271    while v >= 1024.0 && i < UNITS.len() - 1 {
272        v /= 1024.0;
273        i += 1;
274    }
275    format!("{v:.1} {}", UNITS[i])
276}