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 fn tick(&mut self) -> bool {
45 false
46 }
47}
48
49pub const TICK: std::time::Duration = std::time::Duration::from_millis(200);
51
52struct HeldLogs;
54
55impl HeldLogs {
56 fn new() -> Self {
57 crate::aux::logging::hold_logs(true);
58 HeldLogs
59 }
60}
61
62impl Drop for HeldLogs {
63 fn drop(&mut self) {
64 crate::aux::logging::hold_logs(false);
65 }
66}
67
68pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> {
74 ratatui::run(|terminal| -> anyhow::Result<()> {
75 let held = HeldLogs::new();
76 let mut redraw = true;
77 while !screen.done() {
78 if redraw {
79 terminal.draw(|f| screen.render(f))?;
80 }
81 if !event::poll(TICK)? {
82 redraw = screen.tick();
83 continue;
84 }
85 redraw = true;
86 if let Event::Key(key) = event::read()? {
87 if key.kind != KeyEventKind::Press {
88 continue;
89 }
90 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
91 screen.interrupt();
92 } else {
93 screen.handle_key(key);
94 }
95 }
96 if let Some(message) = screen.pending_work() {
97 ratatui::restore();
98 crate::aux::logging::hold_logs(false);
99 eprintln!("{message}");
100 screen.do_work();
101 crate::aux::logging::hold_logs(true);
102 *terminal = ratatui::try_init()?;
103 }
104 }
105 ratatui::restore();
107 drop(held);
108 Ok(())
109 })
110}
111
112pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
114 Line::from(vec![
115 Span::styled(
116 format!(" {badge} "),
117 HIGHLIGHT.add_modifier(Modifier::REVERSED),
118 ),
119 Span::raw(format!(" {title}")),
120 Span::styled(format!(" {extra}"), DIM),
121 ])
122}
123
124pub fn panel(title: String, focused: bool) -> Block<'static> {
127 Block::bordered()
128 .border_type(BorderType::Rounded)
129 .border_style(if focused { PLAIN } else { DIM })
130 .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
132}
133
134pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
136 let mut spans = vec![Span::raw(" ")];
137 for (key, what) in pairs {
138 spans.push(Span::styled(key.to_string(), HIGHLIGHT));
139 spans.push(Span::styled(format!(" {what} "), DIM));
140 }
141 Line::from(spans)
142}
143
144pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
146 let mut spans = vec![
147 Span::raw(format!(" {prompt}")),
148 Span::styled(format!("{text}▏"), HIGHLIGHT),
149 Span::raw(" "),
150 ];
151 spans.extend(help_line(keys).spans);
152 Line::from(spans)
153}
154
155fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
157 buf[(x, y)]
158 .set_symbol(symbol)
159 .set_style(Style::reset().patch(style));
160}
161
162const GUTTER: u16 = 6;
164
165const TARGET_BINS: f64 = 50.0;
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum Scale {
171 Log,
172 Sqrt,
173 Linear,
174}
175
176impl Scale {
177 pub fn next(self) -> Self {
178 match self {
179 Scale::Log => Scale::Sqrt,
180 Scale::Sqrt => Scale::Linear,
181 Scale::Linear => Scale::Log,
182 }
183 }
184
185 pub fn name(self) -> &'static str {
186 match self {
187 Scale::Log => "log",
188 Scale::Sqrt => "sqrt",
189 Scale::Linear => "linear",
190 }
191 }
192
193 fn apply(self, v: f64) -> f64 {
194 match self {
195 Scale::Log => (v + 1.0).log10(),
196 Scale::Sqrt => v.max(0.0).sqrt(),
197 Scale::Linear => v,
198 }
199 }
200
201 fn invert(self, t: f64) -> f64 {
202 match self {
203 Scale::Log => 10f64.powf(t) - 1.0,
204 Scale::Sqrt => t * t,
205 Scale::Linear => t,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Copy)]
213pub struct Binning {
214 pub scale: Scale,
215 width: f64,
217}
218
219impl Binning {
220 pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
223 let span = if integer { max + 1.0 } else { max };
224 let width = match scale {
225 Scale::Log => 0.1,
226 Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
227 _ => scale.apply(span) / TARGET_BINS,
228 };
229 Self {
230 scale,
231 width: width.max(f64::MIN_POSITIVE),
232 }
233 }
234
235 pub fn with_width(scale: Scale, width: f64) -> Self {
239 Self {
240 scale,
241 width: width.max(f64::MIN_POSITIVE),
242 }
243 }
244
245 pub fn key(&self, x: f64) -> i32 {
246 match self.scale {
247 Scale::Log => log_bin_key(x),
248 _ => (self.scale.apply(x) / self.width).floor() as i32,
249 }
250 }
251
252 fn start(&self, k: i32) -> f64 {
255 match self.scale {
256 Scale::Log => (k as f64 - 0.5) * self.width,
257 _ => k as f64 * self.width,
258 }
259 }
260
261 pub fn lower_edge(&self, k: i32) -> usize {
264 if k <= 0 {
265 return 0;
266 }
267 let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
269 while self.key(x as f64) < k {
270 x += 1;
271 }
272 while x > 0 && self.key((x - 1) as f64) >= k {
273 x -= 1;
274 }
275 x
276 }
277
278 fn tick_value(&self, k: i32) -> f64 {
281 self.scale.invert(k as f64 * self.width)
282 }
283
284 fn tick_every(&self, nbins: usize) -> i32 {
286 match self.scale {
287 Scale::Log => 5,
288 _ => (nbins as i32 / 6).max(1),
289 }
290 }
291}
292
293pub struct Binned {
295 pub bins: Binning,
296 pub kmin: i32,
297 pub counts: Vec<usize>,
298}
299
300impl Binned {
301 pub fn new(sorted: &[f32], scale: Scale) -> Self {
303 let (min, max) = match (sorted.first(), sorted.last()) {
304 (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
305 _ => (0.0, 0.0),
306 };
307 let integer = sorted.iter().all(|v| v.fract() == 0.0);
308 let bins = Binning::new(scale, max, integer);
309 let kmin = bins.key(min);
310 let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
311 let counts = count(&bins, kmin, nbins, sorted.iter().copied());
312 Self { bins, kmin, counts }
313 }
314
315 pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
317 count(&self.bins, self.kmin, self.counts.len(), values)
318 }
319
320 pub fn kmax(&self) -> i32 {
321 self.kmin + self.counts.len() as i32 - 1
322 }
323}
324
325fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
328 let mut counts = vec![0; nbins];
329 for v in values {
330 let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
331 counts[i as usize] += 1;
332 }
333 counts
334}
335
336pub fn median(sorted: &[f32]) -> f32 {
338 crate::qc::median_of_sorted(sorted)
339}
340
341pub fn compact(v: f64) -> String {
344 if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
345 format!("{:.2}", v)
346 .trim_end_matches('0')
347 .trim_end_matches('.')
348 .to_string()
349 } else if v < 1e3 {
350 format!("{}", v.round() as i64)
351 } else if v < 1e4 {
352 format!("{:.1}k", v / 1e3)
353 } else if v < 1e6 {
354 format!("{}k", (v / 1e3).round() as u64)
355 } else if v < 1e9 {
356 format!("{:.1}M", v / 1e6)
357 } else {
358 format!("{:.1}G", v / 1e9)
359 }
360}
361
362pub trait BarValue: Copy {
366 fn bar(self) -> f64;
367}
368
369impl BarValue for usize {
370 fn bar(self) -> f64 {
371 self as f64
372 }
373}
374
375impl BarValue for f64 {
376 fn bar(self) -> f64 {
377 self
378 }
379}
380
381pub struct HistPlot<'a, T: BarValue = usize> {
382 pub bins: Binning,
383 pub kmin: i32,
384 pub counts: &'a [T],
385 pub style: &'a dyn Fn(i32) -> Style,
387 pub subset: Option<&'a [T]>,
390 pub y_scale: Scale,
391 pub y_max: Option<f64>,
394 pub pointer: Option<i32>,
396 pub marks: Vec<(i32, &'static str, Style)>,
398 pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
403 pub tick_every: Option<i32>,
405}
406
407const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
408
409impl<T: BarValue> HistPlot<'_, T> {
410 pub fn render(&self, buf: &mut Buffer, area: Rect) {
413 let [plot, axis, labels] = Layout::vertical([
414 Constraint::Min(1),
415 Constraint::Length(1),
416 Constraint::Length(1),
417 ])
418 .areas(area);
419 let [gutter, chart] =
420 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
421 if chart.width == 0 || chart.height == 0 {
422 return;
423 }
424 let nbins = self.counts.len();
425 let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
426 let x_of = |k: i32| -> Option<u16> {
427 let i = k - self.kmin;
428 (i >= 0 && (i as usize) < nbins)
429 .then(|| chart.x + i as u16 * bw)
430 .filter(|&x| x < chart.right())
431 };
432
433 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
434 let tallest = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
435 let max_h = self
436 .y_max
437 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
438 let cells = chart.height as usize * 8;
439 let eighths = |c: T| {
440 if c.bar() <= 0.0 || max_h <= 0.0 {
441 0
442 } else {
443 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
444 }
445 };
446
447 if let Some(x) = self.pointer.and_then(x_of) {
448 for y in chart.top()..chart.bottom() {
449 put(buf, x, y, "┊", ACCENTED);
450 }
451 }
452
453 let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
454 for (i, &c) in counts.iter().enumerate() {
455 let x0 = chart.x + i as u16 * bw;
456 if x0 >= chart.right() {
457 break;
458 }
459 let style = if dim {
460 DIM
461 } else {
462 (self.style)(self.kmin + i as i32)
463 };
464 let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
465 for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
466 let mut fill = top.saturating_sub(j * 8).min(8);
467 if fill == 0 {
468 break;
469 }
470 if under >= (j + 1) * 8 {
474 fill = 8;
475 }
476 for x in x0..(x0 + bw).min(chart.right()) {
477 put(buf, x, y, EIGHTHS[fill - 1], style);
478 }
479 }
480 }
481 };
482 bars(self.counts, None, self.subset.is_some());
483 if let Some(subset) = self.subset {
484 bars(subset, Some(self.counts), false);
485 }
486
487 let gx = gutter.right() - 1;
489 for y in gutter.top()..gutter.bottom() {
490 put(buf, gx, y, "│", DIM);
491 }
492 let mut ylabel = |y: u16, v: f64| {
493 let s = compact(v);
494 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
495 buf.set_string(x, y, &s, DIM);
496 put(buf, gx, y, "┤", DIM);
497 };
498 if max_h > 0.0 {
499 ylabel(gutter.top(), self.y_scale.invert(max_h));
500 if gutter.height >= 6 {
501 ylabel(
502 gutter.top() + gutter.height / 2,
503 self.y_scale.invert(max_h / 2.0),
504 );
505 }
506 }
507
508 for x in axis.left()..axis.right() {
510 let sym = match x.cmp(&gx) {
511 std::cmp::Ordering::Less => " ",
512 std::cmp::Ordering::Equal => "└",
513 std::cmp::Ordering::Greater => "─",
514 };
515 put(buf, x, axis.y, sym, DIM);
516 }
517 let every = self
518 .tick_every
519 .unwrap_or_else(|| self.bins.tick_every(nbins))
520 .max(1);
521 let mut next_free = labels.x;
522 let kmax = self.kmin + nbins as i32 - 1;
523 for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
524 let Some(x) = x_of(k) else { continue };
525 let s = match self.x_label {
526 Some(label) => match label(k) {
527 Some(s) => s,
528 None => continue,
529 },
530 None => compact(self.bins.tick_value(k)),
531 };
532 put(buf, x, axis.y, "┴", DIM);
533 if x >= next_free && x + (s.len() as u16) <= labels.right() {
534 buf.set_string(x, labels.y, &s, DIM);
535 next_free = x + s.len() as u16 + 1;
536 }
537 }
538 let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
539 for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
540 if let Some(x) = x_of(k) {
541 put(buf, x, axis.y, sym, style);
542 }
543 }
544 }
545}
546
547pub struct MirrorSide<'a, T: BarValue = f64> {
549 pub counts: &'a [T],
550 pub subset: Option<&'a [T]>,
553 pub style: Style,
554 pub name: &'a str,
556}
557
558pub struct MirrorPlot<'a, T: BarValue = f64> {
563 pub up: MirrorSide<'a, T>,
564 pub down: MirrorSide<'a, T>,
565 pub y_scale: Scale,
566 pub y_max: Option<f64>,
568 pub y_labels: Option<[String; 3]>,
571 pub pointer: Option<usize>,
573 pub x_label: Option<&'a dyn Fn(usize) -> Option<String>>,
576}
577
578impl<T: BarValue> MirrorPlot<'_, T> {
579 pub fn render(&self, buf: &mut Buffer, area: Rect) {
583 let [plot, axis, labels] = Layout::vertical([
584 Constraint::Min(1),
585 Constraint::Length(1),
586 Constraint::Length(1),
587 ])
588 .areas(area);
589 let [gutter, chart] =
590 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
591 if chart.width == 0 || chart.height < 3 {
592 return;
593 }
594 let half = (chart.height - 1) / 2;
595 let zero = chart.top() + half;
596 let x_of = |i: usize| Some(chart.x + i as u16).filter(|&x| x < chart.right());
597
598 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
599 let all = self.up.counts.iter().chain(self.down.counts);
600 let tallest = all.map(|&c| height(c)).fold(0.0, f64::max);
601 let max_h = self
602 .y_max
603 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
604 let cells = half as usize * 2;
605 let halves = |c: T| {
606 if c.bar() <= 0.0 || max_h <= 0.0 {
607 0
608 } else {
609 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
610 }
611 };
612
613 if let Some(x) = self.pointer.and_then(x_of) {
614 for y in chart.top()..chart.top() + 2 * half + 1 {
615 put(buf, x, y, "┊", ACCENTED);
616 }
617 }
618 for x in chart.left()..chart.right() {
619 put(buf, x, zero, "─", DIM);
620 }
621 for (side, up) in [(&self.up, true), (&self.down, false)] {
622 let (whole, part) = if up { ("█", "▄") } else { ("█", "▀") };
623 let mut bars = |counts: &[T], behind: Option<&[T]>, style: Style| {
624 for (i, &c) in counts.iter().enumerate() {
625 let Some(x) = x_of(i) else { break };
626 let (top, under) = (halves(c), behind.map_or(0, |b| halves(b[i])));
627 for k in 0..top.div_ceil(2) {
628 let y = if up {
629 zero - 1 - k as u16
630 } else {
631 zero + 1 + k as u16
632 };
633 let full = 2 * k + 2 <= top || under >= 2 * k + 2;
636 put(buf, x, y, if full { whole } else { part }, style);
637 }
638 }
639 };
640 match side.subset {
641 Some(subset) => {
642 bars(side.counts, None, DIM);
643 bars(subset, Some(side.counts), side.style);
644 }
645 None => bars(side.counts, None, side.style),
646 }
647 }
648 buf.set_string(chart.x, chart.top(), self.up.name, DIM);
649 buf.set_string(chart.x, chart.top() + 2 * half, self.down.name, DIM);
650
651 let gx = gutter.right() - 1;
653 for y in gutter.top()..gutter.bottom() {
654 put(buf, gx, y, "│", DIM);
655 }
656 let own = || {
657 let top = compact(self.y_scale.invert(max_h));
658 [top.clone(), "0".to_string(), top]
659 };
660 let ys = [chart.top(), zero, chart.top() + 2 * half];
661 for (y, s) in ys
662 .into_iter()
663 .zip(self.y_labels.clone().unwrap_or_else(own))
664 {
665 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
666 buf.set_string(x, y, &s, DIM);
667 put(buf, gx, y, "┤", DIM);
668 }
669
670 for x in axis.left()..axis.right() {
672 let sym = match x.cmp(&gx) {
673 std::cmp::Ordering::Less => " ",
674 std::cmp::Ordering::Equal => "└",
675 std::cmp::Ordering::Greater => "─",
676 };
677 put(buf, x, axis.y, sym, DIM);
678 }
679 let n = self.up.counts.len().max(self.down.counts.len());
680 let mut next_free = labels.x;
681 for i in 0..n {
682 let Some(x) = x_of(i) else { break };
683 let Some(s) = self.x_label.and_then(|label| label(i)) else {
684 continue;
685 };
686 put(buf, x, axis.y, "┴", DIM);
687 if x >= next_free && x + (s.len() as u16) <= labels.right() {
688 buf.set_string(x, labels.y, &s, DIM);
689 next_free = x + s.len() as u16 + 1;
690 }
691 }
692 if let Some(x) = self.pointer.and_then(x_of) {
693 put(buf, x, axis.y, "▲", HIGHLIGHT);
694 }
695 }
696}
697
698#[cfg(test)]
699#[path = "tests/ui.rs"]
700mod tests;