titanium_model/
ui.rs

1//! UI Utilities for creating rich embeds and messages.
2
3/// A text-based progress bar generator.
4#[derive(Debug, Clone)]
5pub struct ProgressBar {
6    /// Total number of steps.
7    pub length: u32,
8    /// Character for the filled portion.
9    pub filled_char: char,
10    /// Character for the empty portion.
11    pub empty_char: char,
12    /// Character for the current position (head).
13    pub head_char: Option<char>,
14    /// Opening bracket/character.
15    pub start_char: Option<String>,
16    /// Closing bracket/character.
17    pub end_char: Option<String>,
18}
19
20impl Default for ProgressBar {
21    fn default() -> Self {
22        Self {
23            length: 10,
24            filled_char: '▬',
25            empty_char: '▬',
26            head_char: Some('🔘'),
27            start_char: None,
28            end_char: None,
29        }
30    }
31}
32
33impl ProgressBar {
34    /// Create a new default progress bar with specified length.
35    #[must_use]
36    pub fn new(length: u32) -> Self {
37        Self {
38            length,
39            ..Default::default()
40        }
41    }
42
43    /// Create a new "Pac-Man" style progress bar (Arch Linux style).
44    /// e.g. [------C o o o]
45    #[must_use]
46    pub fn pacman(length: u32) -> Self {
47        Self {
48            length,
49            filled_char: '-',     // Eaten path
50            empty_char: 'o',      // Pellets
51            head_char: Some('C'), // Pac-Man
52            start_char: Some("[".to_string()),
53            end_char: Some("]".to_string()),
54        }
55    }
56
57    /// Generate the progress bar string.
58    ///
59    /// # Arguments
60    /// * `percent` - A value between 0.0 and 1.0.
61    #[must_use]
62    #[inline]
63    #[allow(
64        clippy::cast_possible_truncation,
65        clippy::cast_sign_loss,
66        clippy::cast_precision_loss
67    )]
68    pub fn create(&self, percent: f32) -> String {
69        let percent = percent.clamp(0.0, 1.0);
70        let filled_count = (self.length as f32 * percent).round() as u32;
71        let filled_count = filled_count.min(self.length); // clamp to max length
72
73        // Calculate exact capacity to avoid re-allocation
74        // Base length + overhead for start/end/head chars (approximate but sufficient)
75        let capacity = (self.length as usize * 4) + 16;
76        let mut result = String::with_capacity(capacity);
77
78        if let Some(s) = &self.start_char {
79            result.push_str(s);
80        }
81
82        for i in 0..self.length {
83            if let Some(head) = self.head_char {
84                if i == filled_count {
85                    result.push(head);
86                    continue;
87                }
88                // If we are at the very end and filled_count == length, we might want to show head?
89                // Current logic: head replaces the character AT the split point.
90                // If split == length (100%), i never equals split in 0..length loop?
91                // Wait, if 100%, filled_count=10. i goes 0..9. i never equals 10.
92                // So head is not shown at 100%? That seems wrong if head is a 'thumb'.
93                // But for a progress bar, usually full = all filled.
94            }
95
96            if i < filled_count {
97                result.push(self.filled_char);
98            } else {
99                result.push(self.empty_char);
100            }
101        }
102
103        // Handle 100% case if head should be visible at the very end?
104        // Or if we strictly follow "head is the current step".
105        // If 100%, there is no "next step", so full bar is appropriate.
106
107        if let Some(e) = &self.end_char {
108            result.push_str(e);
109        }
110
111        result
112    }
113}
114
115/// Discord Timestamp formatting styles.
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub enum TimestampStyle {
118    /// Short Time (e.g. 16:20)
119    ShortTime, // t
120    /// Long Time (e.g. 16:20:30)
121    LongTime, // T
122    /// Short Date (e.g. 20/04/2021)
123    ShortDate, // d
124    /// Long Date (e.g. 20 April 2021)
125    LongDate, // D
126    /// Short Date/Time (e.g. 20 April 2021 16:20)
127    ShortDateTime, // f (default)
128    /// Long Date/Time (e.g. Tuesday, 20 April 2021 16:20)
129    LongDateTime, // F
130    /// Relative Time (e.g. 2 months ago)
131    Relative, // R
132}
133
134impl TimestampStyle {
135    #[must_use]
136    pub fn as_char(&self) -> char {
137        match self {
138            Self::ShortTime => 't',
139            Self::LongTime => 'T',
140            Self::ShortDate => 'd',
141            Self::LongDate => 'D',
142            Self::ShortDateTime => 'f',
143            Self::LongDateTime => 'F',
144            Self::Relative => 'R',
145        }
146    }
147}
148
149/// Helper for generating Discord timestamp strings.
150pub struct Timestamp;
151
152impl Timestamp {
153    /// Create a Discord timestamp tag from unix seconds.
154    #[must_use]
155    pub fn from_unix(seconds: i64, style: TimestampStyle) -> String {
156        format!("<t:{}:{}>", seconds, style.as_char())
157    }
158
159    /// Create a Discord timestamp tag from time remaining (now + duration).
160    /// Useful for "Ends in..."
161    ///
162    /// # Panics
163    ///
164    /// Panics if the system time is before the unix epoch.
165    #[must_use]
166    pub fn expires_in(duration_secs: u64) -> String {
167        use std::time::{SystemTime, UNIX_EPOCH};
168        let start = SystemTime::now();
169        let since_the_epoch = start
170            .duration_since(UNIX_EPOCH)
171            .expect("Time went backwards")
172            .as_secs();
173        let target = since_the_epoch + duration_secs;
174        format!("<t:{target}:R>")
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_progress_bar() {
184        let bar = ProgressBar::new(10);
185        // 50% -> 5 filled
186        // 0 1 2 3 4 [5] 6 7 8 9
187        // ▬ ▬ ▬ ▬ ▬ 🔘 ▬ ▬ ▬ ▬
188        let output = bar.create(0.5);
189        assert!(output.contains('🔘'));
190        assert_eq!(output.chars().count(), 10);
191    }
192
193    #[test]
194    fn test_pacman_bar() {
195        let bar = ProgressBar::pacman(10);
196        // 50%
197        // [-----C o o o o]
198        let output = bar.create(0.5);
199        assert!(output.contains("C"));
200        assert!(output.contains("-"));
201        assert!(output.contains("o"));
202        assert!(output.starts_with('['));
203        assert!(output.ends_with(']'));
204    }
205
206    #[test]
207    fn test_timestamp() {
208        let ts = Timestamp::from_unix(1234567890, TimestampStyle::Relative);
209        assert_eq!(ts, "<t:1234567890:R>");
210    }
211}