Skip to main content

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