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 with_width(scale: Scale, width: f64) -> Self {
199 Self {
200 scale,
201 width: width.max(f64::MIN_POSITIVE),
202 }
203 }
204
205 pub fn key(&self, x: f64) -> i32 {
206 match self.scale {
207 Scale::Log => log_bin_key(x),
208 _ => (self.scale.apply(x) / self.width).floor() as i32,
209 }
210 }
211
212 fn start(&self, k: i32) -> f64 {
215 match self.scale {
216 Scale::Log => (k as f64 - 0.5) * self.width,
217 _ => k as f64 * self.width,
218 }
219 }
220
221 pub fn lower_edge(&self, k: i32) -> usize {
224 if k <= 0 {
225 return 0;
226 }
227 let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
229 while self.key(x as f64) < k {
230 x += 1;
231 }
232 while x > 0 && self.key((x - 1) as f64) >= k {
233 x -= 1;
234 }
235 x
236 }
237
238 fn tick_value(&self, k: i32) -> f64 {
241 self.scale.invert(k as f64 * self.width)
242 }
243
244 fn tick_every(&self, nbins: usize) -> i32 {
246 match self.scale {
247 Scale::Log => 5,
248 _ => (nbins as i32 / 6).max(1),
249 }
250 }
251}
252
253pub struct Binned {
255 pub bins: Binning,
256 pub kmin: i32,
257 pub counts: Vec<usize>,
258}
259
260impl Binned {
261 pub fn new(sorted: &[f32], scale: Scale) -> Self {
263 let (min, max) = match (sorted.first(), sorted.last()) {
264 (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
265 _ => (0.0, 0.0),
266 };
267 let integer = sorted.iter().all(|v| v.fract() == 0.0);
268 let bins = Binning::new(scale, max, integer);
269 let kmin = bins.key(min);
270 let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
271 let counts = count(&bins, kmin, nbins, sorted.iter().copied());
272 Self { bins, kmin, counts }
273 }
274
275 pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
277 count(&self.bins, self.kmin, self.counts.len(), values)
278 }
279
280 pub fn kmax(&self) -> i32 {
281 self.kmin + self.counts.len() as i32 - 1
282 }
283}
284
285fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
288 let mut counts = vec![0; nbins];
289 for v in values {
290 let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
291 counts[i as usize] += 1;
292 }
293 counts
294}
295
296pub fn median(sorted: &[f32]) -> f32 {
298 crate::qc::median_of_sorted(sorted)
299}
300
301pub fn compact(v: f64) -> String {
304 if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
305 format!("{:.2}", v)
306 .trim_end_matches('0')
307 .trim_end_matches('.')
308 .to_string()
309 } else if v < 1e3 {
310 format!("{}", v.round() as i64)
311 } else if v < 1e4 {
312 format!("{:.1}k", v / 1e3)
313 } else if v < 1e6 {
314 format!("{}k", (v / 1e3).round() as u64)
315 } else if v < 1e9 {
316 format!("{:.1}M", v / 1e6)
317 } else {
318 format!("{:.1}G", v / 1e9)
319 }
320}
321
322pub trait BarValue: Copy {
326 fn bar(self) -> f64;
327}
328
329impl BarValue for usize {
330 fn bar(self) -> f64 {
331 self as f64
332 }
333}
334
335impl BarValue for f64 {
336 fn bar(self) -> f64 {
337 self
338 }
339}
340
341pub struct HistPlot<'a, T: BarValue = usize> {
342 pub bins: Binning,
343 pub kmin: i32,
344 pub counts: &'a [T],
345 pub style: &'a dyn Fn(i32) -> Style,
347 pub subset: Option<&'a [T]>,
350 pub y_scale: Scale,
351 pub pointer: Option<i32>,
353 pub marks: Vec<(i32, &'static str, Style)>,
355 pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
360 pub tick_every: Option<i32>,
362}
363
364const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
365
366impl<T: BarValue> HistPlot<'_, T> {
367 pub fn render(&self, buf: &mut Buffer, area: Rect) {
370 let [plot, axis, labels] = Layout::vertical([
371 Constraint::Min(1),
372 Constraint::Length(1),
373 Constraint::Length(1),
374 ])
375 .areas(area);
376 let [gutter, chart] =
377 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
378 if chart.width == 0 || chart.height == 0 {
379 return;
380 }
381 let nbins = self.counts.len();
382 let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
383 let x_of = |k: i32| -> Option<u16> {
384 let i = k - self.kmin;
385 (i >= 0 && (i as usize) < nbins)
386 .then(|| chart.x + i as u16 * bw)
387 .filter(|&x| x < chart.right())
388 };
389
390 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
391 let max_h = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
392 let cells = chart.height as usize * 8;
393 let eighths = |c: T| {
394 if c.bar() <= 0.0 || max_h <= 0.0 {
395 0
396 } else {
397 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
398 }
399 };
400
401 if let Some(x) = self.pointer.and_then(x_of) {
402 for y in chart.top()..chart.bottom() {
403 put(buf, x, y, "┊", ACCENTED);
404 }
405 }
406
407 let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
408 for (i, &c) in counts.iter().enumerate() {
409 let x0 = chart.x + i as u16 * bw;
410 if x0 >= chart.right() {
411 break;
412 }
413 let style = if dim {
414 DIM
415 } else {
416 (self.style)(self.kmin + i as i32)
417 };
418 let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
419 for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
420 let mut fill = top.saturating_sub(j * 8).min(8);
421 if fill == 0 {
422 break;
423 }
424 if under >= (j + 1) * 8 {
428 fill = 8;
429 }
430 for x in x0..(x0 + bw).min(chart.right()) {
431 put(buf, x, y, EIGHTHS[fill - 1], style);
432 }
433 }
434 }
435 };
436 bars(self.counts, None, self.subset.is_some());
437 if let Some(subset) = self.subset {
438 bars(subset, Some(self.counts), false);
439 }
440
441 let gx = gutter.right() - 1;
443 for y in gutter.top()..gutter.bottom() {
444 put(buf, gx, y, "│", DIM);
445 }
446 let mut ylabel = |y: u16, v: f64| {
447 let s = compact(v);
448 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
449 buf.set_string(x, y, &s, DIM);
450 put(buf, gx, y, "┤", DIM);
451 };
452 if max_h > 0.0 {
453 ylabel(gutter.top(), self.y_scale.invert(max_h));
454 if gutter.height >= 6 {
455 ylabel(
456 gutter.top() + gutter.height / 2,
457 self.y_scale.invert(max_h / 2.0),
458 );
459 }
460 }
461
462 for x in axis.left()..axis.right() {
464 let sym = match x.cmp(&gx) {
465 std::cmp::Ordering::Less => " ",
466 std::cmp::Ordering::Equal => "└",
467 std::cmp::Ordering::Greater => "─",
468 };
469 put(buf, x, axis.y, sym, DIM);
470 }
471 let every = self
472 .tick_every
473 .unwrap_or_else(|| self.bins.tick_every(nbins))
474 .max(1);
475 let mut next_free = labels.x;
476 let kmax = self.kmin + nbins as i32 - 1;
477 for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
478 let Some(x) = x_of(k) else { continue };
479 let s = match self.x_label {
480 Some(label) => match label(k) {
481 Some(s) => s,
482 None => continue,
483 },
484 None => compact(self.bins.tick_value(k)),
485 };
486 put(buf, x, axis.y, "┴", DIM);
487 if x >= next_free && x + (s.len() as u16) <= labels.right() {
488 buf.set_string(x, labels.y, &s, DIM);
489 next_free = x + s.len() as u16 + 1;
490 }
491 }
492 let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
493 for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
494 if let Some(x) = x_of(k) {
495 put(buf, x, axis.y, sym, style);
496 }
497 }
498 }
499}
500
501#[cfg(test)]
502#[path = "tests/ui.rs"]
503mod tests;