1use crate::markdown::{str_width, truncate_to_width};
5use crate::theme::Theme;
6use okf_core::Date;
7use ratatui::layout::Rect;
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10
11#[must_use]
13pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
14 let w = width.min(area.width.saturating_sub(2));
15 let h = height.min(area.height.saturating_sub(2));
16 Rect {
17 x: area.x + (area.width.saturating_sub(w)) / 2,
18 y: area.y + (area.height.saturating_sub(h)) / 2,
19 width: w,
20 height: h,
21 }
22}
23
24#[must_use]
27pub fn input_line(
28 label: &str,
29 value: &str,
30 active: bool,
31 theme: &Theme,
32 width: usize,
33) -> Line<'static> {
34 let label_span = Span::styled(format!("{label} "), theme.dim());
35 let avail = width.saturating_sub(str_width(label) + 4);
36 let shown = if str_width(value) > avail {
37 let mut s = String::new();
38 let mut w = 0;
39 for c in value.chars().rev() {
40 let cw = crate::markdown::char_width(c);
41 if w + cw > avail {
42 break;
43 }
44 s.insert(0, c);
45 w += cw;
46 }
47 format!("…{s}")
48 } else {
49 value.to_string()
50 };
51 let mut spans = vec![
52 label_span,
53 Span::styled("▏", theme.dim()),
54 Span::styled(
55 shown,
56 if active {
57 Style::default().add_modifier(Modifier::BOLD)
58 } else {
59 Style::default()
60 },
61 ),
62 ];
63 if active {
64 spans.push(Span::styled("█", theme.accent()));
65 }
66 spans.push(Span::styled("▕", theme.dim()));
67 Line::from(spans)
68}
69
70#[must_use]
73#[allow(clippy::cast_precision_loss)] pub fn block_bar(value: usize, max: usize, width: usize) -> String {
75 const EIGHTHS: [&str; 8] = ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
76 if max == 0 || width == 0 {
77 return String::new();
78 }
79 let cells = value as f64 / max as f64 * width as f64;
80 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
81 let full = cells.floor() as usize;
82 let mut bar = "█".repeat(full.min(width));
83 if full < width {
84 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
85 let rem = ((cells - cells.floor()) * 8.0).round() as usize;
86 if rem > 0 && value > 0 {
87 bar.push_str(EIGHTHS[(rem - 1).min(7)]);
88 }
89 }
90 bar
91}
92
93pub struct BarRowSpec<'a> {
95 pub glyph: &'a str,
97 pub glyph_style: Style,
99 pub label: &'a str,
101 pub count: usize,
103 pub max: usize,
105 pub bar_width: usize,
107 pub selected: bool,
109}
110
111#[must_use]
113pub fn bar_row(spec: &BarRowSpec<'_>, theme: &Theme) -> Line<'static> {
114 let style = if spec.selected {
115 theme.selection()
116 } else {
117 Style::default()
118 };
119 Line::from(vec![
120 Span::styled(format!("{} ", spec.glyph), spec.glyph_style),
121 Span::styled(format!("{:<18}", truncate_to_width(spec.label, 18)), style),
122 Span::styled(format!("{:>4} ", spec.count), style),
123 Span::styled(
124 block_bar(spec.count, spec.max, spec.bar_width),
125 spec.glyph_style,
126 ),
127 ])
128}
129
130#[must_use]
132pub fn date_sparkline(
133 series: &[(Date, usize)],
134 today: Date,
135 days: i64,
136 theme: &Theme,
137) -> Line<'static> {
138 const LEVELS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
139 let start = today.days_since_epoch() - days + 1;
140 let mut buckets = vec![0usize; usize::try_from(days).unwrap_or(0)];
141 for (date, count) in series {
142 let offset = date.days_since_epoch() - start;
143 if offset >= 0
144 && let Ok(ix) = usize::try_from(offset)
145 && ix < buckets.len()
146 {
147 buckets[ix] += count;
148 }
149 }
150 let max = buckets.iter().copied().max().unwrap_or(0).max(1);
151 let text: String = buckets
152 .iter()
153 .map(|&v| {
154 if v == 0 {
155 " "
156 } else {
157 LEVELS[(v * 7).div_ceil(max).min(7)]
158 }
159 })
160 .collect();
161 Line::from(Span::styled(text, theme.accent()))
162}
163
164#[must_use]
167pub fn simple_diff(before: &str, after: &str, theme: &Theme) -> Vec<Line<'static>> {
168 let a: Vec<&str> = before.lines().collect();
169 let b: Vec<&str> = after.lines().collect();
170 let mut prefix = 0;
171 while prefix < a.len() && prefix < b.len() && a[prefix] == b[prefix] {
172 prefix += 1;
173 }
174 let mut suffix = 0;
175 while suffix < a.len() - prefix
176 && suffix < b.len() - prefix
177 && a[a.len() - 1 - suffix] == b[b.len() - 1 - suffix]
178 {
179 suffix += 1;
180 }
181 let mut out = Vec::new();
182 let context = 2usize;
183 for line in &a[prefix.saturating_sub(context)..prefix] {
184 out.push(Line::from(Span::styled(format!(" {line}"), theme.dim())));
185 }
186 for line in &a[prefix..a.len() - suffix] {
187 out.push(Line::from(Span::styled(format!("- {line}"), theme.error())));
188 }
189 for line in &b[prefix..b.len() - suffix] {
190 out.push(Line::from(Span::styled(format!("+ {line}"), theme.ok())));
191 }
192 for line in &a[a.len() - suffix..(a.len() - suffix + context).min(a.len())] {
193 out.push(Line::from(Span::styled(format!(" {line}"), theme.dim())));
194 }
195 if out.is_empty() {
196 out.push(Line::from(Span::styled("(no content change)", theme.dim())));
197 }
198 out
199}
200
201#[must_use]
203pub fn highlight_match(
204 label: &str,
205 indices: &[usize],
206 base: Style,
207 theme: &Theme,
208) -> Vec<Span<'static>> {
209 let mut spans = Vec::new();
210 let mut buf = String::new();
211 let mut matched = String::new();
212 for (i, c) in label.chars().enumerate() {
213 if indices.contains(&i) {
214 if !buf.is_empty() {
215 spans.push(Span::styled(std::mem::take(&mut buf), base));
216 }
217 matched.push(c);
218 } else {
219 if !matched.is_empty() {
220 spans.push(Span::styled(
221 std::mem::take(&mut matched),
222 theme.accent().add_modifier(Modifier::BOLD),
223 ));
224 }
225 buf.push(c);
226 }
227 }
228 if !matched.is_empty() {
229 spans.push(Span::styled(
230 matched,
231 theme.accent().add_modifier(Modifier::BOLD),
232 ));
233 }
234 if !buf.is_empty() {
235 spans.push(Span::styled(buf, base));
236 }
237 spans
238}