1use ratatui::buffer::Buffer;
6use std::time::{Duration, Instant};
7
8use legume_tui::{Keyboard, Modes, Mouse, Round, Session};
9use ratatui::crossterm::event::{
10 self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
11 MouseEvent,
12};
13use ratatui::layout::{Constraint, Layout, Rect};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, BorderType};
17use ratatui::Frame;
18
19use crate::qc::Axis;
20
21pub const ACCENT: Color = Color::Rgb(217, 119, 87);
25
26pub const PLAIN: Style = Style::new();
28pub const DIM: Style = Style::new().add_modifier(Modifier::DIM);
30pub const ACCENTED: Style = Style::new().fg(ACCENT);
32pub const HIGHLIGHT: Style = Style::new().fg(ACCENT).add_modifier(Modifier::BOLD);
34
35pub trait Screen {
37 fn render(&mut self, frame: &mut Frame);
38 fn handle_key(&mut self, key: KeyEvent);
40 fn interrupt(&mut self);
41 fn done(&self) -> bool;
42 fn pending_work(&self) -> Option<String> {
45 None
46 }
47 fn do_work(&mut self) {}
48 fn tick(&mut self) -> bool {
51 false
52 }
53 fn takes_mouse(&self) -> bool {
56 false
57 }
58 fn mouse(&mut self, _event: MouseEvent) -> bool {
62 false
63 }
64 fn reports_chords(&self) -> bool {
68 false
69 }
70 fn take_notice(&mut self) -> Option<String> {
73 None
74 }
75 fn status(&self) -> Option<String> {
78 None
79 }
80}
81
82pub fn modes_of(screen: &impl Screen) -> Modes {
87 Modes::new()
88 .mouse(if screen.takes_mouse() {
89 Mouse::Buttons
90 } else {
91 Mouse::Off
92 })
93 .keyboard_enhancement(if screen.reports_chords() {
94 Keyboard::On(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
95 } else {
96 Keyboard::Off
97 })
98}
99
100fn send(text: &str) -> bool {
102 let mut out = std::io::stdout();
103 std::io::Write::write_all(&mut out, text.as_bytes()).is_ok()
104 && std::io::Write::flush(&mut out).is_ok()
105}
106
107pub fn copy(text: &str) {
111 send(&format!("\x1b]52;c;{}\x07", base64(text.as_bytes())));
112}
113
114fn base64(bytes: &[u8]) -> String {
116 const ABC: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
117 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
118 for chunk in bytes.chunks(3) {
119 let n = chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
120 for k in 0..4 {
121 out.push(match k <= chunk.len() {
122 true => ABC[(n >> (18 - 6 * k) & 63) as usize] as char,
123 false => '=',
124 });
125 }
126 }
127 out
128}
129
130pub fn notify(text: &str) {
133 let text: String = text.chars().filter(|c| !c.is_control()).collect();
134 send(&format!("\x07\x1b]9;{text}\x07"));
135}
136
137pub const TICK: std::time::Duration = std::time::Duration::from_millis(200);
139
140pub struct Tui<'s> {
148 session: &'s Session,
149 terminal: Option<ratatui::DefaultTerminal>,
150 modes: Modes,
152 _held: legume_tui::logs::Hold,
153}
154
155impl<'s> Tui<'s> {
156 pub fn new(session: &'s Session) -> Self {
158 Self {
159 session,
160 terminal: None,
161 modes: Modes::new(),
162 _held: legume_tui::logs::hold(),
163 }
164 }
165
166 pub fn session(&self) -> &'s Session {
167 self.session
168 }
169
170 pub fn stop_requested(&self) -> bool {
173 self.session.stop_requested()
174 }
175
176 pub fn run<S: Screen>(&mut self, screen: &mut S) -> anyhow::Result<()> {
178 self.run_with(screen, S::handle_key)
179 }
180
181 pub fn run_with<S: Screen>(
191 &mut self,
192 screen: &mut S,
193 mut on_key: impl FnMut(&mut S, KeyEvent),
194 ) -> anyhow::Result<()> {
195 let session = self.session;
196 if session.stop_requested() {
197 screen.interrupt();
198 return Ok(());
199 }
200 let wanted = modes_of(screen);
201 self.up(wanted)?;
202 let Some(terminal) = self.terminal.as_mut() else {
203 unreachable!("the screen is up");
204 };
205 let mut redraw = true;
206 let mut next_tick = Instant::now() + TICK;
209 while !screen.done() {
210 if session.stop_requested() {
211 screen.interrupt();
212 break;
213 }
214 if redraw {
215 session.draw(terminal, |f| screen.render(f))?;
216 let wanted = modes_of(screen);
217 if wanted != self.modes {
218 session.set_modes(wanted)?;
219 self.modes = wanted;
220 }
221 }
222 if let Some(status) = screen.status() {
223 session.status(&status);
224 }
225 if let Some(notice) = screen.take_notice() {
226 notify(¬ice);
227 }
228 redraw = false;
229 let wait = next_tick.saturating_duration_since(Instant::now());
230 match session.poll(terminal, wait)? {
231 Round::Event(mut event) => loop {
232 redraw |= feed(screen, &mut on_key, event);
233 if screen.done()
234 || screen.pending_work().is_some()
235 || session.stop_requested()
236 || !event::poll(Duration::ZERO)?
237 {
238 break;
239 }
240 event = event::read()?;
241 },
242 Round::Repainted => redraw = true,
244 _ => {}
245 }
246 if Instant::now() >= next_tick {
247 redraw |= screen.tick();
248 next_tick = Instant::now() + TICK;
249 }
250 if let Some(message) = screen.pending_work() {
251 session.off_screen(|| {
252 eprintln!("{message}");
253 screen.do_work();
254 })?;
255 redraw = true;
256 }
257 }
258 Ok(())
259 }
260
261 fn up(&mut self, modes: Modes) -> anyhow::Result<()> {
264 if self.terminal.is_none() {
265 self.terminal = Some(self.session.terminal_with(modes)?);
266 } else if modes != self.modes {
267 self.session.set_modes(modes)?;
268 }
269 self.modes = modes;
270 Ok(())
271 }
272
273 pub fn work<T>(&mut self, f: impl FnOnce() -> T) -> anyhow::Result<T> {
280 if !self.session.screen_up() {
281 return Ok(self.session.stoppable(f));
282 }
283 Ok(self.session.off_screen(f)?)
284 }
285}
286
287impl Drop for Tui<'_> {
288 fn drop(&mut self) {
289 if self.terminal.take().is_some() {
290 self.session.restore();
291 }
292 }
293}
294
295fn feed<S: Screen>(
298 screen: &mut S,
299 on_key: &mut impl FnMut(&mut S, KeyEvent),
300 event: Event,
301) -> bool {
302 match event {
303 Event::Key(key) if key.kind != KeyEventKind::Press => false,
304 Event::Key(key)
305 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') =>
306 {
307 screen.interrupt();
308 true
309 }
310 Event::Key(key) => {
311 on_key(screen, key);
312 true
313 }
314 Event::Mouse(m) => screen.mouse(m),
315 Event::Resize(..) => true,
316 _ => false,
317 }
318}
319
320pub fn run_screen<S: Screen>(screen: &mut S) -> anyhow::Result<()> {
324 run_screen_with(screen, S::handle_key)
325}
326
327pub fn run_screen_with<S: Screen>(
331 screen: &mut S,
332 on_key: impl FnMut(&mut S, KeyEvent),
333) -> anyhow::Result<()> {
334 let session = Session::plain(&super::TOOL);
335 run_screen_in_with(&session, screen, on_key)
336}
337
338pub fn run_screen_in<S: Screen>(session: &Session, screen: &mut S) -> anyhow::Result<()> {
342 run_screen_in_with(session, screen, S::handle_key)
343}
344
345pub fn run_screen_in_with<S: Screen>(
347 session: &Session,
348 screen: &mut S,
349 on_key: impl FnMut(&mut S, KeyEvent),
350) -> anyhow::Result<()> {
351 Tui::new(session).run_with(screen, on_key)
352}
353
354pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
356 Line::from(vec![
357 Span::styled(
358 format!(" {badge} "),
359 HIGHLIGHT.add_modifier(Modifier::REVERSED),
360 ),
361 Span::raw(format!(" {title}")),
362 Span::styled(format!(" {extra}"), DIM),
363 ])
364}
365
366pub fn title_bar(frame: &mut Frame, area: Rect, title: Line<'static>, keys: &[(String, String)]) {
371 if keys.is_empty() {
372 frame.render_widget(title, area);
373 return;
374 }
375 let pairs: Vec<_> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
376 let keys = help_line(&pairs);
377 let width = (keys.width() as u16).min(area.width);
378 let [left, right] =
379 Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]).areas(area);
380 frame.render_widget(title, left);
381 frame.render_widget(keys, right);
382}
383
384pub fn panel(title: String, focused: bool) -> Block<'static> {
387 Block::bordered()
388 .border_type(BorderType::Rounded)
389 .border_style(if focused { PLAIN } else { DIM })
390 .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
392}
393
394pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
396 let mut spans = vec![Span::raw(" ")];
397 for (key, what) in pairs {
398 spans.push(Span::styled(key.to_string(), HIGHLIGHT));
399 spans.push(Span::styled(format!(" {what} "), DIM));
400 }
401 Line::from(spans)
402}
403
404pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
406 let mut spans = vec![
407 Span::raw(format!(" {prompt}")),
408 Span::styled(format!("{text}▏"), HIGHLIGHT),
409 Span::raw(" "),
410 ];
411 spans.extend(help_line(keys).spans);
412 Line::from(spans)
413}
414
415fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
417 buf[(x, y)]
418 .set_symbol(symbol)
419 .set_style(Style::reset().patch(style));
420}
421
422const GUTTER: u16 = 6;
424
425const TARGET_BINS: f64 = 50.0;
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum Scale {
431 Log,
432 Sqrt,
433 Linear,
434}
435
436impl Scale {
437 pub fn next(self) -> Self {
438 match self {
439 Scale::Log => Scale::Sqrt,
440 Scale::Sqrt => Scale::Linear,
441 Scale::Linear => Scale::Log,
442 }
443 }
444
445 pub fn name(self) -> &'static str {
446 match self {
447 Scale::Log => "log",
448 Scale::Sqrt => "sqrt",
449 Scale::Linear => "linear",
450 }
451 }
452
453 fn apply(self, v: f64) -> f64 {
454 match self {
455 Scale::Log => (v + 1.0).log10(),
456 Scale::Sqrt => v.max(0.0).sqrt(),
457 Scale::Linear => v,
458 }
459 }
460
461 fn invert(self, t: f64) -> f64 {
462 match self {
463 Scale::Log => 10f64.powf(t) - 1.0,
464 Scale::Sqrt => t * t,
465 Scale::Linear => t,
466 }
467 }
468}
469
470#[derive(Debug, Clone, Copy)]
474pub struct Binning {
475 pub scale: Scale,
476 width: f64,
478 shift: f64,
480}
481
482impl Binning {
483 pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
488 let shift = if integer || max >= 20.0 {
489 1.0
490 } else {
491 (max / 1e3).clamp(1e-9, 1.0)
492 };
493 Self::shifted(scale, max, integer, shift)
494 }
495
496 fn shifted(scale: Scale, max: f64, integer: bool, shift: f64) -> Self {
498 let span = if integer { max + 1.0 } else { max };
499 let mut bins = Self {
500 scale,
501 width: 1.0,
502 shift,
503 };
504 let width = match scale {
505 Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
506 _ => bins.t(span) / TARGET_BINS,
507 };
508 bins.width = width.max(f64::MIN_POSITIVE);
509 bins
510 }
511
512 pub fn with_width(scale: Scale, width: f64) -> Self {
516 Self {
517 scale,
518 width: width.max(f64::MIN_POSITIVE),
519 shift: 1.0,
520 }
521 }
522
523 fn t(&self, x: f64) -> f64 {
525 match self.scale {
526 Scale::Log => (x.max(0.0) + self.shift).ln() - self.shift.ln(),
527 _ => self.scale.apply(x),
528 }
529 }
530
531 fn value(&self, t: f64) -> f64 {
533 match self.scale {
534 Scale::Log => ((t + self.shift.ln()).exp() - self.shift).max(0.0),
535 _ => self.scale.invert(t),
536 }
537 }
538
539 pub fn key(&self, x: f64) -> i32 {
540 (self.t(x) / self.width).floor() as i32
541 }
542
543 fn start(&self, k: i32) -> f64 {
545 k as f64 * self.width
546 }
547
548 pub fn lower_edge(&self, k: i32) -> usize {
551 if k <= 0 {
552 return 0;
553 }
554 let mut x = self.value(self.start(k)).ceil().max(0.0) as usize;
556 while self.key(x as f64) < k {
557 x += 1;
558 }
559 while x > 0 && self.key((x - 1) as f64) >= k {
560 x -= 1;
561 }
562 x
563 }
564
565 fn tick_value(&self, k: i32) -> f64 {
567 self.value(self.start(k))
568 }
569
570 fn tick_every(&self, nbins: usize) -> i32 {
572 (nbins as i32 / 6).max(1)
573 }
574
575 fn ticks(&self, kmin: i32, kmax: i32) -> Vec<(i32, f64)> {
579 match self.scale {
580 Scale::Log => {
581 let axis = Axis {
582 lo: self.tick_value(kmin),
583 hi: self.value(self.start(kmax + 1)),
584 log: true,
585 shift: self.shift,
586 };
587 axis.ticks().into_iter().map(|v| (self.key(v), v)).collect()
588 }
589 _ => {
590 let every = self.tick_every((kmax - kmin + 1).max(1) as usize);
591 (kmin..=kmax)
592 .filter(|k| k % every == 0)
593 .map(|k| (k, self.tick_value(k)))
594 .collect()
595 }
596 }
597 }
598}
599
600pub struct Binned {
602 pub bins: Binning,
603 pub kmin: i32,
604 pub counts: Vec<usize>,
605}
606
607impl Binned {
608 pub fn new(sorted: &[f32], scale: Scale) -> Self {
611 let (min, max) = match (sorted.first(), sorted.last()) {
612 (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
613 _ => (0.0, 0.0),
614 };
615 let integer = sorted.iter().all(|v| v.fract() == 0.0);
616 let shift = match integer {
618 true => 1.0,
619 false => Axis::fit(sorted, true).shift,
620 };
621 let bins = Binning::shifted(scale, max, integer, shift);
622 let kmin = bins.key(min);
623 let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
624 let counts = count(&bins, kmin, nbins, sorted.iter().copied());
625 Self { bins, kmin, counts }
626 }
627
628 pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
630 count(&self.bins, self.kmin, self.counts.len(), values)
631 }
632
633 pub fn kmax(&self) -> i32 {
634 self.kmin + self.counts.len() as i32 - 1
635 }
636}
637
638fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
641 let mut counts = vec![0; nbins];
642 for v in values.filter(|v| v.is_finite()) {
643 let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
644 counts[i as usize] += 1;
645 }
646 counts
647}
648
649pub fn median(sorted: &[f32]) -> f32 {
651 crate::qc::median_of_sorted(sorted)
652}
653
654pub fn compact(v: f64) -> String {
657 if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
658 format!("{:.2}", v)
659 .trim_end_matches('0')
660 .trim_end_matches('.')
661 .to_string()
662 } else if v < 1e3 {
663 format!("{}", v.round() as i64)
664 } else if v < 1e4 {
665 format!("{:.1}k", v / 1e3)
666 } else if v < 1e6 {
667 format!("{}k", (v / 1e3).round() as u64)
668 } else if v < 1e9 {
669 format!("{:.1}M", v / 1e6)
670 } else {
671 format!("{:.1}G", v / 1e9)
672 }
673}
674
675pub trait BarValue: Copy {
679 fn bar(self) -> f64;
680}
681
682impl BarValue for usize {
683 fn bar(self) -> f64 {
684 self as f64
685 }
686}
687
688impl BarValue for f64 {
689 fn bar(self) -> f64 {
690 self
691 }
692}
693
694pub struct HistPlot<'a, T: BarValue = usize> {
695 pub bins: Binning,
696 pub kmin: i32,
697 pub counts: &'a [T],
698 pub style: &'a dyn Fn(i32) -> Style,
700 pub subset: Option<&'a [T]>,
703 pub y_scale: Scale,
704 pub y_max: Option<f64>,
707 pub pointer: Option<i32>,
709 pub marks: Vec<(i32, &'static str, Style)>,
711 pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
716 pub tick_every: Option<i32>,
718}
719
720const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
721
722impl<T: BarValue> HistPlot<'_, T> {
723 pub fn render(&self, buf: &mut Buffer, area: Rect) {
726 let [plot, axis, labels] = Layout::vertical([
727 Constraint::Min(1),
728 Constraint::Length(1),
729 Constraint::Length(1),
730 ])
731 .areas(area);
732 let [gutter, chart] =
733 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
734 if chart.width == 0 || chart.height == 0 {
735 return;
736 }
737 let nbins = self.counts.len();
738 let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
739 let x_of = |k: i32| -> Option<u16> {
740 let i = k - self.kmin;
741 (i >= 0 && (i as usize) < nbins)
742 .then(|| chart.x + i as u16 * bw)
743 .filter(|&x| x < chart.right())
744 };
745
746 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
747 let tallest = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
748 let max_h = self
749 .y_max
750 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
751 let cells = chart.height as usize * 8;
752 let eighths = |c: T| {
753 if c.bar() <= 0.0 || max_h <= 0.0 {
754 0
755 } else {
756 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
757 }
758 };
759
760 if let Some(x) = self.pointer.and_then(x_of) {
761 for y in chart.top()..chart.bottom() {
762 put(buf, x, y, "┊", ACCENTED);
763 }
764 }
765
766 let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
767 for (i, &c) in counts.iter().enumerate() {
768 let x0 = chart.x + i as u16 * bw;
769 if x0 >= chart.right() {
770 break;
771 }
772 let style = if dim {
773 DIM
774 } else {
775 (self.style)(self.kmin + i as i32)
776 };
777 let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
778 for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
779 let mut fill = top.saturating_sub(j * 8).min(8);
780 if fill == 0 {
781 break;
782 }
783 if under >= (j + 1) * 8 {
787 fill = 8;
788 }
789 for x in x0..(x0 + bw).min(chart.right()) {
790 put(buf, x, y, EIGHTHS[fill - 1], style);
791 }
792 }
793 }
794 };
795 bars(self.counts, None, self.subset.is_some());
796 if let Some(subset) = self.subset {
797 bars(subset, Some(self.counts), false);
798 }
799
800 let gx = gutter.right() - 1;
802 for y in gutter.top()..gutter.bottom() {
803 put(buf, gx, y, "│", DIM);
804 }
805 let mut ylabel = |y: u16, v: f64| {
806 let s = compact(v);
807 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
808 buf.set_string(x, y, &s, DIM);
809 put(buf, gx, y, "┤", DIM);
810 };
811 if max_h > 0.0 {
812 ylabel(gutter.top(), self.y_scale.invert(max_h));
813 if gutter.height >= 6 {
814 ylabel(
815 gutter.top() + gutter.height / 2,
816 self.y_scale.invert(max_h / 2.0),
817 );
818 }
819 }
820
821 for x in axis.left()..axis.right() {
823 let sym = match x.cmp(&gx) {
824 std::cmp::Ordering::Less => " ",
825 std::cmp::Ordering::Equal => "└",
826 std::cmp::Ordering::Greater => "─",
827 };
828 put(buf, x, axis.y, sym, DIM);
829 }
830 let mut next_free = labels.x;
831 let kmax = self.kmin + nbins as i32 - 1;
832 let ticks: Vec<(i32, Option<f64>)> = match (self.x_label, self.tick_every) {
835 (None, None) => self
836 .bins
837 .ticks(self.kmin, kmax)
838 .into_iter()
839 .map(|(k, v)| (k, Some(v)))
840 .collect(),
841 _ => {
842 let every = self
843 .tick_every
844 .unwrap_or_else(|| self.bins.tick_every(nbins))
845 .max(1);
846 (self.kmin..=kmax)
847 .filter(|k| k % every == 0)
848 .map(|k| (k, None))
849 .collect()
850 }
851 };
852 for (k, value) in ticks {
853 let Some(x) = x_of(k) else { continue };
854 let s = match (self.x_label, value) {
855 (Some(label), _) => match label(k) {
856 Some(s) => s,
857 None => continue,
858 },
859 (None, Some(v)) => compact(v),
860 (None, None) => compact(self.bins.tick_value(k)),
861 };
862 put(buf, x, axis.y, "┴", DIM);
863 if x >= next_free && x + (s.len() as u16) <= labels.right() {
864 buf.set_string(x, labels.y, &s, DIM);
865 next_free = x + s.len() as u16 + 1;
866 }
867 }
868 let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
869 for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
870 if let Some(x) = x_of(k) {
871 put(buf, x, axis.y, sym, style);
872 }
873 }
874 }
875}
876
877pub struct MirrorSide<'a, T: BarValue = f64> {
879 pub counts: &'a [T],
880 pub subset: Option<&'a [T]>,
883 pub style: Style,
884 pub name: &'a str,
886}
887
888pub struct MirrorPlot<'a, T: BarValue = f64> {
893 pub up: MirrorSide<'a, T>,
894 pub down: MirrorSide<'a, T>,
895 pub y_scale: Scale,
896 pub y_max: Option<f64>,
898 pub y_labels: Option<[String; 3]>,
901 pub pointer: Option<usize>,
903 pub x_label: Option<&'a dyn Fn(usize) -> Option<String>>,
906}
907
908impl<T: BarValue> MirrorPlot<'_, T> {
909 pub fn render(&self, buf: &mut Buffer, area: Rect) {
913 let [plot, axis, labels] = Layout::vertical([
914 Constraint::Min(1),
915 Constraint::Length(1),
916 Constraint::Length(1),
917 ])
918 .areas(area);
919 let [gutter, chart] =
920 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
921 if chart.width == 0 || chart.height < 3 {
922 return;
923 }
924 let half = (chart.height - 1) / 2;
925 let zero = chart.top() + half;
926 let x_of = |i: usize| Some(chart.x + i as u16).filter(|&x| x < chart.right());
927
928 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
929 let all = self.up.counts.iter().chain(self.down.counts);
930 let tallest = all.map(|&c| height(c)).fold(0.0, f64::max);
931 let max_h = self
932 .y_max
933 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
934 let cells = half as usize * 2;
935 let halves = |c: T| {
936 if c.bar() <= 0.0 || max_h <= 0.0 {
937 0
938 } else {
939 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
940 }
941 };
942
943 if let Some(x) = self.pointer.and_then(x_of) {
944 for y in chart.top()..chart.top() + 2 * half + 1 {
945 put(buf, x, y, "┊", ACCENTED);
946 }
947 }
948 for x in chart.left()..chart.right() {
949 put(buf, x, zero, "─", DIM);
950 }
951 for (side, up) in [(&self.up, true), (&self.down, false)] {
952 let (whole, part) = if up { ("█", "▄") } else { ("█", "▀") };
953 let mut bars = |counts: &[T], behind: Option<&[T]>, style: Style| {
954 for (i, &c) in counts.iter().enumerate() {
955 let Some(x) = x_of(i) else { break };
956 let (top, under) = (halves(c), behind.map_or(0, |b| halves(b[i])));
957 for k in 0..top.div_ceil(2) {
958 let y = if up {
959 zero - 1 - k as u16
960 } else {
961 zero + 1 + k as u16
962 };
963 let full = 2 * k + 2 <= top || under >= 2 * k + 2;
966 put(buf, x, y, if full { whole } else { part }, style);
967 }
968 }
969 };
970 match side.subset {
971 Some(subset) => {
972 bars(side.counts, None, DIM);
973 bars(subset, Some(side.counts), side.style);
974 }
975 None => bars(side.counts, None, side.style),
976 }
977 }
978 buf.set_string(chart.x, chart.top(), self.up.name, DIM);
979 buf.set_string(chart.x, chart.top() + 2 * half, self.down.name, DIM);
980
981 let gx = gutter.right() - 1;
983 for y in gutter.top()..gutter.bottom() {
984 put(buf, gx, y, "│", DIM);
985 }
986 let own = || {
987 let top = compact(self.y_scale.invert(max_h));
988 [top.clone(), "0".to_string(), top]
989 };
990 let ys = [chart.top(), zero, chart.top() + 2 * half];
991 for (y, s) in ys
992 .into_iter()
993 .zip(self.y_labels.clone().unwrap_or_else(own))
994 {
995 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
996 buf.set_string(x, y, &s, DIM);
997 put(buf, gx, y, "┤", DIM);
998 }
999
1000 for x in axis.left()..axis.right() {
1002 let sym = match x.cmp(&gx) {
1003 std::cmp::Ordering::Less => " ",
1004 std::cmp::Ordering::Equal => "└",
1005 std::cmp::Ordering::Greater => "─",
1006 };
1007 put(buf, x, axis.y, sym, DIM);
1008 }
1009 let n = self.up.counts.len().max(self.down.counts.len());
1010 let mut next_free = labels.x;
1011 for i in 0..n {
1012 let Some(x) = x_of(i) else { break };
1013 let Some(s) = self.x_label.and_then(|label| label(i)) else {
1014 continue;
1015 };
1016 put(buf, x, axis.y, "┴", DIM);
1017 if x >= next_free && x + (s.len() as u16) <= labels.right() {
1018 buf.set_string(x, labels.y, &s, DIM);
1019 next_free = x + s.len() as u16 + 1;
1020 }
1021 }
1022 if let Some(x) = self.pointer.and_then(x_of) {
1023 put(buf, x, axis.y, "▲", HIGHLIGHT);
1024 }
1025 }
1026}
1027
1028#[cfg(test)]
1029#[path = "tests/ui.rs"]
1030mod tests;