Skip to main content

okf_studio/ui/
widgets.rs

1//! Shared drawing helpers: centered rects, input fields, unicode bar charts,
2//! and the date sparkline.
3
4use 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/// A centered rect of at most `width`×`height` within `area`.
12#[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/// Renders a labeled text-input line: `label ▏value▕` with a cursor block
25/// when active.
26#[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/// A unicode block bar of `value / max`, `width` cells wide, with eighth
71/// precision on the final cell.
72#[must_use]
73#[allow(clippy::cast_precision_loss)] // bar widths and counts are tiny
74pub 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
93/// What one selectable distribution row shows.
94pub struct BarRowSpec<'a> {
95    /// The leading state glyph.
96    pub glyph: &'a str,
97    /// The glyph's (and bar's) style.
98    pub glyph_style: Style,
99    /// The row label.
100    pub label: &'a str,
101    /// The row's count.
102    pub count: usize,
103    /// The scale maximum.
104    pub max: usize,
105    /// The bar's width in cells.
106    pub bar_width: usize,
107    /// Whether the row is selected.
108    pub selected: bool,
109}
110
111/// One selectable distribution row: `glyph label  count ███▌`.
112#[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/// A sparkline over the last `days` days of a date-bucketed series.
131#[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/// A simple line diff for the fix preview: unchanged context around `-`/`+`
165/// runs, computed by trimming the common prefix and suffix.
166#[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/// Highlights fuzzy-match positions within a label.
202#[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}