1use ratatui::buffer::Buffer;
6use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
7use ratatui::layout::{Constraint, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, BorderType};
11use ratatui::Frame;
12
13use crate::qc::log_bin_key;
14
15pub const ACCENT: Color = Color::Rgb(217, 119, 87);
19
20pub const PLAIN: Style = Style::new();
22pub const DIM: Style = Style::new().add_modifier(Modifier::DIM);
24pub const ACCENTED: Style = Style::new().fg(ACCENT);
26pub const HIGHLIGHT: Style = Style::new().fg(ACCENT).add_modifier(Modifier::BOLD);
28
29pub trait Screen {
31 fn render(&mut self, frame: &mut Frame);
32 fn handle_key(&mut self, key: KeyEvent);
34 fn interrupt(&mut self);
35 fn done(&self) -> bool;
36 fn pending_work(&self) -> Option<String> {
39 None
40 }
41 fn do_work(&mut self) {}
42}
43
44pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> {
48 ratatui::run(|terminal| -> anyhow::Result<()> {
49 while !screen.done() {
50 terminal.draw(|f| screen.render(f))?;
51 if let Event::Key(key) = event::read()? {
52 if key.kind != KeyEventKind::Press {
53 continue;
54 }
55 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
56 screen.interrupt();
57 } else {
58 screen.handle_key(key);
59 }
60 }
61 if let Some(message) = screen.pending_work() {
62 ratatui::restore();
63 eprintln!("{message}");
64 screen.do_work();
65 *terminal = ratatui::try_init()?;
66 }
67 }
68 Ok(())
69 })
70}
71
72pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
74 Line::from(vec![
75 Span::styled(
76 format!(" {badge} "),
77 HIGHLIGHT.add_modifier(Modifier::REVERSED),
78 ),
79 Span::raw(format!(" {title}")),
80 Span::styled(format!(" {extra}"), DIM),
81 ])
82}
83
84pub fn panel(title: String, focused: bool) -> Block<'static> {
87 Block::bordered()
88 .border_type(BorderType::Rounded)
89 .border_style(if focused { PLAIN } else { DIM })
90 .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
92}
93
94pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
96 let mut spans = vec![Span::raw(" ")];
97 for (key, what) in pairs {
98 spans.push(Span::styled(key.to_string(), HIGHLIGHT));
99 spans.push(Span::styled(format!(" {what} "), DIM));
100 }
101 Line::from(spans)
102}
103
104pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
106 let mut spans = vec![
107 Span::raw(format!(" {prompt}")),
108 Span::styled(format!("{text}▏"), HIGHLIGHT),
109 Span::raw(" "),
110 ];
111 spans.extend(help_line(keys).spans);
112 Line::from(spans)
113}
114
115fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
117 buf[(x, y)]
118 .set_symbol(symbol)
119 .set_style(Style::reset().patch(style));
120}
121
122const GUTTER: u16 = 6;
124
125const TARGET_BINS: f64 = 50.0;
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Scale {
131 Log,
132 Sqrt,
133 Linear,
134}
135
136impl Scale {
137 pub fn next(self) -> Self {
138 match self {
139 Scale::Log => Scale::Sqrt,
140 Scale::Sqrt => Scale::Linear,
141 Scale::Linear => Scale::Log,
142 }
143 }
144
145 pub fn name(self) -> &'static str {
146 match self {
147 Scale::Log => "log",
148 Scale::Sqrt => "sqrt",
149 Scale::Linear => "linear",
150 }
151 }
152
153 fn apply(self, v: f64) -> f64 {
154 match self {
155 Scale::Log => (v + 1.0).log10(),
156 Scale::Sqrt => v.max(0.0).sqrt(),
157 Scale::Linear => v,
158 }
159 }
160
161 fn invert(self, t: f64) -> f64 {
162 match self {
163 Scale::Log => 10f64.powf(t) - 1.0,
164 Scale::Sqrt => t * t,
165 Scale::Linear => t,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy)]
173pub struct Binning {
174 pub scale: Scale,
175 width: f64,
177}
178
179impl Binning {
180 pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
183 let span = if integer { max + 1.0 } else { max };
184 let width = match scale {
185 Scale::Log => 0.1,
186 Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
187 _ => scale.apply(span) / TARGET_BINS,
188 };
189 Self {
190 scale,
191 width: width.max(f64::MIN_POSITIVE),
192 }
193 }
194
195 pub fn key(&self, x: f64) -> i32 {
196 match self.scale {
197 Scale::Log => log_bin_key(x),
198 _ => (self.scale.apply(x) / self.width).floor() as i32,
199 }
200 }
201
202 fn start(&self, k: i32) -> f64 {
205 match self.scale {
206 Scale::Log => (k as f64 - 0.5) * self.width,
207 _ => k as f64 * self.width,
208 }
209 }
210
211 pub fn lower_edge(&self, k: i32) -> usize {
214 if k <= 0 {
215 return 0;
216 }
217 let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
219 while self.key(x as f64) < k {
220 x += 1;
221 }
222 while x > 0 && self.key((x - 1) as f64) >= k {
223 x -= 1;
224 }
225 x
226 }
227
228 fn tick_value(&self, k: i32) -> f64 {
231 self.scale.invert(k as f64 * self.width)
232 }
233
234 fn tick_every(&self, nbins: usize) -> i32 {
236 match self.scale {
237 Scale::Log => 5,
238 _ => (nbins as i32 / 6).max(1),
239 }
240 }
241}
242
243pub struct Binned {
245 pub bins: Binning,
246 pub kmin: i32,
247 pub counts: Vec<usize>,
248}
249
250impl Binned {
251 pub fn new(sorted: &[f32], scale: Scale) -> Self {
253 let (min, max) = match (sorted.first(), sorted.last()) {
254 (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
255 _ => (0.0, 0.0),
256 };
257 let integer = sorted.iter().all(|v| v.fract() == 0.0);
258 let bins = Binning::new(scale, max, integer);
259 let kmin = bins.key(min);
260 let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
261 let counts = count(&bins, kmin, nbins, sorted.iter().copied());
262 Self { bins, kmin, counts }
263 }
264
265 pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
267 count(&self.bins, self.kmin, self.counts.len(), values)
268 }
269
270 pub fn kmax(&self) -> i32 {
271 self.kmin + self.counts.len() as i32 - 1
272 }
273}
274
275fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
278 let mut counts = vec![0; nbins];
279 for v in values {
280 let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
281 counts[i as usize] += 1;
282 }
283 counts
284}
285
286pub fn median(sorted: &[f32]) -> f32 {
288 crate::qc::median_of_sorted(sorted)
289}
290
291pub fn compact(v: f64) -> String {
294 if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
295 format!("{:.2}", v)
296 .trim_end_matches('0')
297 .trim_end_matches('.')
298 .to_string()
299 } else if v < 1e3 {
300 format!("{}", v.round() as i64)
301 } else if v < 1e4 {
302 format!("{:.1}k", v / 1e3)
303 } else if v < 1e6 {
304 format!("{}k", (v / 1e3).round() as u64)
305 } else if v < 1e9 {
306 format!("{:.1}M", v / 1e6)
307 } else {
308 format!("{:.1}G", v / 1e9)
309 }
310}
311
312pub struct HistPlot<'a> {
314 pub bins: Binning,
315 pub kmin: i32,
316 pub counts: &'a [usize],
317 pub style: &'a dyn Fn(i32) -> Style,
319 pub subset: Option<&'a [usize]>,
322 pub y_scale: Scale,
323 pub pointer: Option<i32>,
325 pub marks: Vec<(i32, &'static str, Style)>,
327}
328
329const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
330
331impl HistPlot<'_> {
332 pub fn render(&self, buf: &mut Buffer, area: Rect) {
335 let [plot, axis, labels] = Layout::vertical([
336 Constraint::Min(1),
337 Constraint::Length(1),
338 Constraint::Length(1),
339 ])
340 .areas(area);
341 let [gutter, chart] =
342 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
343 if chart.width == 0 || chart.height == 0 {
344 return;
345 }
346 let nbins = self.counts.len();
347 let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
348 let x_of = |k: i32| -> Option<u16> {
349 let i = k - self.kmin;
350 (i >= 0 && (i as usize) < nbins)
351 .then(|| chart.x + i as u16 * bw)
352 .filter(|&x| x < chart.right())
353 };
354
355 let height = |c: usize| self.y_scale.apply(c as f64);
356 let max_h = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
357 let cells = chart.height as usize * 8;
358 let eighths = |c: usize| {
359 if c == 0 || max_h <= 0.0 {
360 0
361 } else {
362 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
363 }
364 };
365
366 if let Some(x) = self.pointer.and_then(x_of) {
367 for y in chart.top()..chart.bottom() {
368 put(buf, x, y, "┊", ACCENTED);
369 }
370 }
371
372 let mut bars = |counts: &[usize], behind: Option<&[usize]>, dim: bool| {
373 for (i, &c) in counts.iter().enumerate() {
374 let x0 = chart.x + i as u16 * bw;
375 if x0 >= chart.right() {
376 break;
377 }
378 let style = if dim {
379 DIM
380 } else {
381 (self.style)(self.kmin + i as i32)
382 };
383 let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
384 for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
385 let mut fill = top.saturating_sub(j * 8).min(8);
386 if fill == 0 {
387 break;
388 }
389 if under >= (j + 1) * 8 {
393 fill = 8;
394 }
395 for x in x0..(x0 + bw).min(chart.right()) {
396 put(buf, x, y, EIGHTHS[fill - 1], style);
397 }
398 }
399 }
400 };
401 bars(self.counts, None, self.subset.is_some());
402 if let Some(subset) = self.subset {
403 bars(subset, Some(self.counts), false);
404 }
405
406 let gx = gutter.right() - 1;
408 for y in gutter.top()..gutter.bottom() {
409 put(buf, gx, y, "│", DIM);
410 }
411 let mut ylabel = |y: u16, v: f64| {
412 let s = compact(v);
413 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
414 buf.set_string(x, y, &s, DIM);
415 put(buf, gx, y, "┤", DIM);
416 };
417 if max_h > 0.0 {
418 ylabel(gutter.top(), self.y_scale.invert(max_h));
419 if gutter.height >= 6 {
420 ylabel(
421 gutter.top() + gutter.height / 2,
422 self.y_scale.invert(max_h / 2.0),
423 );
424 }
425 }
426
427 for x in axis.left()..axis.right() {
429 let sym = match x.cmp(&gx) {
430 std::cmp::Ordering::Less => " ",
431 std::cmp::Ordering::Equal => "└",
432 std::cmp::Ordering::Greater => "─",
433 };
434 put(buf, x, axis.y, sym, DIM);
435 }
436 let every = self.bins.tick_every(nbins);
437 let mut next_free = labels.x;
438 let kmax = self.kmin + nbins as i32 - 1;
439 for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
440 let Some(x) = x_of(k) else { continue };
441 put(buf, x, axis.y, "┴", DIM);
442 let s = compact(self.bins.tick_value(k));
443 if x >= next_free && x + (s.len() as u16) <= labels.right() {
444 buf.set_string(x, labels.y, &s, DIM);
445 next_free = x + s.len() as u16 + 1;
446 }
447 }
448 let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
449 for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
450 if let Some(x) = x_of(k) {
451 put(buf, x, axis.y, sym, style);
452 }
453 }
454 }
455}
456
457#[cfg(test)]
458#[path = "tests/ui.rs"]
459mod tests;