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::log_bin_key;
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 notify(text: &str) {
110 let text: String = text.chars().filter(|c| !c.is_control()).collect();
111 send(&format!("\x07\x1b]9;{text}\x07"));
112}
113
114pub const TICK: std::time::Duration = std::time::Duration::from_millis(200);
116
117pub struct Tui<'s> {
125 session: &'s Session,
126 terminal: Option<ratatui::DefaultTerminal>,
127 modes: Modes,
129 _held: legume_tui::logs::Hold,
130}
131
132impl<'s> Tui<'s> {
133 pub fn new(session: &'s Session) -> Self {
135 Self {
136 session,
137 terminal: None,
138 modes: Modes::new(),
139 _held: legume_tui::logs::hold(),
140 }
141 }
142
143 pub fn session(&self) -> &'s Session {
144 self.session
145 }
146
147 pub fn stop_requested(&self) -> bool {
150 self.session.stop_requested()
151 }
152
153 pub fn run<S: Screen>(&mut self, screen: &mut S) -> anyhow::Result<()> {
155 self.run_with(screen, S::handle_key)
156 }
157
158 pub fn run_with<S: Screen>(
168 &mut self,
169 screen: &mut S,
170 mut on_key: impl FnMut(&mut S, KeyEvent),
171 ) -> anyhow::Result<()> {
172 let session = self.session;
173 if session.stop_requested() {
174 screen.interrupt();
175 return Ok(());
176 }
177 let wanted = modes_of(screen);
178 self.up(wanted)?;
179 let Some(terminal) = self.terminal.as_mut() else {
180 unreachable!("the screen is up");
181 };
182 let mut redraw = true;
183 let mut next_tick = Instant::now() + TICK;
186 while !screen.done() {
187 if session.stop_requested() {
188 screen.interrupt();
189 break;
190 }
191 if redraw {
192 session.draw(terminal, |f| screen.render(f))?;
193 let wanted = modes_of(screen);
194 if wanted != self.modes {
195 session.set_modes(wanted)?;
196 self.modes = wanted;
197 }
198 }
199 if let Some(status) = screen.status() {
200 session.status(&status);
201 }
202 if let Some(notice) = screen.take_notice() {
203 notify(¬ice);
204 }
205 redraw = false;
206 let wait = next_tick.saturating_duration_since(Instant::now());
207 match session.poll(terminal, wait)? {
208 Round::Event(mut event) => loop {
209 redraw |= feed(screen, &mut on_key, event);
210 if screen.done()
211 || screen.pending_work().is_some()
212 || session.stop_requested()
213 || !event::poll(Duration::ZERO)?
214 {
215 break;
216 }
217 event = event::read()?;
218 },
219 Round::Repainted => redraw = true,
221 _ => {}
222 }
223 if Instant::now() >= next_tick {
224 redraw |= screen.tick();
225 next_tick = Instant::now() + TICK;
226 }
227 if let Some(message) = screen.pending_work() {
228 session.off_screen(|| {
229 eprintln!("{message}");
230 screen.do_work();
231 })?;
232 redraw = true;
233 }
234 }
235 Ok(())
236 }
237
238 fn up(&mut self, modes: Modes) -> anyhow::Result<()> {
241 if self.terminal.is_none() {
242 self.terminal = Some(self.session.terminal_with(modes)?);
243 } else if modes != self.modes {
244 self.session.set_modes(modes)?;
245 }
246 self.modes = modes;
247 Ok(())
248 }
249
250 pub fn work<T>(&mut self, f: impl FnOnce() -> T) -> anyhow::Result<T> {
257 if !self.session.screen_up() {
258 return Ok(self.session.stoppable(f));
259 }
260 Ok(self.session.off_screen(f)?)
261 }
262}
263
264impl Drop for Tui<'_> {
265 fn drop(&mut self) {
266 if self.terminal.take().is_some() {
267 self.session.restore();
268 }
269 }
270}
271
272fn feed<S: Screen>(
275 screen: &mut S,
276 on_key: &mut impl FnMut(&mut S, KeyEvent),
277 event: Event,
278) -> bool {
279 match event {
280 Event::Key(key) if key.kind != KeyEventKind::Press => false,
281 Event::Key(key)
282 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') =>
283 {
284 screen.interrupt();
285 true
286 }
287 Event::Key(key) => {
288 on_key(screen, key);
289 true
290 }
291 Event::Mouse(m) => screen.mouse(m),
292 Event::Resize(..) => true,
293 _ => false,
294 }
295}
296
297pub fn run_screen<S: Screen>(screen: &mut S) -> anyhow::Result<()> {
301 run_screen_with(screen, S::handle_key)
302}
303
304pub fn run_screen_with<S: Screen>(
308 screen: &mut S,
309 on_key: impl FnMut(&mut S, KeyEvent),
310) -> anyhow::Result<()> {
311 let session = Session::plain(&super::TOOL);
312 run_screen_in_with(&session, screen, on_key)
313}
314
315pub fn run_screen_in<S: Screen>(session: &Session, screen: &mut S) -> anyhow::Result<()> {
319 run_screen_in_with(session, screen, S::handle_key)
320}
321
322pub fn run_screen_in_with<S: Screen>(
324 session: &Session,
325 screen: &mut S,
326 on_key: impl FnMut(&mut S, KeyEvent),
327) -> anyhow::Result<()> {
328 Tui::new(session).run_with(screen, on_key)
329}
330
331pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
333 Line::from(vec![
334 Span::styled(
335 format!(" {badge} "),
336 HIGHLIGHT.add_modifier(Modifier::REVERSED),
337 ),
338 Span::raw(format!(" {title}")),
339 Span::styled(format!(" {extra}"), DIM),
340 ])
341}
342
343pub fn title_bar(frame: &mut Frame, area: Rect, title: Line<'static>, keys: &[(String, String)]) {
348 if keys.is_empty() {
349 frame.render_widget(title, area);
350 return;
351 }
352 let pairs: Vec<_> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
353 let keys = help_line(&pairs);
354 let width = (keys.width() as u16).min(area.width);
355 let [left, right] =
356 Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]).areas(area);
357 frame.render_widget(title, left);
358 frame.render_widget(keys, right);
359}
360
361pub fn panel(title: String, focused: bool) -> Block<'static> {
364 Block::bordered()
365 .border_type(BorderType::Rounded)
366 .border_style(if focused { PLAIN } else { DIM })
367 .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
369}
370
371pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
373 let mut spans = vec![Span::raw(" ")];
374 for (key, what) in pairs {
375 spans.push(Span::styled(key.to_string(), HIGHLIGHT));
376 spans.push(Span::styled(format!(" {what} "), DIM));
377 }
378 Line::from(spans)
379}
380
381pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
383 let mut spans = vec![
384 Span::raw(format!(" {prompt}")),
385 Span::styled(format!("{text}▏"), HIGHLIGHT),
386 Span::raw(" "),
387 ];
388 spans.extend(help_line(keys).spans);
389 Line::from(spans)
390}
391
392fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
394 buf[(x, y)]
395 .set_symbol(symbol)
396 .set_style(Style::reset().patch(style));
397}
398
399const GUTTER: u16 = 6;
401
402const TARGET_BINS: f64 = 50.0;
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
407pub enum Scale {
408 Log,
409 Sqrt,
410 Linear,
411}
412
413impl Scale {
414 pub fn next(self) -> Self {
415 match self {
416 Scale::Log => Scale::Sqrt,
417 Scale::Sqrt => Scale::Linear,
418 Scale::Linear => Scale::Log,
419 }
420 }
421
422 pub fn name(self) -> &'static str {
423 match self {
424 Scale::Log => "log",
425 Scale::Sqrt => "sqrt",
426 Scale::Linear => "linear",
427 }
428 }
429
430 fn apply(self, v: f64) -> f64 {
431 match self {
432 Scale::Log => (v + 1.0).log10(),
433 Scale::Sqrt => v.max(0.0).sqrt(),
434 Scale::Linear => v,
435 }
436 }
437
438 fn invert(self, t: f64) -> f64 {
439 match self {
440 Scale::Log => 10f64.powf(t) - 1.0,
441 Scale::Sqrt => t * t,
442 Scale::Linear => t,
443 }
444 }
445}
446
447#[derive(Debug, Clone, Copy)]
450pub struct Binning {
451 pub scale: Scale,
452 width: f64,
454}
455
456impl Binning {
457 pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
460 let span = if integer { max + 1.0 } else { max };
461 let width = match scale {
462 Scale::Log => 0.1,
463 Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
464 _ => scale.apply(span) / TARGET_BINS,
465 };
466 Self {
467 scale,
468 width: width.max(f64::MIN_POSITIVE),
469 }
470 }
471
472 pub fn with_width(scale: Scale, width: f64) -> Self {
476 Self {
477 scale,
478 width: width.max(f64::MIN_POSITIVE),
479 }
480 }
481
482 pub fn key(&self, x: f64) -> i32 {
483 match self.scale {
484 Scale::Log => log_bin_key(x),
485 _ => (self.scale.apply(x) / self.width).floor() as i32,
486 }
487 }
488
489 fn start(&self, k: i32) -> f64 {
492 match self.scale {
493 Scale::Log => (k as f64 - 0.5) * self.width,
494 _ => k as f64 * self.width,
495 }
496 }
497
498 pub fn lower_edge(&self, k: i32) -> usize {
501 if k <= 0 {
502 return 0;
503 }
504 let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
506 while self.key(x as f64) < k {
507 x += 1;
508 }
509 while x > 0 && self.key((x - 1) as f64) >= k {
510 x -= 1;
511 }
512 x
513 }
514
515 fn tick_value(&self, k: i32) -> f64 {
518 self.scale.invert(k as f64 * self.width)
519 }
520
521 fn tick_every(&self, nbins: usize) -> i32 {
523 match self.scale {
524 Scale::Log => 5,
525 _ => (nbins as i32 / 6).max(1),
526 }
527 }
528}
529
530pub struct Binned {
532 pub bins: Binning,
533 pub kmin: i32,
534 pub counts: Vec<usize>,
535}
536
537impl Binned {
538 pub fn new(sorted: &[f32], scale: Scale) -> Self {
540 let (min, max) = match (sorted.first(), sorted.last()) {
541 (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
542 _ => (0.0, 0.0),
543 };
544 let integer = sorted.iter().all(|v| v.fract() == 0.0);
545 let bins = Binning::new(scale, max, integer);
546 let kmin = bins.key(min);
547 let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
548 let counts = count(&bins, kmin, nbins, sorted.iter().copied());
549 Self { bins, kmin, counts }
550 }
551
552 pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
554 count(&self.bins, self.kmin, self.counts.len(), values)
555 }
556
557 pub fn kmax(&self) -> i32 {
558 self.kmin + self.counts.len() as i32 - 1
559 }
560}
561
562fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
565 let mut counts = vec![0; nbins];
566 for v in values {
567 let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
568 counts[i as usize] += 1;
569 }
570 counts
571}
572
573pub fn median(sorted: &[f32]) -> f32 {
575 crate::qc::median_of_sorted(sorted)
576}
577
578pub fn compact(v: f64) -> String {
581 if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
582 format!("{:.2}", v)
583 .trim_end_matches('0')
584 .trim_end_matches('.')
585 .to_string()
586 } else if v < 1e3 {
587 format!("{}", v.round() as i64)
588 } else if v < 1e4 {
589 format!("{:.1}k", v / 1e3)
590 } else if v < 1e6 {
591 format!("{}k", (v / 1e3).round() as u64)
592 } else if v < 1e9 {
593 format!("{:.1}M", v / 1e6)
594 } else {
595 format!("{:.1}G", v / 1e9)
596 }
597}
598
599pub trait BarValue: Copy {
603 fn bar(self) -> f64;
604}
605
606impl BarValue for usize {
607 fn bar(self) -> f64 {
608 self as f64
609 }
610}
611
612impl BarValue for f64 {
613 fn bar(self) -> f64 {
614 self
615 }
616}
617
618pub struct HistPlot<'a, T: BarValue = usize> {
619 pub bins: Binning,
620 pub kmin: i32,
621 pub counts: &'a [T],
622 pub style: &'a dyn Fn(i32) -> Style,
624 pub subset: Option<&'a [T]>,
627 pub y_scale: Scale,
628 pub y_max: Option<f64>,
631 pub pointer: Option<i32>,
633 pub marks: Vec<(i32, &'static str, Style)>,
635 pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
640 pub tick_every: Option<i32>,
642}
643
644const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
645
646impl<T: BarValue> HistPlot<'_, T> {
647 pub fn render(&self, buf: &mut Buffer, area: Rect) {
650 let [plot, axis, labels] = Layout::vertical([
651 Constraint::Min(1),
652 Constraint::Length(1),
653 Constraint::Length(1),
654 ])
655 .areas(area);
656 let [gutter, chart] =
657 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
658 if chart.width == 0 || chart.height == 0 {
659 return;
660 }
661 let nbins = self.counts.len();
662 let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
663 let x_of = |k: i32| -> Option<u16> {
664 let i = k - self.kmin;
665 (i >= 0 && (i as usize) < nbins)
666 .then(|| chart.x + i as u16 * bw)
667 .filter(|&x| x < chart.right())
668 };
669
670 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
671 let tallest = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
672 let max_h = self
673 .y_max
674 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
675 let cells = chart.height as usize * 8;
676 let eighths = |c: T| {
677 if c.bar() <= 0.0 || max_h <= 0.0 {
678 0
679 } else {
680 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
681 }
682 };
683
684 if let Some(x) = self.pointer.and_then(x_of) {
685 for y in chart.top()..chart.bottom() {
686 put(buf, x, y, "┊", ACCENTED);
687 }
688 }
689
690 let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
691 for (i, &c) in counts.iter().enumerate() {
692 let x0 = chart.x + i as u16 * bw;
693 if x0 >= chart.right() {
694 break;
695 }
696 let style = if dim {
697 DIM
698 } else {
699 (self.style)(self.kmin + i as i32)
700 };
701 let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
702 for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
703 let mut fill = top.saturating_sub(j * 8).min(8);
704 if fill == 0 {
705 break;
706 }
707 if under >= (j + 1) * 8 {
711 fill = 8;
712 }
713 for x in x0..(x0 + bw).min(chart.right()) {
714 put(buf, x, y, EIGHTHS[fill - 1], style);
715 }
716 }
717 }
718 };
719 bars(self.counts, None, self.subset.is_some());
720 if let Some(subset) = self.subset {
721 bars(subset, Some(self.counts), false);
722 }
723
724 let gx = gutter.right() - 1;
726 for y in gutter.top()..gutter.bottom() {
727 put(buf, gx, y, "│", DIM);
728 }
729 let mut ylabel = |y: u16, v: f64| {
730 let s = compact(v);
731 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
732 buf.set_string(x, y, &s, DIM);
733 put(buf, gx, y, "┤", DIM);
734 };
735 if max_h > 0.0 {
736 ylabel(gutter.top(), self.y_scale.invert(max_h));
737 if gutter.height >= 6 {
738 ylabel(
739 gutter.top() + gutter.height / 2,
740 self.y_scale.invert(max_h / 2.0),
741 );
742 }
743 }
744
745 for x in axis.left()..axis.right() {
747 let sym = match x.cmp(&gx) {
748 std::cmp::Ordering::Less => " ",
749 std::cmp::Ordering::Equal => "└",
750 std::cmp::Ordering::Greater => "─",
751 };
752 put(buf, x, axis.y, sym, DIM);
753 }
754 let every = self
755 .tick_every
756 .unwrap_or_else(|| self.bins.tick_every(nbins))
757 .max(1);
758 let mut next_free = labels.x;
759 let kmax = self.kmin + nbins as i32 - 1;
760 for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
761 let Some(x) = x_of(k) else { continue };
762 let s = match self.x_label {
763 Some(label) => match label(k) {
764 Some(s) => s,
765 None => continue,
766 },
767 None => compact(self.bins.tick_value(k)),
768 };
769 put(buf, x, axis.y, "┴", DIM);
770 if x >= next_free && x + (s.len() as u16) <= labels.right() {
771 buf.set_string(x, labels.y, &s, DIM);
772 next_free = x + s.len() as u16 + 1;
773 }
774 }
775 let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
776 for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
777 if let Some(x) = x_of(k) {
778 put(buf, x, axis.y, sym, style);
779 }
780 }
781 }
782}
783
784pub struct MirrorSide<'a, T: BarValue = f64> {
786 pub counts: &'a [T],
787 pub subset: Option<&'a [T]>,
790 pub style: Style,
791 pub name: &'a str,
793}
794
795pub struct MirrorPlot<'a, T: BarValue = f64> {
800 pub up: MirrorSide<'a, T>,
801 pub down: MirrorSide<'a, T>,
802 pub y_scale: Scale,
803 pub y_max: Option<f64>,
805 pub y_labels: Option<[String; 3]>,
808 pub pointer: Option<usize>,
810 pub x_label: Option<&'a dyn Fn(usize) -> Option<String>>,
813}
814
815impl<T: BarValue> MirrorPlot<'_, T> {
816 pub fn render(&self, buf: &mut Buffer, area: Rect) {
820 let [plot, axis, labels] = Layout::vertical([
821 Constraint::Min(1),
822 Constraint::Length(1),
823 Constraint::Length(1),
824 ])
825 .areas(area);
826 let [gutter, chart] =
827 Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
828 if chart.width == 0 || chart.height < 3 {
829 return;
830 }
831 let half = (chart.height - 1) / 2;
832 let zero = chart.top() + half;
833 let x_of = |i: usize| Some(chart.x + i as u16).filter(|&x| x < chart.right());
834
835 let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
836 let all = self.up.counts.iter().chain(self.down.counts);
837 let tallest = all.map(|&c| height(c)).fold(0.0, f64::max);
838 let max_h = self
839 .y_max
840 .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
841 let cells = half as usize * 2;
842 let halves = |c: T| {
843 if c.bar() <= 0.0 || max_h <= 0.0 {
844 0
845 } else {
846 ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
847 }
848 };
849
850 if let Some(x) = self.pointer.and_then(x_of) {
851 for y in chart.top()..chart.top() + 2 * half + 1 {
852 put(buf, x, y, "┊", ACCENTED);
853 }
854 }
855 for x in chart.left()..chart.right() {
856 put(buf, x, zero, "─", DIM);
857 }
858 for (side, up) in [(&self.up, true), (&self.down, false)] {
859 let (whole, part) = if up { ("█", "▄") } else { ("█", "▀") };
860 let mut bars = |counts: &[T], behind: Option<&[T]>, style: Style| {
861 for (i, &c) in counts.iter().enumerate() {
862 let Some(x) = x_of(i) else { break };
863 let (top, under) = (halves(c), behind.map_or(0, |b| halves(b[i])));
864 for k in 0..top.div_ceil(2) {
865 let y = if up {
866 zero - 1 - k as u16
867 } else {
868 zero + 1 + k as u16
869 };
870 let full = 2 * k + 2 <= top || under >= 2 * k + 2;
873 put(buf, x, y, if full { whole } else { part }, style);
874 }
875 }
876 };
877 match side.subset {
878 Some(subset) => {
879 bars(side.counts, None, DIM);
880 bars(subset, Some(side.counts), side.style);
881 }
882 None => bars(side.counts, None, side.style),
883 }
884 }
885 buf.set_string(chart.x, chart.top(), self.up.name, DIM);
886 buf.set_string(chart.x, chart.top() + 2 * half, self.down.name, DIM);
887
888 let gx = gutter.right() - 1;
890 for y in gutter.top()..gutter.bottom() {
891 put(buf, gx, y, "│", DIM);
892 }
893 let own = || {
894 let top = compact(self.y_scale.invert(max_h));
895 [top.clone(), "0".to_string(), top]
896 };
897 let ys = [chart.top(), zero, chart.top() + 2 * half];
898 for (y, s) in ys
899 .into_iter()
900 .zip(self.y_labels.clone().unwrap_or_else(own))
901 {
902 let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
903 buf.set_string(x, y, &s, DIM);
904 put(buf, gx, y, "┤", DIM);
905 }
906
907 for x in axis.left()..axis.right() {
909 let sym = match x.cmp(&gx) {
910 std::cmp::Ordering::Less => " ",
911 std::cmp::Ordering::Equal => "└",
912 std::cmp::Ordering::Greater => "─",
913 };
914 put(buf, x, axis.y, sym, DIM);
915 }
916 let n = self.up.counts.len().max(self.down.counts.len());
917 let mut next_free = labels.x;
918 for i in 0..n {
919 let Some(x) = x_of(i) else { break };
920 let Some(s) = self.x_label.and_then(|label| label(i)) else {
921 continue;
922 };
923 put(buf, x, axis.y, "┴", DIM);
924 if x >= next_free && x + (s.len() as u16) <= labels.right() {
925 buf.set_string(x, labels.y, &s, DIM);
926 next_free = x + s.len() as u16 + 1;
927 }
928 }
929 if let Some(x) = self.pointer.and_then(x_of) {
930 put(buf, x, axis.y, "▲", HIGHLIGHT);
931 }
932 }
933}
934
935#[cfg(test)]
936#[path = "tests/ui.rs"]
937mod tests;