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 pub fn create(&self, percent: f32) -> String {
64 let percent = percent.clamp(0.0, 1.0);
65 let filled_count = (self.length as f32 * percent).round() as u32;
66 let filled_count = filled_count.min(self.length); // clamp to max length
67
68 // Calculate exact capacity to avoid re-allocation
69 // Base length + overhead for start/end/head chars (approximate but sufficient)
70 let capacity = (self.length as usize * 4) + 16;
71 let mut result = String::with_capacity(capacity);
72
73 if let Some(s) = &self.start_char {
74 result.push_str(s);
75 }
76
77 for i in 0..self.length {
78 if let Some(head) = self.head_char {
79 if i == filled_count {
80 result.push(head);
81 continue;
82 }
83 // If we are at the very end and filled_count == length, we might want to show head?
84 // Current logic: head replaces the character AT the split point.
85 // If split == length (100%), i never equals split in 0..length loop?
86 // Wait, if 100%, filled_count=10. i goes 0..9. i never equals 10.
87 // So head is not shown at 100%? That seems wrong if head is a 'thumb'.
88 // But for a progress bar, usually full = all filled.
89 }
90
91 if i < filled_count {
92 result.push(self.filled_char);
93 } else {
94 result.push(self.empty_char);
95 }
96 }
97
98 // Handle 100% case if head should be visible at the very end?
99 // Or if we strictly follow "head is the current step".
100 // If 100%, there is no "next step", so full bar is appropriate.
101
102 if let Some(e) = &self.end_char {
103 result.push_str(e);
104 }
105
106 result
107 }
108}
109
110/// Discord Timestamp formatting styles.
111#[derive(Debug, Clone, Copy, PartialEq)]
112pub enum TimestampStyle {
113 /// Short Time (e.g. 16:20)
114 ShortTime, // t
115 /// Long Time (e.g. 16:20:30)
116 LongTime, // T
117 /// Short Date (e.g. 20/04/2021)
118 ShortDate, // d
119 /// Long Date (e.g. 20 April 2021)
120 LongDate, // D
121 /// Short Date/Time (e.g. 20 April 2021 16:20)
122 ShortDateTime, // f (default)
123 /// Long Date/Time (e.g. Tuesday, 20 April 2021 16:20)
124 LongDateTime, // F
125 /// Relative Time (e.g. 2 months ago)
126 Relative, // R
127}
128
129impl TimestampStyle {
130 pub fn as_char(&self) -> char {
131 match self {
132 Self::ShortTime => 't',
133 Self::LongTime => 'T',
134 Self::ShortDate => 'd',
135 Self::LongDate => 'D',
136 Self::ShortDateTime => 'f',
137 Self::LongDateTime => 'F',
138 Self::Relative => 'R',
139 }
140 }
141}
142
143/// Helper for generating Discord timestamp strings.
144pub struct Timestamp;
145
146impl Timestamp {
147 /// Create a Discord timestamp tag from unix seconds.
148 #[must_use]
149 pub fn from_unix(seconds: i64, style: TimestampStyle) -> String {
150 format!("<t:{}:{}>", seconds, style.as_char())
151 }
152
153 /// Create a Discord timestamp tag from time remaining (now + duration).
154 /// Useful for "Ends in..."
155 #[must_use]
156 pub fn expires_in(duration_secs: u64) -> String {
157 use std::time::{SystemTime, UNIX_EPOCH};
158 let start = SystemTime::now();
159 let since_the_epoch = start
160 .duration_since(UNIX_EPOCH)
161 .expect("Time went backwards")
162 .as_secs();
163 let target = since_the_epoch + duration_secs;
164 format!("<t:{}:R>", target)
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_progress_bar() {
174 let bar = ProgressBar::new(10);
175 // 50% -> 5 filled
176 // 0 1 2 3 4 [5] 6 7 8 9
177 // ▬ ▬ ▬ ▬ ▬ 🔘 ▬ ▬ ▬ ▬
178 let output = bar.create(0.5);
179 assert!(output.contains('🔘'));
180 assert_eq!(output.chars().count(), 10);
181 }
182
183 #[test]
184 fn test_pacman_bar() {
185 let bar = ProgressBar::pacman(10);
186 // 50%
187 // [-----C o o o o]
188 let output = bar.create(0.5);
189 assert!(output.contains("C"));
190 assert!(output.contains("-"));
191 assert!(output.contains("o"));
192 assert!(output.starts_with('['));
193 assert!(output.ends_with(']'));
194 }
195
196 #[test]
197 fn test_timestamp() {
198 let ts = Timestamp::from_unix(1234567890, TimestampStyle::Relative);
199 assert_eq!(ts, "<t:1234567890:R>");
200 }
201}