1use std::collections::HashMap;
4
5use crate::numfmt::group_chrome;
6use std::path::Path;
7use std::sync::Arc;
8
9use polars::prelude::*;
10
11use crate::parquet_footer::Footer;
12use ratatui::buffer::Buffer;
13use ratatui::layout::{Constraint, Direction, Layout, Rect};
14use ratatui::prelude::Stylize;
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{HighlightSpacing, Paragraph, Row, StatefulWidget, Table, Widget};
18
19use super::datatable::DataTableState;
20use crate::export_modal::ExportFormat;
21use crate::render::context::RenderContext;
22use crate::widgets::ui::{HintBar, SectionRule, Surface};
23
24struct NoteRow {
26 text: String,
27 dim: bool,
29}
30
31fn notes_window(heights: &[usize], selected: usize, stored: usize, show: usize) -> (usize, usize) {
49 if heights.is_empty() {
50 return (0, 0);
51 }
52 let selected = selected.min(heights.len() - 1);
53 let span = |a: usize, b: usize| heights[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1);
55 let mut first = stored.min(selected);
57 while first < selected && span(first, selected + 1) > show {
58 first += 1;
59 }
60 let mut last = selected + 1;
62 while last < heights.len() && span(first, last + 1) <= show {
63 last += 1;
64 }
65 while first > 0 && span(first - 1, last) <= show {
68 first -= 1;
69 }
70 (first, last)
71}
72
73fn note_rows(note: &crate::notes::Note, selected: bool, width: usize) -> Vec<NoteRow> {
76 let mut rows = Vec::new();
77 let marker = if selected {
78 crate::glyphs::get().prompt
79 } else {
80 " "
81 };
82 let mut first = true;
83 for line in wrap_to(¬e.summary, width.saturating_sub(2)) {
84 rows.push(NoteRow {
85 text: format!("{}{line}", if first { marker } else { " " }),
86 dim: false,
87 });
88 first = false;
89 }
90 for line in wrap_to(¬e.scope, width.saturating_sub(4)) {
91 rows.push(NoteRow {
92 text: format!(" {line}"),
93 dim: true,
94 });
95 }
96 rows
97}
98
99pub(crate) fn wrap_to(text: &str, width: usize) -> Vec<String> {
107 use unicode_width::UnicodeWidthStr;
108 if width == 0 {
109 return vec![text.to_string()];
110 }
111 let mut lines = Vec::new();
112 let mut line = String::new();
113 for word in text.split_whitespace() {
114 let room = if line.is_empty() {
115 width
116 } else {
117 width.saturating_sub(line.width() + 1)
118 };
119 if word.width() > room && !line.is_empty() {
120 lines.push(std::mem::take(&mut line));
121 }
122 if !line.is_empty() {
123 line.push(' ');
124 }
125 line.push_str(word);
126 }
127 if !line.is_empty() || lines.is_empty() {
128 lines.push(line);
129 }
130 lines
131}
132
133pub(crate) fn group_u64(n: u64) -> String {
135 let mut out = String::new();
136 crate::numfmt::NumberFormat::CHROME.write_u64(n, &mut out);
137 out
138}
139
140pub(crate) fn count_of(n: u64, one: &str, many: &str) -> String {
142 format!("{} {}", group_u64(n), if n == 1 { one } else { many })
143}
144
145pub(crate) fn short_count(n: u64) -> String {
147 const STEPS: [(u64, &str); 4] = [
148 (1_000_000_000_000, "T"),
149 (1_000_000_000, "B"),
150 (1_000_000, "M"),
151 (1_000, "K"),
152 ];
153 for (size, suffix) in STEPS {
154 if n >= size {
155 return format!("{:.1}{suffix}", n as f64 / size as f64);
156 }
157 }
158 n.to_string()
159}
160
161fn wrap_words(text: &str, width: usize) -> Vec<String> {
166 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
167 let mut lines = Vec::new();
168 let mut line = String::new();
169 let mut line_width = 0;
170 let mut broken = false;
172 let mut rest = text;
173 while !rest.is_empty() {
174 let space = rest.starts_with(' ');
175 let end = rest
176 .find(|c: char| (c == ' ') != space)
177 .unwrap_or(rest.len());
178 let (token, after) = rest.split_at(end);
179 rest = after;
180 let w = token.width();
181 if space {
182 if broken && line.is_empty() {
183 continue;
184 }
185 if line_width + w <= width {
186 line.push_str(token);
187 line_width += w;
188 } else {
189 if !line.is_empty() {
191 lines.push(std::mem::take(&mut line));
192 }
193 line_width = 0;
194 broken = true;
195 }
196 continue;
197 }
198 if line_width + w > width && !line.is_empty() && w <= width {
199 lines.push(std::mem::take(&mut line).trim_end().to_string());
200 line_width = 0;
201 broken = true;
202 }
203 for c in token.chars() {
204 let cw = c.width().unwrap_or(0);
205 if line_width + cw > width && !line.is_empty() {
206 lines.push(std::mem::take(&mut line));
207 line_width = 0;
208 broken = true;
209 }
210 line.push(c);
211 line_width += cw;
212 }
213 }
214 if !(broken && line.is_empty()) || lines.is_empty() {
216 lines.push(line);
217 }
218 lines
219}
220
221pub(crate) fn meta_text(value: &crate::model_files::MetaValue) -> String {
223 use crate::model_files::MetaValue;
224 match value {
225 MetaValue::Text(text) => text.clone(),
226 MetaValue::List { of, len, items } if items.len() as u64 == *len && *len > 0 => {
227 let quote = *of == "strings";
228 let items: Vec<String> = items
229 .iter()
230 .map(|i| if quote { format!("{i:?}") } else { i.clone() })
231 .collect();
232 format!("[{}]", items.join(", "))
233 }
234 MetaValue::List { of, len, .. } => format!("[{} {of}]", group_u64(*len)),
235 }
236}
237
238pub(crate) const VALUE_SHOWN_BYTES: usize = 64 * 1024;
242
243pub(crate) fn metadata_lines(
247 metadata: &[(String, crate::model_files::MetaValue)],
248 width: usize,
249) -> Vec<(String, String)> {
250 use unicode_width::UnicodeWidthStr;
251 let longest = metadata.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
252 let key_width = longest.min(width * 2 / 5).max(1);
254 let value_width = width.saturating_sub(key_width + 2).max(1);
255 let mut out = Vec::new();
256 for (key, value) in metadata {
257 let key_cell = format!("{:<w$} ", clip(key, key_width), w = key_width);
258 let blank = " ".repeat(key_width + 2);
259 let listed;
261 let text = match value {
262 crate::model_files::MetaValue::Text(text) => text.as_str(),
263 other => {
264 listed = meta_text(other);
265 listed.as_str()
266 }
267 };
268 let cut = text.floor_char_boundary(VALUE_SHOWN_BYTES);
269 let (text, more) = (&text[..cut], text.len() - cut);
270 let mut first = true;
271 for raw in text.split('\n') {
272 let clean: String = raw
275 .chars()
276 .filter_map(|c| match c {
277 '\t' => Some(' '),
278 c if c.is_control() => None,
279 c => Some(c),
280 })
281 .collect();
282 for line in wrap_words(&clean, value_width) {
283 let k = if first {
284 key_cell.clone()
285 } else {
286 blank.clone()
287 };
288 first = false;
289 out.push((k, line));
290 }
291 }
292 if more > 0 {
293 let g = crate::glyphs::get();
294 out.push((
295 blank,
296 format!("{} {} more", g.ellipsis, format_bytes(more as u64)),
297 ));
298 }
299 }
300 out
301}
302
303pub(crate) fn clock(seconds: f64) -> String {
305 let ms = (seconds.max(0.0) * 1000.0).round() as u64;
306 let (h, m, s, ms) = (ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000);
307 if h > 0 {
308 format!("{h}:{m:02}:{s:02}.{ms:03}")
309 } else {
310 format!("{m}:{s:02}.{ms:03}")
311 }
312}
313
314pub fn format_bytes(n: u64) -> String {
316 const K: u64 = 1024;
317 const M: u64 = K * K;
318 const G: u64 = M * K;
319 if n >= G {
320 format!("{:.1} GiB", n as f64 / G as f64)
321 } else if n >= M {
322 format!("{:.1} MiB", n as f64 / M as f64)
323 } else if n >= K {
324 format!("{:.1} KiB", n as f64 / K as f64)
325 } else {
326 format!("{} B", n)
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
331pub enum InfoTab {
332 #[default]
333 Schema,
334 Metadata,
336 Format,
340 Resources,
341 Partitions,
342 Notes,
343 Documentation,
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
350pub struct TabsOffered {
351 pub metadata: bool,
353 pub format: bool,
355 pub partitions: bool,
356 pub notes: bool,
357 pub documentation: bool,
359}
360
361impl TabsOffered {
362 pub fn of(state: &DataTableState, facts_tab: Option<&'static str>) -> Self {
365 Self {
366 metadata: state
367 .delimited_read()
368 .is_some_and(|read| read.metadata.is_some()),
369 format: state.format_detail().is_some() || facts_tab.is_some(),
370 partitions: state
371 .partition_columns()
372 .map(|v| !v.is_empty())
373 .unwrap_or(false),
374 notes: state.has_notes(),
375 documentation: false,
376 }
377 }
378}
379
380impl InfoTab {
381 pub fn visible(offered: TabsOffered) -> Vec<InfoTab> {
385 let mut tabs = vec![InfoTab::Schema];
386 if offered.documentation {
387 tabs.push(InfoTab::Documentation);
388 }
389 if offered.metadata {
390 tabs.push(InfoTab::Metadata);
391 }
392 if offered.format {
393 tabs.push(InfoTab::Format);
394 }
395 tabs.push(InfoTab::Resources);
396 if offered.partitions {
397 tabs.push(InfoTab::Partitions);
398 }
399 if offered.notes {
400 tabs.push(InfoTab::Notes);
401 }
402 tabs
403 }
404
405 pub fn title(self) -> &'static str {
406 match self {
407 InfoTab::Schema => "Schema",
408 InfoTab::Metadata => "Metadata",
409 InfoTab::Format => "Format",
410 InfoTab::Resources => "Resources",
411 InfoTab::Partitions => "Partitions",
412 InfoTab::Notes => "Notes",
413 InfoTab::Documentation => "Documentation",
414 }
415 }
416
417 pub fn next(self, offered: TabsOffered) -> Self {
419 let tabs = Self::visible(offered);
420 let at = self.index(offered);
421 tabs[(at + 1) % tabs.len()]
422 }
423
424 pub fn prev(self, offered: TabsOffered) -> Self {
425 let tabs = Self::visible(offered);
426 let at = self.index(offered);
427 tabs[(at + tabs.len() - 1) % tabs.len()]
428 }
429
430 pub fn index(self, offered: TabsOffered) -> usize {
432 Self::visible(offered)
433 .iter()
434 .position(|tab| *tab == self)
435 .unwrap_or(0)
436 }
437}
438
439#[derive(Default)]
441pub struct InfoModal {
442 pub active: bool,
443 pub active_tab: InfoTab,
444 pub schema_selected_index: usize,
445 pub schema_scroll_offset: usize,
446 pub schema_table_state: ratatui::widgets::TableState,
447 pub schema_visible_height: usize,
449 pub notes_selected_index: usize,
451 pub notes_scroll_offset: usize,
454 pub detail_scroll: usize,
457 pub detail_visible: usize,
459 pub detail_selected: usize,
463}
464
465impl InfoModal {
466 pub fn new() -> Self {
467 Self::default()
468 }
469
470 pub fn open(&mut self) {
471 self.open_on(InfoTab::Schema);
472 }
473
474 pub fn open_on(&mut self, tab: InfoTab) {
477 self.active = true;
478 self.active_tab = tab;
479 self.schema_selected_index = 0;
480 self.schema_scroll_offset = 0;
481 self.schema_table_state.select(Some(0));
482 self.notes_selected_index = 0;
483 self.notes_scroll_offset = 0;
484 self.detail_scroll = 0;
485 self.detail_selected = 0;
486 }
487
488 pub fn close(&mut self) {
489 self.active = false;
490 }
491
492 pub fn switch_tab(&mut self, offered: TabsOffered) {
494 self.active_tab = self.active_tab.next(offered);
495 if self.active_tab == InfoTab::Schema {
496 self.schema_selected_index = 0;
497 self.schema_scroll_offset = 0;
498 self.schema_table_state.select(Some(0));
499 }
500 }
501
502 pub fn switch_tab_prev(&mut self, offered: TabsOffered) {
504 self.active_tab = self.active_tab.prev(offered);
505 if self.active_tab == InfoTab::Schema {
506 self.schema_selected_index = 0;
507 self.schema_scroll_offset = 0;
508 self.schema_table_state.select(Some(0));
509 }
510 }
511
512 pub fn detail_scroll_by(&mut self, delta: isize) {
515 self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
516 }
517
518 pub fn detail_page(&mut self, down: bool) {
520 let page = self.detail_visible.max(1) as isize;
521 self.detail_scroll_by(if down { page } else { -page });
522 }
523
524 pub fn notes_move(&mut self, delta: isize, total: usize) -> bool {
529 if total == 0 {
530 return false;
531 }
532 let last = total - 1;
533 let next = (self.notes_selected_index as isize + delta).clamp(0, last as isize) as usize;
534 if next == self.notes_selected_index {
535 return false;
536 }
537 self.notes_selected_index = next;
538 true
539 }
540
541 pub fn schema_table_down(&mut self, total_rows: usize, visible_height: usize) -> bool {
544 if total_rows == 0 {
545 return false;
546 }
547 let max_idx = total_rows.saturating_sub(1);
548 if self.schema_selected_index >= max_idx {
549 return false;
550 }
551 self.schema_selected_index += 1;
552 let visible_end = self.schema_scroll_offset + visible_height;
553 if visible_height > 0 && self.schema_selected_index >= visible_end {
554 self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
555 }
556 let local = self
557 .schema_selected_index
558 .saturating_sub(self.schema_scroll_offset);
559 self.schema_table_state.select(Some(local));
560 true
561 }
562
563 pub fn schema_table_up(&mut self, total_rows: usize, _visible_height: usize) -> bool {
564 if total_rows == 0 || self.schema_selected_index == 0 {
565 return false;
566 }
567 self.schema_selected_index -= 1;
568 if self.schema_selected_index < self.schema_scroll_offset {
569 self.schema_scroll_offset = self.schema_selected_index;
570 }
571 let local = self
572 .schema_selected_index
573 .saturating_sub(self.schema_scroll_offset);
574 self.schema_table_state.select(Some(local));
575 true
576 }
577
578 pub fn sync_schema_table_state(&mut self, total_rows: usize, visible_height: usize) {
580 if total_rows == 0 {
581 self.schema_table_state.select(None);
582 return;
583 }
584 let max_idx = total_rows.saturating_sub(1);
585 self.schema_selected_index = self.schema_selected_index.min(max_idx);
586 if self.schema_scroll_offset + visible_height <= self.schema_selected_index
587 && visible_height > 0
588 {
589 self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
590 }
591 if self.schema_selected_index < self.schema_scroll_offset {
592 self.schema_scroll_offset = self.schema_selected_index;
593 }
594 let local = self
595 .schema_selected_index
596 .saturating_sub(self.schema_scroll_offset);
597 self.schema_table_state.select(Some(local));
598 }
599}
600
601#[derive(Debug, Clone)]
609pub enum FileFacts {
610 Reading,
612 Read {
614 size: Option<u64>,
616 footer: Option<Footer>,
619 detail: Option<Arc<crate::text_formats::Detail>>,
621 },
622 Failed(String),
625}
626
627impl FileFacts {
628 pub(crate) fn read(
634 path: &Path,
635 facts: Option<crate::readers::Facts>,
636 ) -> std::result::Result<Self, String> {
637 let io = |e: std::io::Error| {
638 log::warn!(target: "datui", "file size of {}: {e}", path.display());
639 match e.kind() {
640 std::io::ErrorKind::NotFound => "file not found".to_string(),
641 std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
642 _ => {
644 let said = e.to_string();
645 match said.rsplit_once(" (os error") {
646 Some((words, _)) => words.to_string(),
647 None => said,
648 }
649 }
650 }
651 };
652 let meta = std::fs::metadata(path).map_err(io)?;
653 if meta.is_dir() {
654 return Ok(Self::Read {
655 size: None,
656 footer: None,
657 detail: None,
658 });
659 }
660 let read = match facts {
661 Some(facts) => (facts.read)(path).map_err(|e| {
662 log::warn!(target: "datui", "footer of {}: {e}", path.display());
663 "unreadable footer".to_string()
664 })?,
665 None => crate::readers::FormatFacts::default(),
666 };
667 Ok(Self::Read {
668 size: Some(meta.len()),
669 footer: read.footer,
670 detail: read.detail,
671 })
672 }
673}
674
675fn clip(text: &str, room: usize) -> String {
677 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
678 if text.width() <= room {
679 return text.to_string();
680 }
681 let mark = crate::glyphs::get().ellipsis;
682 let mut kept = String::new();
683 for ch in text.chars() {
684 if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
685 break;
686 }
687 kept.push(ch);
688 }
689 kept + mark
690}
691
692pub struct InfoContext<'a> {
697 pub format: Option<ExportFormat>,
698 pub declared_types: bool,
700 pub facts: Option<&'a FileFacts>,
703 pub facts_tab: Option<&'static str>,
707 pub footer_expected: bool,
710}
711
712impl<'a> InfoContext<'a> {
713 pub fn schema_source(&self) -> &'static str {
715 if self.declared_types {
716 "types declared"
717 } else {
718 "types inferred"
719 }
720 }
721
722 pub fn footer(&self) -> Option<&'a Footer> {
724 match self.facts? {
725 FileFacts::Read { footer, .. } => footer.as_ref(),
726 FileFacts::Reading | FileFacts::Failed(_) => None,
727 }
728 }
729
730 fn facts_detail(&self) -> Option<&'a crate::text_formats::Detail> {
732 match self.facts? {
733 FileFacts::Read { detail, .. } => detail.as_deref(),
734 FileFacts::Reading | FileFacts::Failed(_) => None,
735 }
736 }
737
738 fn reading(&self) -> bool {
740 matches!(self.facts, Some(FileFacts::Reading))
741 }
742}
743
744pub struct DataTableInfo<'a> {
745 pub state: &'a DataTableState,
746 pub ctx: InfoContext<'a>,
747 pub modal: &'a mut InfoModal,
748 pub theme: &'a RenderContext,
749 pub hex: bool,
751 pub header_toggle: bool,
753 pub codebook: Option<&'a crate::codebook::Codebook>,
755 pub documentation: Option<&'a mut crate::widgets::documentation::DocState>,
757 pub estimate: Option<crate::schema_union::RowEstimate>,
759}
760
761fn read_line(state: &DataTableState) -> Option<String> {
764 let mode = state.read_mode()?.label();
765 Some(if state.fetched() {
766 format!("download {} {mode}", crate::glyphs::get().arrow_right)
767 } else {
768 mode.to_string()
769 })
770}
771
772fn rows_and_columns(rows: Option<usize>, columns: usize) -> String {
779 let middot = crate::glyphs::get().middot;
780 match rows {
781 Some(rows) => format!(
782 "Rows (total): {} {middot} Columns: {}",
783 format_int(rows),
784 columns
785 ),
786 None => format!("Rows (total): counting... {middot} Columns: {columns}"),
787 }
788}
789
790fn estimated_rows_and_columns(
793 estimate: crate::schema_union::RowEstimate,
794 columns: usize,
795) -> String {
796 let middot = crate::glyphs::get().middot;
797 format!(
798 "Rows (total): ~{} (est. from {} of {} files; c counts) {middot} Columns: {columns}",
799 crate::discover::format_rows(estimate.rows as usize),
800 format_int(estimate.sampled),
801 format_int(estimate.files),
802 )
803}
804
805impl<'a> DataTableInfo<'a> {
806 pub fn new(
807 state: &'a DataTableState,
808 ctx: InfoContext<'a>,
809 modal: &'a mut InfoModal,
810 theme: &'a RenderContext,
811 ) -> Self {
812 Self {
813 state,
814 ctx,
815 modal,
816 theme,
817 hex: false,
818 header_toggle: false,
819 codebook: None,
820 documentation: None,
821 estimate: None,
822 }
823 }
824
825 fn codebook_here(&self) -> Option<&'a crate::codebook::Codebook> {
827 self.codebook
828 .filter(|book| book.covers(self.state.schema().iter_names().map(|n| n.as_str())))
829 }
830
831 fn render_schema_tab(&mut self, area: Rect, buf: &mut Buffer) {
832 let summary = self.render_schema_summary(area, buf);
833 let mut rest = Rect {
834 y: area.y + summary,
835 height: area.height.saturating_sub(summary),
836 ..area
837 };
838 if rest.height == 0 {
839 return;
840 }
841 let note_rows = if self.codebook_here().is_some() && rest.height >= 10 {
844 4
845 } else {
846 0
847 };
848 rest.height -= note_rows;
849 let used = self.render_schema_table(rest, buf);
850 if note_rows > 0 {
851 self.render_column_note(
853 Rect {
854 y: rest.y + used + 1,
855 height: note_rows - 1,
856 ..rest
857 },
858 buf,
859 );
860 }
861 }
862
863 fn render_column_note(&self, area: Rect, buf: &mut Buffer) {
866 let Some(book) = self.codebook_here() else {
867 return;
868 };
869 let Some((name, _)) = self
870 .state
871 .schema()
872 .get_at_index(self.modal.schema_selected_index)
873 else {
874 return;
875 };
876 let width = area.width as usize;
877 let rows = area.height as usize;
878 let mut lines: Vec<String> = Vec::new();
879 match book.column(name.as_str()) {
880 Some(column) => {
881 let about = column.about();
882 if !about.is_empty() {
883 lines.extend(wrap_to(&format!("{name}: {about}"), width));
884 }
885 if !column.values.is_empty() {
886 let sep = format!(" {} ", crate::glyphs::get().middot);
887 let codes: Vec<&str> = column
888 .values
889 .keys()
890 .map(|k| if k.is_empty() { "blank" } else { k.as_str() })
891 .collect();
892 let left = rows.saturating_sub(lines.len()).max(1);
893 let mut wrapped = wrap_to(&format!("Codes: {}", codes.join(&sep)), width);
894 if wrapped.len() > left {
895 let rest = wrapped[left - 1..].join(" ");
897 wrapped.truncate(left - 1);
898 wrapped.push(clip(&rest, width));
899 }
900 lines.extend(wrapped);
901 }
902 }
903 None => lines.push(format!("{name}: not documented")),
904 }
905 for (i, line) in lines.iter().take(rows).enumerate() {
906 Paragraph::new(clip(line, width))
907 .style(Style::default().fg(self.theme.text_secondary))
908 .render(
909 Rect {
910 y: area.y + i as u16,
911 height: 1,
912 ..area
913 },
914 buf,
915 );
916 }
917 }
918
919 fn render_schema_summary(&self, area: Rect, buf: &mut Buffer) -> u16 {
920 let ncols = self.state.schema().len();
921 let mut lines = vec![];
922 lines.push(match self.estimate {
924 Some(estimate) => estimated_rows_and_columns(estimate, ncols),
925 None => rows_and_columns(self.state.num_rows_if_valid(), ncols),
926 });
927 let by_type = columns_by_type(self.state.schema().as_ref());
928 if !by_type.is_empty() {
929 lines.push(by_type);
930 }
931 let others = self.state.other_tables();
933 if !others.is_empty() {
934 let sep = format!(" {} ", crate::glyphs::get().middot);
935 lines.push(format!("Other tables (--table): {}", others.join(&sep)));
936 }
937 if let Some(book) = self.codebook_here()
938 && !book.source.is_empty()
939 {
940 lines.push(clip(
941 &format!("Documentation: {}", book.source),
942 area.width as usize,
943 ));
944 }
945 for (i, s) in lines.iter().enumerate() {
946 Paragraph::new(s.as_str()).render(
947 Rect {
948 x: area.x,
949 y: area.y + i as u16,
950 width: area.width,
951 height: 1,
952 },
953 buf,
954 );
955 }
956 lines.len() as u16
957 }
958
959 fn render_schema_table(&mut self, area: Rect, buf: &mut Buffer) -> u16 {
961 let dataset = self.state.dataset_schema();
964 let src = match dataset {
965 Some(dataset) => dataset.origin.to_string(),
966 None if self.state.format_detail().is_some_and(|d| d.own_columns) => {
968 "types declared".to_string()
969 }
970 None => self.ctx.schema_source().to_string(),
971 };
972 let presence = dataset.map(|dataset| {
976 let readable = dataset.files.saturating_sub(dataset.unreadable.len());
977 let present_by_name: HashMap<&str, usize> = dataset
978 .columns
979 .iter()
980 .map(|c| (c.name.as_str(), c.present_in))
981 .collect();
982 (readable, present_by_name)
983 });
984 let has_files = presence.is_some();
985 let compression = self.ctx.footer().map(|m| {
986 crate::parquet_footer::column_compression(m.as_ref(), self.state.schema().as_ref())
987 });
988 let has_comp =
991 self.ctx.footer_expected || compression.as_ref().is_some_and(|c| !c.is_empty());
992 let has_units = !self.state.units().is_empty();
994 let book = self.codebook_here();
995 let mut header_cells = vec!["Column", "Type"];
996 if has_units {
997 header_cells.push("Unit");
998 }
999 if book.is_some() {
1000 header_cells.push("About");
1001 }
1002 if has_files {
1003 header_cells.push("Files");
1004 }
1005 if has_comp {
1006 header_cells.push("Compression");
1007 }
1008 let header = Row::new(header_cells).bold();
1009
1010 let total_rows = self.state.schema().len();
1011 let body_focused = true;
1014 SectionRule {
1017 title: "Schema",
1018 chip: Some(&src),
1019 }
1020 .render(Rect { height: 1, ..area }, buf, self.theme);
1021 let inner = Rect {
1022 y: area.y + 1,
1023 height: area.height.saturating_sub(1),
1024 ..area
1025 };
1026 let visible_height = inner.height as usize;
1027
1028 let fits = total_rows <= visible_height.saturating_sub(1);
1032 let data_height = visible_height.saturating_sub(1 + usize::from(!fits));
1033 self.modal.schema_visible_height = data_height;
1034 self.modal.sync_schema_table_state(total_rows, data_height);
1035
1036 let offset = self.modal.schema_scroll_offset;
1037 let take = data_height.min(total_rows.saturating_sub(offset));
1038 let mut rows: Vec<Vec<String>> = vec![];
1039 for (idx, (name, dtype)) in self.state.schema().iter().enumerate() {
1040 if idx < offset {
1041 continue;
1042 }
1043 if idx >= offset + take {
1044 break;
1045 }
1046 let name_str: &str = name.as_ref();
1047 let mut cells = vec![name.to_string(), dtype.to_string()];
1048 if has_units {
1049 cells.push(self.state.unit_of(name_str).unwrap_or_default().to_string());
1050 }
1051 if let Some(book) = book {
1052 cells.push(book.column(name_str).map(|c| c.about()).unwrap_or_default());
1053 }
1054 if let Some((readable, present_by_name)) = &presence {
1055 let files_str = match present_by_name.get(name_str) {
1058 Some(present) if present >= readable => {
1059 format!("all {}", format_int(*readable))
1060 }
1061 Some(present) => {
1062 format!("{} of {}", format_int(*present), format_int(*readable))
1063 }
1064 None => crate::glyphs::get().dash.to_string(),
1065 };
1066 cells.push(files_str);
1067 }
1068 if has_comp {
1069 let comp_str = match compression.as_ref().map(|c| c.get(name_str)) {
1070 Some(Some((codec, ratio))) => {
1071 format!("{} {:.1}{}", codec, ratio, crate::glyphs::get().times)
1072 }
1073 None if self.ctx.reading() => String::new(),
1075 _ => crate::glyphs::get().dash.to_string(),
1076 };
1077 cells.push(comp_str);
1078 }
1079 rows.push(cells);
1080 }
1081
1082 let widths: Vec<Constraint> = if book.is_some() {
1083 let mut weights = vec![3, 2];
1085 weights.extend(has_units.then_some(2));
1086 weights.push(7);
1087 weights.extend(has_files.then_some(2));
1088 weights.extend(has_comp.then_some(3));
1089 weights.into_iter().map(Constraint::Fill).collect()
1090 } else if has_units {
1091 let mut weights = vec![3, 3, 2];
1093 weights.extend(has_files.then_some(2));
1094 weights.extend(has_comp.then_some(3));
1095 weights.into_iter().map(Constraint::Fill).collect()
1096 } else {
1097 match (has_files, has_comp) {
1098 (true, true) => vec![
1099 Constraint::Percentage(25),
1100 Constraint::Percentage(30),
1101 Constraint::Percentage(20),
1102 Constraint::Percentage(25),
1103 ],
1104 (true, false) => vec![
1105 Constraint::Percentage(35),
1106 Constraint::Percentage(40),
1107 Constraint::Percentage(25),
1108 ],
1109 (false, true) => vec![
1110 Constraint::Percentage(30),
1111 Constraint::Percentage(40),
1112 Constraint::Percentage(30),
1113 ],
1114 (false, false) => vec![Constraint::Percentage(50), Constraint::Percentage(50)],
1115 }
1116 };
1117 let g = crate::glyphs::get();
1121 let (highlight, symbol) = if body_focused {
1122 (self.theme.highlight_style(), g.selector)
1123 } else {
1124 (Style::default().fg(self.theme.accent), g.selector_blank)
1125 };
1126 let symbol = Span::styled(symbol, Style::default().fg(self.theme.accent));
1127 if book.is_some() {
1130 let about = 2 + usize::from(has_units);
1131 let room = Rect {
1132 width: inner
1133 .width
1134 .saturating_sub(crate::glyphs::cell_width(g.selector) as u16),
1135 ..inner
1136 };
1137 let cols = Layout::horizontal(widths.clone()).spacing(1).split(room);
1138 if let Some(col) = cols.get(about) {
1139 for cells in &mut rows {
1140 if let Some(cell) = cells.get_mut(about) {
1141 *cell = clip(cell, col.width as usize);
1142 }
1143 }
1144 }
1145 }
1146 let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
1147 let table = Table::new(rows, widths)
1148 .header(header)
1149 .column_spacing(1)
1150 .row_highlight_style(highlight)
1151 .highlight_symbol(symbol)
1152 .highlight_spacing(HighlightSpacing::Always);
1153 let table_area = Rect {
1154 height: inner.height.saturating_sub(u16::from(!fits)),
1155 ..inner
1156 };
1157 StatefulWidget::render(table, table_area, buf, &mut self.modal.schema_table_state);
1158
1159 if !fits && inner.height > 0 {
1160 let above = offset;
1161 let below = total_rows.saturating_sub(offset + take);
1162 let counted = match (above, below) {
1163 (0, 0) => None,
1164 (0, n) => Some(format!("{} below", format_int(n))),
1165 (n, 0) => Some(format!("{} above", format_int(n))),
1166 (a, b) => Some(format!("{} above, {} below", format_int(a), format_int(b))),
1167 };
1168 if let Some(text) = counted {
1169 Paragraph::new(text)
1170 .style(Style::default().fg(self.theme.dimmed))
1171 .alignment(ratatui::layout::Alignment::Right)
1172 .render(
1173 Rect {
1174 y: inner.y + inner.height - 1,
1175 height: 1,
1176 ..inner
1177 },
1178 buf,
1179 );
1180 }
1181 }
1182 (2 + take + usize::from(!fits)).min(area.height as usize) as u16
1184 }
1185
1186 fn render_resources_tab(&self, area: Rect, buf: &mut Buffer) {
1187 const LABEL_WIDTH: u16 = 17;
1189 let label_constraint = Constraint::Length(LABEL_WIDTH);
1190 let value_constraint = Constraint::Min(1);
1191 let mut y = area.y;
1192 let h = area.height;
1193 let w = area.width;
1194
1195 fn label_value_row(label: &str, value: &str, area: Rect, buf: &mut Buffer, label_w: u16) {
1196 let chunks = Layout::default()
1197 .direction(Direction::Horizontal)
1198 .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1199 .split(area);
1200 Paragraph::new(label).render(chunks[0], buf);
1201 Paragraph::new(value).render(chunks[1], buf);
1202 }
1203
1204 if y >= area.y + h {
1205 return;
1206 }
1207 let size_chunks = Layout::default()
1208 .direction(Direction::Horizontal)
1209 .constraints([label_constraint, value_constraint])
1210 .split(Rect {
1211 y,
1212 width: w,
1213 height: 1,
1214 ..area
1215 });
1216 let file_size = match self.ctx.facts {
1219 None | Some(FileFacts::Read { size: None, .. }) => Span::raw(crate::glyphs::get().dash),
1220 Some(FileFacts::Read {
1221 size: Some(size), ..
1222 }) => Span::raw(format_bytes(*size)),
1223 Some(FileFacts::Reading) => {
1224 Span::styled("reading...", Style::default().fg(self.theme.dimmed))
1225 }
1226 Some(FileFacts::Failed(why)) => Span::styled(
1227 clip(why, size_chunks[1].width as usize),
1228 Style::default().fg(self.theme.error),
1229 ),
1230 };
1231 Paragraph::new("File size:").render(size_chunks[0], buf);
1232 Paragraph::new(Line::from(file_size)).render(size_chunks[1], buf);
1233 y += 1;
1234
1235 if y >= area.y + h {
1236 return;
1237 }
1238 let fmt = self
1239 .ctx
1240 .format
1241 .map(|f| f.as_str())
1242 .unwrap_or(crate::glyphs::get().dash);
1243 label_value_row(
1244 "Format:",
1245 fmt,
1246 Rect {
1247 y,
1248 width: w,
1249 height: 1,
1250 ..area
1251 },
1252 buf,
1253 LABEL_WIDTH,
1254 );
1255 y += 1;
1256
1257 if let Some(read) = read_line(self.state) {
1259 if y >= area.y + h {
1260 return;
1261 }
1262 label_value_row(
1263 "Read:",
1264 &read,
1265 Rect {
1266 y,
1267 width: w,
1268 height: 1,
1269 ..area
1270 },
1271 buf,
1272 LABEL_WIDTH,
1273 );
1274 y += 1;
1275 }
1276
1277 if y >= area.y + h {
1278 return;
1279 }
1280 let buf_rows = self.state.buffered_rows();
1281 let max_rows = self.state.max_buffered_rows();
1282 let row_area = Rect {
1283 y,
1284 width: w,
1285 height: 1,
1286 ..area
1287 };
1288 let row_chunks = Layout::default()
1289 .direction(Direction::Horizontal)
1290 .constraints([label_constraint, value_constraint])
1291 .split(row_area);
1292 Paragraph::new("Buffer (Rows):").render(row_chunks[0], buf);
1293 if max_rows > 0 {
1295 let label = format!("{} / {}", format_int(buf_rows), format_int(max_rows));
1296 Paragraph::new(label).render(row_chunks[1], buf);
1297 } else {
1298 Paragraph::new(format_int(buf_rows)).render(row_chunks[1], buf);
1299 }
1300 y += 1;
1301
1302 if y >= area.y + h {
1303 return;
1304 }
1305 let buf_mb = self
1306 .state
1307 .buffered_memory_bytes()
1308 .map(|b| b / (1024 * 1024));
1309 let max_mb = self.state.max_buffered_mb();
1310 let mb_area = Rect {
1311 y,
1312 width: w,
1313 height: 1,
1314 ..area
1315 };
1316 let mb_chunks = Layout::default()
1317 .direction(Direction::Horizontal)
1318 .constraints([label_constraint, value_constraint])
1319 .split(mb_area);
1320 Paragraph::new("Buffer (MB):").render(mb_chunks[0], buf);
1321 if max_mb > 0 {
1322 let label = match buf_mb {
1323 Some(m) => format!("{:.1} / {} MiB", m as f64, max_mb),
1324 None => crate::glyphs::get().dash.to_string(),
1325 };
1326 Paragraph::new(label).render(mb_chunks[1], buf);
1327 } else {
1328 let value = buf_mb
1329 .map(|m| format!("{:.1} MiB", m as f64))
1330 .unwrap_or_else(|| {
1331 self.state
1332 .buffered_memory_bytes()
1333 .map(|b| format_bytes(b as u64))
1334 .unwrap_or_else(|| crate::glyphs::get().dash.to_string())
1335 });
1336 Paragraph::new(value).render(mb_chunks[1], buf);
1337 }
1338 y += 1;
1339
1340 self.render_measurements(area, buf, &mut y, LABEL_WIDTH);
1341 }
1342
1343 fn render_measurements(&self, area: Rect, buf: &mut Buffer, y: &mut u16, label_w: u16) {
1350 let meter = self.state.measurements();
1351 let mut rows: Vec<(&str, String)> = [
1352 ("Listing:", "files", "file", meter.listing()),
1353 ("Footers:", "footers read", "footer read", meter.footers()),
1354 ("Last page:", "files read", "file read", meter.last_page()),
1355 ]
1356 .into_iter()
1357 .filter_map(|(label, unit, singular, cost)| {
1358 Some((label, measurement_line(&cost?, unit, singular)))
1359 })
1360 .collect();
1361 if let Some(total) = meter.total() {
1362 rows.push(("Total:", total_line(&total)));
1363 }
1364 if rows.is_empty() {
1365 return;
1366 }
1367 let bottom = area.y + area.height;
1368 if *y + 2 >= bottom {
1374 return;
1375 }
1376 *y += 1;
1377 SectionRule {
1378 title: "Measurements",
1379 chip: None,
1380 }
1381 .render(
1382 Rect {
1383 y: *y,
1384 width: area.width,
1385 height: 1,
1386 ..area
1387 },
1388 buf,
1389 self.theme,
1390 );
1391 *y += 1;
1392 for (label, line) in rows {
1393 if *y >= bottom {
1394 return;
1395 }
1396 let chunks = Layout::default()
1397 .direction(Direction::Horizontal)
1398 .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1399 .split(Rect {
1400 y: *y,
1401 width: area.width,
1402 height: 1,
1403 ..area
1404 });
1405 Paragraph::new(label).render(chunks[0], buf);
1406 Paragraph::new(line).render(chunks[1], buf);
1407 *y += 1;
1408 }
1409 }
1410
1411 fn render_metadata_tab(&mut self, area: Rect, buf: &mut Buffer) {
1414 let Some(read) = self.state.delimited_read().cloned() else {
1415 return;
1416 };
1417 let Some(metadata) = &read.metadata else {
1418 return;
1419 };
1420 if area.height == 0 || area.width < 8 {
1421 return;
1422 }
1423 let mut lines = Vec::new();
1424 if let Some(title) = &metadata.title {
1425 lines.push((title.clone(), Style::default()));
1426 }
1427 if let Some(file) = &read.facts_from {
1428 lines.push((format!("From {file}"), Style::default()));
1429 }
1430 let shown: Vec<(String, crate::model_files::MetaValue)> = if metadata.pairs.is_empty() {
1431 let line = read.delimited().metadata_line.unwrap_or(1);
1432 vec![(
1433 format!("line {line}"),
1434 crate::model_files::MetaValue::Text(metadata.raw.clone()),
1435 )]
1436 } else {
1437 metadata
1438 .pairs
1439 .iter()
1440 .map(|(k, v)| (k.clone(), crate::model_files::MetaValue::Text(v.clone())))
1441 .collect()
1442 };
1443 self.render_detail(area, buf, &lines, "Metadata", &shown);
1444 }
1445
1446 fn render_format_tab(&mut self, area: Rect, buf: &mut Buffer) {
1448 let state = self.state;
1449 let Some(detail) = state.format_detail().or_else(|| self.ctx.facts_detail()) else {
1450 let (said, style) = match self.ctx.facts {
1452 Some(FileFacts::Failed(why)) => {
1453 (why.clone(), Style::default().fg(self.theme.error))
1454 }
1455 _ => (
1456 "reading...".to_string(),
1457 Style::default().fg(self.theme.dimmed),
1458 ),
1459 };
1460 Paragraph::new(clip(&said, area.width as usize))
1461 .style(style)
1462 .render(
1463 Rect {
1464 height: area.height.min(1),
1465 ..area
1466 },
1467 buf,
1468 );
1469 return;
1470 };
1471 let warn = Style::default().fg(self.theme.warning);
1472 let lines: Vec<(String, Style)> = detail
1473 .lines
1474 .iter()
1475 .map(|line| (line.clone(), Style::default()))
1476 .chain(detail.warnings.iter().map(|line| (line.clone(), warn)))
1477 .collect();
1478 let pick = !detail.tables.is_empty();
1479 self.render_detail_list(area, buf, &lines, detail.list_title, &detail.list, pick);
1480 }
1481
1482 fn render_detail(
1486 &mut self,
1487 area: Rect,
1488 buf: &mut Buffer,
1489 lines: &[(String, Style)],
1490 title: &str,
1491 list: &[(String, crate::model_files::MetaValue)],
1492 ) {
1493 self.render_detail_list(area, buf, lines, title, list, false);
1494 }
1495
1496 fn render_detail_list(
1499 &mut self,
1500 area: Rect,
1501 buf: &mut Buffer,
1502 lines: &[(String, Style)],
1503 title: &str,
1504 list: &[(String, crate::model_files::MetaValue)],
1505 pick: bool,
1506 ) {
1507 if area.height == 0 || area.width < 8 {
1508 return;
1509 }
1510 let width = area.width as usize;
1511 let mut y = area.y;
1512 let bottom = area.y + area.height;
1513 for (line, style) in lines {
1515 for part in wrap_to(line, width) {
1516 if y >= bottom {
1517 return;
1518 }
1519 Paragraph::new(clip(&part, width)).style(*style).render(
1520 Rect {
1521 y,
1522 height: 1,
1523 ..area
1524 },
1525 buf,
1526 );
1527 y += 1;
1528 }
1529 }
1530 if list.is_empty() || y + 2 >= bottom {
1533 return;
1534 }
1535 y += 1;
1536 let count = group_u64(list.len() as u64);
1537 SectionRule {
1538 title,
1539 chip: Some(&count),
1540 }
1541 .render(
1542 Rect {
1543 y,
1544 height: 1,
1545 ..area
1546 },
1547 buf,
1548 self.theme,
1549 );
1550 y += 1;
1551
1552 let gutter = u16::from(pick);
1555 let rows = metadata_lines(list, width.saturating_sub(gutter as usize));
1556 let room = (bottom - y) as usize;
1557 let fits = rows.len() <= room;
1558 let shown = if fits { room } else { room.saturating_sub(1) };
1560 self.modal.detail_visible = shown;
1561 let max_scroll = rows.len().saturating_sub(shown);
1562 let picked = pick.then(|| {
1565 let starts: Vec<usize> = (0..rows.len())
1566 .filter(|&i| !rows[i].0.trim().is_empty())
1567 .collect();
1568 let at = self
1569 .modal
1570 .detail_selected
1571 .min(starts.len().saturating_sub(1));
1572 self.modal.detail_selected = at;
1573 let start = starts.get(at).copied().unwrap_or(0);
1574 let end = starts.get(at + 1).copied().unwrap_or(rows.len());
1575 if start < self.modal.detail_scroll {
1576 self.modal.detail_scroll = start;
1577 } else if end > self.modal.detail_scroll + shown {
1578 self.modal.detail_scroll = end.saturating_sub(shown).min(start);
1579 }
1580 start..end
1581 });
1582 self.modal.detail_scroll = self.modal.detail_scroll.min(max_scroll);
1583 let first = self.modal.detail_scroll;
1584 let key_style = Style::default().fg(self.theme.text_secondary);
1585 let rail = crate::glyphs::get().rail;
1586 for (i, (key, value)) in rows.iter().enumerate().skip(first).take(shown) {
1587 let row = Rect {
1588 y,
1589 height: 1,
1590 ..area
1591 };
1592 let on = picked.as_ref().is_some_and(|p| p.contains(&i));
1593 let (key_style, value_style) = if on {
1594 let hl = self.theme.highlight_style();
1595 (key_style.patch(hl), Style::default().patch(hl))
1596 } else {
1597 (key_style, Style::default())
1598 };
1599 if on && picked.as_ref().is_some_and(|p| p.start == i) {
1600 buf.set_string(row.x, row.y, rail, Style::default().fg(self.theme.accent));
1601 }
1602 Paragraph::new(Line::from(vec![
1603 Span::styled(key.clone(), key_style),
1604 Span::styled(value.clone(), value_style),
1605 ]))
1606 .render(
1607 Rect {
1608 x: row.x + gutter,
1609 width: row.width.saturating_sub(gutter),
1610 ..row
1611 },
1612 buf,
1613 );
1614 y += 1;
1615 }
1616 if !fits && shown > 0 {
1617 let above = first;
1618 let below = rows.len().saturating_sub(first + shown);
1619 let text = match (above, below) {
1620 (0, n) => format!("{} below", group_chrome(n)),
1621 (n, 0) => format!("{} above", group_chrome(n)),
1622 (a, b) => format!("{} above, {} below", group_chrome(a), group_chrome(b)),
1623 };
1624 Paragraph::new(text)
1625 .style(Style::default().fg(self.theme.dimmed))
1626 .alignment(ratatui::layout::Alignment::Right)
1627 .render(
1628 Rect {
1629 y: bottom - 1,
1630 height: 1,
1631 ..area
1632 },
1633 buf,
1634 );
1635 }
1636 }
1637
1638 fn render_notes_tab(&mut self, area: Rect, buf: &mut Buffer) {
1648 let notes = self.state.notes();
1649 if area.height == 0 || area.width <= 4 || notes.is_empty() {
1650 return;
1651 }
1652 let selected = self.modal.notes_selected_index.min(notes.len() - 1);
1653 let width = area.width as usize;
1654 let blocks: Vec<Vec<NoteRow>> = notes
1655 .iter()
1656 .enumerate()
1657 .map(|(index, note)| note_rows(note, index == selected, width))
1658 .collect();
1659 let heights: Vec<usize> = blocks.iter().map(Vec::len).collect();
1660 let dim = Style::default().fg(self.theme.dimmed);
1661
1662 let full = area.height as usize;
1666 let (first, last) = notes_window(&heights, selected, self.modal.notes_scroll_offset, full);
1667 let all_shown = first == 0 && last == heights.len();
1668 let offer = notes[selected]
1675 .read_as_text
1676 .as_ref()
1677 .map(|column| format!("Enter read {column} as text"));
1678 let reserve = (!all_shown || offer.is_some()) && heights[selected] < full;
1679 let show = if reserve { full - 1 } else { full };
1680 if heights[selected] > show {
1681 let count = notes.len();
1686 Paragraph::new(Line::from(Span::styled(
1687 format!(
1688 "{} {} {} selected: no room",
1689 group_chrome(count),
1690 if count == 1 { "note" } else { "notes" },
1691 crate::glyphs::get().middot
1692 ),
1693 dim,
1694 )))
1695 .render(Rect { height: 1, ..area }, buf);
1696 return;
1697 }
1698 let (first, last) = if reserve {
1699 notes_window(&heights, selected, self.modal.notes_scroll_offset, show)
1700 } else {
1701 (first, last)
1702 };
1703 self.modal.notes_scroll_offset = first;
1704
1705 let mut y = area.y;
1706 let bottom = area.y + show as u16;
1707 for (offset, block) in blocks[first..last].iter().enumerate() {
1708 if offset > 0 && y < bottom {
1709 y += 1;
1710 }
1711 for row in block {
1712 if y >= bottom {
1713 break;
1714 }
1715 let at = Rect {
1716 y,
1717 height: 1,
1718 ..area
1719 };
1720 if row.dim {
1721 Paragraph::new(Line::from(Span::styled(row.text.clone(), dim))).render(at, buf);
1722 } else {
1723 Paragraph::new(row.text.as_str()).render(at, buf);
1724 }
1725 y += 1;
1726 }
1727 }
1728
1729 let (above, below) = (first, notes.len() - last);
1735 let hidden = match (reserve, above, below) {
1736 (false, _, _) | (_, 0, 0) => None,
1737 (_, 0, n) => Some(format!("{} below", group_chrome(n))),
1738 (_, n, 0) => Some(format!("{} above", group_chrome(n))),
1739 (_, a, b) => Some(format!(
1740 "{} above, {} below",
1741 group_chrome(a),
1742 group_chrome(b)
1743 )),
1744 };
1745 if !reserve || (hidden.is_none() && offer.is_none()) {
1746 return;
1747 }
1748 let last_row = Rect {
1749 y: area.y + area.height - 1,
1750 height: 1,
1751 ..area
1752 };
1753 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1755 let taken = hidden
1756 .as_ref()
1757 .map(|text| (text.width() as u16).saturating_add(1))
1758 .unwrap_or(0);
1759 if let Some(offer) = offer.as_ref() {
1760 let room = last_row.width.saturating_sub(taken) as usize;
1761 let offer = if offer.width() > room {
1765 let mark = crate::glyphs::get().ellipsis;
1768 let mut kept = String::new();
1769 for ch in offer.chars() {
1770 if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
1771 break;
1772 }
1773 kept.push(ch);
1774 }
1775 Some(format!("{kept}{mark}"))
1776 } else {
1777 Some(offer.clone())
1778 };
1779 if let Some(offer) = offer.filter(|_| room >= 8) {
1781 Paragraph::new(Line::from(Span::styled(offer, dim))).render(
1782 Rect {
1783 width: room as u16,
1784 ..last_row
1785 },
1786 buf,
1787 );
1788 }
1789 }
1790 if let Some(hidden) = hidden {
1791 Paragraph::new(Line::from(Span::styled(hidden, dim)))
1792 .right_aligned()
1793 .render(last_row, buf);
1794 }
1795 }
1796
1797 fn render_partitioned_data_tab(&self, area: Rect, buf: &mut Buffer) {
1798 let y = area.y;
1799 let w = area.width;
1800
1801 let Some(partition_columns) = self.state.partition_columns() else {
1802 Paragraph::new("Partition columns: unknown").render(
1803 Rect {
1804 y,
1805 width: w,
1806 height: 1,
1807 ..area
1808 },
1809 buf,
1810 );
1811 return;
1812 };
1813
1814 if partition_columns.is_empty() {
1815 Paragraph::new("Partition columns: none").render(
1816 Rect {
1817 y,
1818 width: w,
1819 height: 1,
1820 ..area
1821 },
1822 buf,
1823 );
1824 return;
1825 }
1826
1827 let line = format!("Partition columns: {}", partition_columns.join(", "));
1828 Paragraph::new(line).render(
1829 Rect {
1830 y,
1831 width: w,
1832 height: 1,
1833 ..area
1834 },
1835 buf,
1836 );
1837 }
1838}
1839
1840fn format_took(took: std::time::Duration) -> String {
1852 let ms = took.as_secs_f64() * 1000.0;
1853 if (ms * 100.0).round() < 100_000.0 {
1859 format!("{ms:.2} ms")
1860 } else {
1861 format!("{:.2}s", took.as_secs_f64())
1862 }
1863}
1864
1865fn wire_line(wire: crate::measurements::OverTheWire) -> String {
1867 let mut line = format!(
1868 ", {} request{}",
1869 format_int(wire.requests),
1870 if wire.requests == 1 { "" } else { "s" }
1871 );
1872 if let Some(bytes) = wire.bytes {
1876 line.push_str(&format!(", {}", format_bytes(bytes)));
1877 }
1878 line
1879}
1880
1881fn measurement_line(cost: &crate::measurements::Cost, unit: &str, singular: &str) -> String {
1890 let mut line = format_took(cost.took);
1891 if let Some(files) = cost.files {
1895 let unit = if files == 1 { singular } else { unit };
1896 line.push_str(&format!(", {} {unit}", format_int(files)));
1897 }
1898 if let Some(wire) = cost.over_the_wire {
1899 line.push_str(&wire_line(wire));
1900 }
1901 line
1902}
1903
1904fn total_line(total: &crate::measurements::Total) -> String {
1908 let mut line = format_took(total.took);
1909 if let Some(wire) = total.over_the_wire {
1910 line.push_str(&wire_line(wire));
1911 }
1912 line
1913}
1914
1915fn format_int(n: usize) -> String {
1918 crate::numfmt::group_chrome(n)
1919}
1920
1921fn columns_by_type(schema: &Schema) -> String {
1922 let mut counts: HashMap<String, usize> = HashMap::new();
1923 for (_, dtype) in schema.iter() {
1924 let k = dtype.to_string();
1925 *counts.entry(k).or_default() += 1;
1926 }
1927 let mut pairs: Vec<_> = counts.into_iter().collect();
1928 pairs.sort_by(|a, b| a.0.cmp(&b.0));
1929 pairs
1930 .into_iter()
1931 .map(|(k, v)| format!("{}: {}", k, v))
1932 .collect::<Vec<_>>()
1933 .join(&format!(" {} ", crate::glyphs::get().middot))
1934}
1935
1936impl<'a> Widget for &mut DataTableInfo<'a> {
1937 fn render(self, area: Rect, buf: &mut Buffer) {
1938 let ctx = self.theme;
1939 let offered = TabsOffered {
1940 documentation: self.documentation.is_some(),
1941 ..TabsOffered::of(self.state, self.ctx.facts_tab)
1942 };
1943 let tab = self.modal.active_tab;
1944
1945 let g = crate::glyphs::get();
1948 let scrolls = match tab {
1949 InfoTab::Schema => true,
1950 InfoTab::Notes => offered.notes,
1951 InfoTab::Metadata => offered.metadata,
1952 InfoTab::Format => offered.format,
1953 InfoTab::Documentation => offered.documentation,
1954 _ => false,
1955 };
1956 let tables = tab == InfoTab::Format
1958 && offered.format
1959 && self
1960 .state
1961 .format_detail()
1962 .is_some_and(|d| !d.tables.is_empty());
1963 let mut footer = HintBar::from_ctx(ctx).hint_weighted(g.updown_lr, "Tabs", 3);
1964 if tables {
1965 footer = footer
1966 .hint_weighted("Enter", "Open", 2)
1967 .hint_weighted(g.updown, "Move", 2);
1968 } else if scrolls {
1969 footer = footer.hint_weighted(g.updown, "Scroll", 2);
1970 }
1971 if tab == InfoTab::Documentation && offered.documentation {
1972 footer = footer.hint_weighted("Enter", "Values", 1);
1973 if self
1974 .documentation
1975 .as_deref()
1976 .is_some_and(|d| d.offers_open())
1977 {
1978 footer = footer.hint_weighted("o", "Open", 1);
1979 }
1980 footer = footer.hint_weighted("y", "Copy", 1);
1981 }
1982 if tab == InfoTab::Schema && self.header_toggle {
1983 footer = footer.hint_weighted("H", "Header", -1);
1984 }
1985 if self.hex {
1986 footer = footer.hint_weighted("x", "Hex", 0);
1987 }
1988 let footer = footer.hint_weighted("Esc", "Close", 4);
1989 let surface = Surface::new("Info");
1992 let surface = if area.height > 3 {
1993 surface.footer(&footer)
1994 } else {
1995 surface
1996 };
1997 let content = surface.render(area, buf, ctx);
1998 if content.height == 0 || content.width < 4 {
1999 return;
2000 }
2001 let tab_rows = u16::from(content.height >= 4);
2004 let gap = u16::from(content.height >= 6);
2005
2006 let tabs = InfoTab::visible(offered);
2010 let active = tabs[tab.index(offered)];
2011 let current = tab.index(offered);
2012 let mut spans = Vec::new();
2013 let mut clicks = Vec::new();
2014 for (i, t) in tabs.iter().enumerate() {
2015 let is_active = *t == active;
2016 if i > 0 {
2017 spans.push(Span::styled(
2018 format!(" {}", g.rule),
2019 Style::default().fg(ctx.dimmed),
2020 ));
2021 }
2022 spans.push(Span::raw(" "));
2023 let style = if is_active {
2024 Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
2025 } else {
2026 Style::default().fg(ctx.text_secondary)
2027 };
2028 let title = match (t, self.state.format_detail()) {
2031 (InfoTab::Format, Some(detail)) => detail.tab,
2032 (InfoTab::Format, None) => self.ctx.facts_tab.unwrap_or(t.title()),
2033 _ => t.title(),
2034 };
2035 clicks.push((
2037 spans.len(),
2038 crate::pointer::Hit::Option {
2039 field: None,
2040 index: i,
2041 current,
2042 },
2043 ));
2044 spans.push(Span::styled(title, style));
2045 }
2046 let tab_area = Rect {
2047 height: tab_rows,
2048 ..content
2049 };
2050 let line = Line::from(spans);
2051 if tab_rows > 0 {
2052 crate::pointer::record_spans(tab_area, &line, clicks);
2053 }
2054 Paragraph::new(line).render(tab_area, buf);
2055
2056 let body = Rect {
2059 y: content.y + tab_rows + gap,
2060 height: content.height - tab_rows - gap,
2061 ..content
2062 };
2063 match tab {
2064 InfoTab::Schema => self.render_schema_tab(body, buf),
2065 InfoTab::Resources => self.render_resources_tab(body, buf),
2066 InfoTab::Metadata if offered.metadata => self.render_metadata_tab(body, buf),
2067 InfoTab::Format if offered.format => self.render_format_tab(body, buf),
2068 InfoTab::Partitions if offered.partitions => {
2069 self.render_partitioned_data_tab(body, buf)
2070 }
2071 InfoTab::Notes if offered.notes => self.render_notes_tab(body, buf),
2072 InfoTab::Documentation if offered.documentation => {
2073 if let Some(page) = self.documentation.as_deref_mut() {
2074 crate::widgets::documentation::render_page(page, body, buf, ctx);
2075 }
2076 }
2077 InfoTab::Metadata
2078 | InfoTab::Format
2079 | InfoTab::Partitions
2080 | InfoTab::Notes
2081 | InfoTab::Documentation => self.render_schema_tab(body, buf),
2082 }
2083 }
2084}
2085
2086#[cfg(test)]
2087mod tests {
2088 use super::*;
2089
2090 fn offer(partitions: bool, notes: bool) -> TabsOffered {
2092 TabsOffered {
2093 metadata: false,
2094 format: false,
2095 partitions,
2096 notes,
2097 documentation: false,
2098 }
2099 }
2100
2101 #[test]
2105 fn file_facts_read_what_each_source_has() {
2106 let dir = tempfile::tempdir().unwrap();
2107 let csv = dir.path().join("rows.csv");
2108 std::fs::write(&csv, "a\n1\n").unwrap();
2109 let parquet = dir.path().join("rows.parquet");
2110 let mut df = df!("a" => &[1i64, 2, 3]).unwrap();
2111 ParquetWriter::new(std::fs::File::create(&parquet).unwrap())
2112 .finish(&mut df)
2113 .unwrap();
2114 let parquet_len = std::fs::metadata(&parquet).unwrap().len();
2115 let facts = crate::readers::of(crate::FileFormat::Parquet).facts;
2116
2117 assert!(matches!(
2118 FileFacts::read(&csv, None),
2119 Ok(FileFacts::Read {
2120 size: Some(4),
2121 footer: None,
2122 detail: None,
2123 })
2124 ));
2125 match FileFacts::read(&parquet, facts) {
2126 Ok(FileFacts::Read {
2127 size: Some(size),
2128 footer: Some(footer),
2129 detail: Some(detail),
2130 }) => {
2131 assert_eq!(size, parquet_len);
2132 assert_eq!(footer.num_rows, 3);
2133 assert_eq!(detail.tab, "Parquet");
2134 }
2135 other => panic!("a Parquet file's size, footer and tab: {other:?}"),
2136 }
2137 assert!(matches!(
2138 FileFacts::read(dir.path(), facts),
2139 Ok(FileFacts::Read {
2140 size: None,
2141 footer: None,
2142 detail: None,
2143 })
2144 ));
2145 assert_eq!(
2147 FileFacts::read(&dir.path().join("gone.parquet"), facts).unwrap_err(),
2148 "file not found"
2149 );
2150 assert_eq!(
2151 FileFacts::read(&csv, facts).expect_err("a CSV has no footer"),
2152 "unreadable footer"
2153 );
2154 }
2155
2156 #[test]
2162 fn the_schema_tab_does_not_call_a_partial_the_total() {
2163 use crate::widgets::datatable::DataTableState;
2164 use polars::prelude::*;
2165
2166 let rows = || df!("id" => (0..70i64).collect::<Vec<_>>()).unwrap().lazy();
2167 let mut lf = rows();
2168 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2169 let mut state = DataTableState::from_schema_and_lazyframe(
2170 schema,
2171 rows(),
2172 &crate::OpenOptions::default(),
2173 None,
2174 )
2175 .unwrap();
2176 state.set_provisional_rows(70);
2179
2180 let theme = RenderContext::for_test();
2181 let painted = |state: &DataTableState| {
2182 let area = Rect::new(0, 0, 60, 12);
2183 let mut buf = Buffer::empty(area);
2184 let mut modal = InfoModal::default();
2185 let panel = DataTableInfo::new(
2186 state,
2187 InfoContext {
2188 format: None,
2189 facts: None,
2190 facts_tab: None,
2191 footer_expected: false,
2192 declared_types: false,
2193 },
2194 &mut modal,
2195 &theme,
2196 );
2197 panel.render_schema_summary(area, &mut buf);
2198 (0..area.height)
2199 .map(|y| {
2200 (0..area.width)
2201 .map(|x| buf[(x, y)].symbol().to_string())
2202 .collect::<String>()
2203 })
2204 .collect::<Vec<_>>()
2205 .join("\n")
2206 };
2207
2208 let uncounted = painted(&state);
2209 assert!(
2210 uncounted.contains("counting..."),
2211 "a count not taken is not a total: {uncounted}"
2212 );
2213 assert!(
2214 !uncounted.contains("70"),
2215 "and the buffer's height is not shown in its place: {uncounted}"
2216 );
2217
2218 assert!(state.count_landed(state.len_generation(), 70, None));
2219 let counted = painted(&state);
2220 assert!(
2221 counted.contains("Rows (total): 70"),
2222 "and once it has been counted, that is what it says: {counted}"
2223 );
2224 }
2225
2226 #[test]
2230 fn a_tall_schema_counts_its_hidden_columns() {
2231 use crate::widgets::datatable::DataTableState;
2232 use polars::prelude::*;
2233
2234 let wide = || {
2235 let base = df!("col_0" => &[1i64]).unwrap().lazy();
2236 let extra: Vec<Expr> = (1..24)
2237 .map(|i| lit(1i64).alias(format!("col_{i}")))
2238 .collect();
2239 base.with_columns(extra)
2240 };
2241 let mut lf = wide();
2242 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2243 let state = DataTableState::from_schema_and_lazyframe(
2244 schema,
2245 wide(),
2246 &crate::OpenOptions::default(),
2247 None,
2248 )
2249 .unwrap();
2250
2251 let theme = RenderContext::for_test();
2252 let area = Rect::new(0, 0, 60, 16);
2253 let mut buf = Buffer::empty(area);
2254 let mut modal = InfoModal::default();
2255 let mut panel = DataTableInfo::new(
2256 &state,
2257 InfoContext {
2258 format: None,
2259 facts: None,
2260 facts_tab: None,
2261 footer_expected: false,
2262 declared_types: false,
2263 },
2264 &mut modal,
2265 &theme,
2266 );
2267 (&mut panel).render(area, &mut buf);
2268 let text = (0..area.height)
2269 .map(|y| {
2270 (0..area.width)
2271 .map(|x| buf[(x, y)].symbol().to_string())
2272 .collect::<String>()
2273 })
2274 .collect::<Vec<_>>()
2275 .join("\n");
2276 assert!(
2277 text.contains("below"),
2278 "the hidden columns are counted: {text}"
2279 );
2280 assert!(text.contains("Esc"), "the footer names the way out: {text}");
2281 assert!(text.contains("Tabs"), "and the tab keys: {text}");
2282 assert!(!text.contains(">>"), "the bespoke marker is gone: {text}");
2283 }
2284
2285 #[test]
2288 fn the_schema_footer_offers_h_only_where_it_works() {
2289 use crate::widgets::datatable::DataTableState;
2290 use polars::prelude::*;
2291
2292 let lf = || df!("a" => &[1i64], "b" => &[2i64]).unwrap().lazy();
2293 let schema = std::sync::Arc::new((*lf().collect_schema().unwrap()).clone());
2294 let state = DataTableState::from_schema_and_lazyframe(
2295 schema,
2296 lf(),
2297 &crate::OpenOptions::default(),
2298 None,
2299 )
2300 .unwrap();
2301 let theme = RenderContext::for_test();
2302 let area = Rect::new(0, 0, 80, 16);
2303 let footer = |header_toggle: bool, tab: InfoTab| {
2304 let mut buf = Buffer::empty(area);
2305 let mut modal = InfoModal {
2306 active_tab: tab,
2307 ..Default::default()
2308 };
2309 let mut panel = DataTableInfo::new(
2310 &state,
2311 InfoContext {
2312 format: None,
2313 facts: None,
2314 facts_tab: None,
2315 footer_expected: false,
2316 declared_types: false,
2317 },
2318 &mut modal,
2319 &theme,
2320 );
2321 panel.header_toggle = header_toggle;
2322 (&mut panel).render(area, &mut buf);
2323 (0..area.height)
2324 .map(|y| {
2325 (0..area.width)
2326 .map(|x| buf[(x, y)].symbol().to_string())
2327 .collect::<String>()
2328 })
2329 .collect::<Vec<_>>()
2330 .join("\n")
2331 };
2332 let shown = footer(true, InfoTab::Schema);
2333 assert!(shown.contains("Header"), "{shown}");
2334 assert!(!footer(false, InfoTab::Schema).contains("Header"));
2335 assert!(!footer(true, InfoTab::Resources).contains("Header"));
2336 }
2337
2338 #[test]
2347 fn a_time_reads_in_the_unit_the_page_promises() {
2348 use std::time::Duration;
2349
2350 let cases = [
2351 (Duration::ZERO, "0.00 ms"),
2352 (Duration::from_nanos(1_000), "0.00 ms"),
2353 (Duration::from_nanos(5_000), "0.01 ms"),
2354 (Duration::from_micros(344), "0.34 ms"),
2355 (Duration::from_micros(999_500), "999.50 ms"),
2356 (Duration::from_nanos(999_994_999), "999.99 ms"),
2357 (Duration::from_nanos(999_995_000), "1.00s"),
2358 (Duration::from_secs(1), "1.00s"),
2359 (Duration::from_millis(3_880), "3.88s"),
2360 ];
2361 for (took, expected) in cases {
2362 assert_eq!(
2363 format_took(took),
2364 expected,
2365 "{took:?} should read as {expected}"
2366 );
2367 }
2368 }
2369
2370 #[test]
2373 fn the_resources_tab_says_how_the_data_is_read() {
2374 use crate::widgets::datatable::{DataTableState, OpenFacts};
2375 let painted = |read_mode: Option<crate::ReadMode>| {
2376 let rows = || df!("id" => [1i64, 2]).unwrap().lazy();
2377 let schema = Arc::new((*rows().collect_schema().unwrap()).clone());
2378 let state = DataTableState::from_schema_and_lazyframe(
2379 schema,
2380 rows(),
2381 &crate::OpenOptions::default(),
2382 None,
2383 )
2384 .unwrap()
2385 .with_open(OpenFacts {
2386 read_mode,
2387 ..Default::default()
2388 });
2389 let theme = RenderContext::for_test();
2390 let area = Rect::new(0, 0, 60, 12);
2391 let mut buf = Buffer::empty(area);
2392 let mut modal = InfoModal::default();
2393 DataTableInfo::new(
2394 &state,
2395 InfoContext {
2396 format: None,
2397 facts: None,
2398 facts_tab: None,
2399 footer_expected: false,
2400 declared_types: false,
2401 },
2402 &mut modal,
2403 &theme,
2404 )
2405 .render_resources_tab(area, &mut buf);
2406 (0..area.height)
2407 .map(|y| {
2408 (0..area.width)
2409 .map(|x| buf[(x, y)].symbol())
2410 .collect::<String>()
2411 })
2412 .collect::<Vec<_>>()
2413 };
2414 let lines = painted(Some(crate::ReadMode::InMemory));
2415 assert!(
2416 lines
2417 .iter()
2418 .any(|l| l.trim_end() == format!("{:<17}in memory", "Read:")),
2419 "{lines:#?}"
2420 );
2421 let converted = painted(Some(crate::ReadMode::Converted));
2422 assert!(converted.iter().any(|l| l.contains("converted to Arrow")));
2423 assert!(!painted(None).iter().any(|l| l.starts_with("Read:")));
2424 }
2425
2426 #[test]
2433 fn the_resources_tab_shows_what_was_measured_and_nothing_else() {
2434 use crate::measurements::Meter;
2435 use crate::widgets::datatable::DataTableState;
2436 use polars::prelude::*;
2437 use std::time::Duration;
2438
2439 let dataset_with = |meter: &std::sync::Arc<Meter>| {
2442 let rows = || df!("id" => (0..3i64).collect::<Vec<_>>()).unwrap().lazy();
2443 let mut lf = rows();
2444 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2445 DataTableState::from_schema_and_lazyframe(
2446 schema,
2447 rows(),
2448 &crate::OpenOptions::default(),
2449 None,
2450 )
2451 .unwrap()
2452 .with_open(crate::widgets::datatable::OpenFacts {
2453 measurements: meter.clone(),
2454 ..Default::default()
2455 })
2456 };
2457
2458 let theme = RenderContext::for_test();
2459 let painted = |meter: &std::sync::Arc<Meter>, height: u16| {
2460 let state = dataset_with(meter);
2461 let area = Rect::new(0, 0, 70, height);
2462 let mut buf = Buffer::empty(area);
2463 let mut modal = InfoModal::default();
2464 let panel = DataTableInfo::new(
2465 &state,
2466 InfoContext {
2467 format: None,
2468 facts: None,
2469 facts_tab: None,
2470 footer_expected: false,
2471 declared_types: false,
2472 },
2473 &mut modal,
2474 &theme,
2475 );
2476 panel.render_resources_tab(area, &mut buf);
2477 (0..area.height)
2478 .map(|y| {
2479 (0..area.width)
2480 .map(|x| buf[(x, y)].symbol().to_string())
2481 .collect::<String>()
2482 })
2483 .collect::<Vec<_>>()
2484 .join("\n")
2485 };
2486
2487 let unmeasured = painted(&std::sync::Arc::new(Meter::default()), 24);
2490 assert!(
2491 !unmeasured.contains("Measurements"),
2492 "a meter holding nothing has nothing to show: {unmeasured}"
2493 );
2494
2495 let local = std::sync::Arc::new(Meter::default());
2497 local.listed(Duration::from_micros(344), Some(6541), false);
2498 local.read_footers(Duration::from_millis(3880), Some(6541), false);
2499 let shown = painted(&local, 24);
2500 assert!(
2501 shown.contains("Measurements"),
2502 "once there is something to say, the section appears: {shown}"
2503 );
2504 assert!(
2505 shown.contains("0.34 ms, 6,541 files"),
2506 "a listing that really took a third of a millisecond says so, rather than \
2507 rounding to a figure that reads as unmeasured: {shown}"
2508 );
2509 assert!(
2510 shown.contains("3.88s, 6,541 footers read"),
2511 "and a stretch over a second is in seconds, counting footers rather than files: {shown}"
2512 );
2513 assert!(
2514 shown.contains("Total:") && shown.contains("3.88s"),
2515 "the total is a time: {shown}"
2516 );
2517 for label in [
2519 "File size:",
2520 "Buffer (Rows):",
2521 "Buffer (MB):",
2522 "Listing:",
2523 "Total:",
2524 ] {
2525 let line = shown
2526 .lines()
2527 .find(|l| l.starts_with(label))
2528 .unwrap_or_else(|| panic!("{label} in {shown}"));
2529 let cells: Vec<char> = line.chars().collect();
2530 assert!(
2531 cells[label.len()..17].iter().all(|c| *c == ' ') && cells[17] != ' ',
2532 "{label} value at column 17: {line:?}"
2533 );
2534 }
2535 assert!(
2536 shown
2537 .lines()
2538 .any(|l| l.starts_with("Measurements ") && l.contains(crate::glyphs::get().rule_h)),
2539 "the heading is a section rule: {shown}"
2540 );
2541 assert!(
2542 !shown.contains("13,082"),
2543 "and not the two file counts added together, which is not the size of \
2544 anything: {shown}"
2545 );
2546 assert!(
2547 !shown.contains("requests"),
2548 "a local open made none, and says nothing rather than saying zero: {shown}"
2549 );
2550
2551 let just_one = std::sync::Arc::new(Meter::default());
2554 just_one.listed(Duration::from_millis(1), Some(1), false);
2555 just_one.footer_request(512);
2556 just_one.read_footers(Duration::from_millis(2), Some(1), true);
2557 let singular = painted(&just_one, 24);
2558 assert!(
2559 singular.contains("1 file,") || singular.contains("1 file "),
2560 "one file, not one files: {singular}"
2561 );
2562 assert!(
2563 singular.contains("1 footer read,"),
2564 "and one footer read, not one footers read: {singular}"
2565 );
2566 assert!(
2567 !singular.contains("1 files") && !singular.contains("1 footers"),
2568 "neither plural appears anywhere: {singular}"
2569 );
2570
2571 let globbed = std::sync::Arc::new(Meter::default());
2575 globbed.listed(Duration::from_millis(1), None, false);
2576 for _ in 0..4 {
2579 globbed.footer_request(250);
2580 }
2581 globbed.read_footers(Duration::from_millis(3), Some(2), true);
2582 let glob_shown = painted(&globbed, 24);
2583 let row = |label: &str| -> String {
2584 glob_shown
2585 .lines()
2586 .find(|l| l.trim_start().starts_with(label))
2587 .unwrap_or_else(|| panic!("{label} row is shown: {glob_shown}"))
2588 .to_string()
2589 };
2590 let listing_row = row("Listing:");
2591 assert_eq!(
2592 listing_row.trim_end(),
2593 "Listing: 1.00 ms",
2594 "the walk reports a time and nothing else: no file count it never learned, \
2595 and no request count no listing route can take"
2596 );
2597 let total_row = row("Total:");
2598 assert!(
2599 total_row.contains("4.00 ms") && total_row.contains("4 requests"),
2600 "and the total is both times with the footer reads' requests: {total_row:?}"
2601 );
2602
2603 let remote = std::sync::Arc::new(Meter::default());
2605 remote.listed(Duration::from_millis(500), Some(3), false);
2606 remote.footer_request(49_152);
2607 remote.read_footers(Duration::from_millis(1500), Some(3), true);
2608 let over_wire = painted(&remote, 24);
2609 assert!(
2610 over_wire.contains("1.50s, 3 footers read, 1 request, 48.0 KiB"),
2611 "the footer row says what datui asked for and what came back: {over_wire}"
2612 );
2613 assert!(
2614 over_wire.contains("500.00 ms, 3 files") && !over_wire.contains("500.00 ms, 3 files, "),
2615 "while the listing, whose pages the store turns over itself, claims no \
2616 requests of its own: {over_wire}"
2617 );
2618
2619 for height in 1..=24u16 {
2624 let short = painted(&local, height);
2625 if short.contains("Measurements") {
2626 assert!(
2627 short.contains("Listing:"),
2628 "at height {height} the heading is shown with no row under it: {short}"
2629 );
2630 }
2631 }
2632 }
2633
2634 #[test]
2637 fn the_schema_tab_names_a_file_s_other_tables() {
2638 use crate::widgets::datatable::{DataTableState, OpenFacts};
2639 use polars::prelude::*;
2640
2641 let theme = RenderContext::for_test();
2642 let paint = |other_tables: Vec<String>| {
2643 let mut lf = df!("id" => &[1i64, 2]).unwrap().lazy();
2644 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2645 let state = DataTableState::from_schema_and_lazyframe(
2646 schema,
2647 lf,
2648 &crate::OpenOptions::default(),
2649 None,
2650 )
2651 .unwrap()
2652 .with_open(OpenFacts {
2653 other_tables,
2654 ..Default::default()
2655 });
2656 let area = Rect::new(0, 0, 70, 12);
2657 let mut buf = Buffer::empty(area);
2658 let mut modal = InfoModal::default();
2659 modal.open();
2660 let mut panel = DataTableInfo::new(
2661 &state,
2662 InfoContext {
2663 format: None,
2664 facts: None,
2665 facts_tab: None,
2666 footer_expected: false,
2667 declared_types: false,
2668 },
2669 &mut modal,
2670 &theme,
2671 );
2672 (&mut panel).render(area, &mut buf);
2673 (0..area.height)
2674 .map(|y| {
2675 (0..area.width)
2676 .map(|x| buf[(x, y)].symbol().to_string())
2677 .collect::<String>()
2678 })
2679 .collect::<Vec<_>>()
2680 };
2681 let middot = crate::glyphs::get().middot;
2682 let text = paint(vec!["GSV 9".into(), "sentences".into()]);
2683 let line = format!("Other tables (--table): GSV 9 {middot} sentences");
2684 assert!(text.iter().any(|row| row.contains(&line)), "{text:#?}");
2685 let text = paint(Vec::new());
2686 assert!(
2687 !text.iter().any(|row| row.contains("Other tables")),
2688 "{text:#?}"
2689 );
2690 }
2691
2692 #[test]
2695 fn the_format_tab_shows_its_lines_and_list() {
2696 use crate::model_files::MetaValue;
2697 use crate::text_formats::Detail;
2698 use crate::widgets::datatable::{DataTableState, OpenFacts};
2699 use polars::prelude::*;
2700
2701 let theme = RenderContext::for_test();
2702 let mut lf = df!("time" => &[1i64]).unwrap().lazy();
2703 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2704 let list: Vec<(String, MetaValue)> = (0..30)
2705 .map(|i| {
2706 (
2707 format!("tb.sig{i}"),
2708 MetaValue::Text(format!("wire 1 bit id {i}")),
2709 )
2710 })
2711 .collect();
2712 let state = DataTableState::from_schema_and_lazyframe(
2713 schema,
2714 lf,
2715 &crate::OpenOptions::default(),
2716 None,
2717 )
2718 .unwrap()
2719 .with_open(OpenFacts {
2720 detail: Some(std::sync::Arc::new(Detail {
2721 tab: "VCD",
2722 lines: vec!["VCD timescale 1ns".into(), "Version: Icarus".into()],
2723 list_title: "Signals",
2724 list,
2725 first: true,
2726 ..Default::default()
2727 })),
2728 ..Default::default()
2729 });
2730 let area = Rect::new(0, 0, 60, 20);
2731 let mut buf = Buffer::empty(area);
2732 let mut modal = InfoModal::default();
2733 modal.open_on(InfoTab::Format);
2734 let mut panel = DataTableInfo::new(
2735 &state,
2736 InfoContext {
2737 format: None,
2738 facts: None,
2739 facts_tab: None,
2740 footer_expected: false,
2741 declared_types: false,
2742 },
2743 &mut modal,
2744 &theme,
2745 );
2746 (&mut panel).render(area, &mut buf);
2747 let text: Vec<String> = (0..area.height)
2748 .map(|y| {
2749 (0..area.width)
2750 .map(|x| buf[(x, y)].symbol().to_string())
2751 .collect()
2752 })
2753 .collect();
2754 let has = |needle: &str| text.iter().any(|row| row.contains(needle));
2755 assert!(has("VCD") && !has("Format"), "{text:#?}");
2756 assert!(has("Version: Icarus"), "{text:#?}");
2757 assert!(has("Signals") && has("30"), "{text:#?}");
2758 assert!(has("tb.sig0") && has("wire 1 bit id 0"), "{text:#?}");
2759 assert!(has("below"), "the rest is counted: {text:#?}");
2760 }
2761
2762 #[test]
2766 fn the_body_has_the_accent_and_the_tab_bar_none() {
2767 use crate::widgets::datatable::DataTableState;
2768 use polars::prelude::*;
2769
2770 let rows = || {
2771 df!("id" => &[1i64, 2], "name" => &["a", "b"])
2772 .unwrap()
2773 .lazy()
2774 };
2775 let mut lf = rows();
2776 let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2777 let state = DataTableState::from_schema_and_lazyframe(
2778 schema,
2779 rows(),
2780 &crate::OpenOptions::default(),
2781 None,
2782 )
2783 .unwrap();
2784 let theme = RenderContext::for_test();
2785 let g = crate::glyphs::get();
2786
2787 let paint = || {
2788 let area = Rect::new(0, 0, 50, 16);
2789 let mut buf = Buffer::empty(area);
2790 let mut modal = InfoModal::default();
2791 modal.open();
2792 let mut panel = DataTableInfo::new(
2793 &state,
2794 InfoContext {
2795 format: None,
2796 facts: None,
2797 facts_tab: None,
2798 footer_expected: false,
2799 declared_types: false,
2800 },
2801 &mut modal,
2802 &theme,
2803 );
2804 (&mut panel).render(area, &mut buf);
2805 let text: Vec<String> = (0..area.height)
2806 .map(|y| {
2807 (0..area.width)
2808 .map(|x| buf[(x, y)].symbol().to_string())
2809 .collect()
2810 })
2811 .collect();
2812 (buf, text)
2813 };
2814 let find = |text: &[String], needle: &str| {
2815 let y = text
2816 .iter()
2817 .position(|row| row.contains(needle))
2818 .unwrap_or_else(|| panic!("{needle:?} not drawn: {text:#?}"));
2819 let x = text[y][..text[y].find(needle).unwrap()].chars().count();
2821 (x as u16, y as u16)
2822 };
2823
2824 let (buf, text) = paint();
2825 let (x, y) = find(&text, "Schema types inferred");
2826 assert_eq!(buf[(x, y)].fg, theme.accent, "{text:#?}");
2828 let (_, id_row) = find(&text, " id ");
2829 assert!(text[id_row as usize].contains(g.rail), "{text:#?}");
2830 let (x, y) = find(&text, "Resources");
2831 assert!(!text[y as usize].contains(g.rail), "{text:#?}");
2832 assert_ne!(
2833 buf[(x, y)].fg,
2834 theme.accent,
2835 "an inactive tab is not accented"
2836 );
2837
2838 for row in &text[1..text.len() - 1] {
2841 assert!(
2842 !row.contains(g.border.top_left) && !row.contains(g.border.bottom_left),
2843 "a second border inside the panel: {text:#?}"
2844 );
2845 }
2846 assert!(text[text.len() - 2].contains("Esc"), "{text:#?}");
2847 }
2848
2849 #[test]
2850 fn the_tabs_on_offer_depend_on_the_dataset() {
2851 assert_eq!(
2852 InfoTab::visible(offer(false, false)),
2853 [InfoTab::Schema, InfoTab::Resources]
2854 );
2855 assert_eq!(
2856 InfoTab::visible(offer(true, true)),
2857 [
2858 InfoTab::Schema,
2859 InfoTab::Resources,
2860 InfoTab::Partitions,
2861 InfoTab::Notes
2862 ]
2863 );
2864 assert_eq!(
2865 InfoTab::visible(offer(false, true)),
2866 [InfoTab::Schema, InfoTab::Resources, InfoTab::Notes],
2867 "notes without partitions still sit last"
2868 );
2869 }
2870
2871 #[test]
2872 fn the_format_tab_sits_beside_the_schema() {
2873 let offered = TabsOffered {
2874 format: true,
2875 ..offer(false, true)
2876 };
2877 assert_eq!(
2878 InfoTab::visible(offered),
2879 [
2880 InfoTab::Schema,
2881 InfoTab::Format,
2882 InfoTab::Resources,
2883 InfoTab::Notes
2884 ]
2885 );
2886 assert_eq!(InfoTab::Format.prev(offered), InfoTab::Schema);
2887 assert_eq!(InfoTab::Format.index(offer(false, false)), 0, "not offered");
2888 }
2889
2890 #[test]
2891 fn a_clock_shows_hours_only_when_there_are_some() {
2892 assert_eq!(clock(3.25), "0:03.250");
2893 assert_eq!(clock(62.0), "1:02.000");
2894 assert_eq!(clock(3723.0005), "1:02:03.001");
2895 }
2896
2897 #[test]
2900 fn metadata_values_wrap_on_word_boundaries() {
2901 assert_eq!(
2902 wrap_words("Broadcast WAV coding history", 12),
2903 ["Broadcast", "WAV coding", "history"]
2904 );
2905 assert_eq!(
2906 wrap_words(" {% if x %} y", 10),
2907 [" {% if", "x %} y"]
2908 );
2909 assert_eq!(
2910 wrap_words("a 0123456789abcdef", 6),
2911 ["a 0123", "456789", "abcdef"]
2912 );
2913 assert_eq!(wrap_words("日本語 text", 7), ["日本語", "text"]);
2915 assert_eq!(wrap_words("", 5), [""]);
2916 assert_eq!(wrap_words("abc ", 4), ["abc"]);
2918 assert_eq!(wrap_words(" x", 5), ["x"]);
2919 }
2920
2921 #[test]
2924 fn metadata_values_wrap_whole_under_their_key() {
2925 use crate::model_files::MetaValue;
2926 let meta = vec![
2927 (
2928 "a".to_string(),
2929 MetaValue::Text("line one\nsecond line that is long".to_string()),
2930 ),
2931 (
2932 "tokens".to_string(),
2933 MetaValue::List {
2934 of: "strings",
2935 len: 151_936,
2936 items: vec![],
2937 },
2938 ),
2939 (
2940 "tags".to_string(),
2941 MetaValue::List {
2942 of: "strings",
2943 len: 2,
2944 items: vec!["x".to_string(), "y".to_string()],
2945 },
2946 ),
2947 ];
2948 let lines = metadata_lines(&meta, 20);
2949 let key = |s: &str| format!("{s:<6} ");
2950 let blank = " ".repeat(8);
2951 assert_eq!(
2952 lines,
2953 [
2954 (key("a"), "line one".to_string()),
2955 (blank.clone(), "second line".to_string()),
2956 (blank.clone(), "that is long".to_string()),
2957 (key("tokens"), "[151,936".to_string()),
2958 (blank.clone(), "strings]".to_string()),
2959 (key("tags"), "[\"x\", \"y\"]".to_string()),
2960 ]
2961 );
2962 let huge = vec![(
2964 "tokenizer.huggingface.json".to_string(),
2965 MetaValue::Text("x".repeat(VALUE_SHOWN_BYTES + 2048)),
2966 )];
2967 let lines = metadata_lines(&huge, 80);
2968 let last = &lines.last().unwrap().1;
2969 assert!(last.ends_with("2.0 KiB more"), "{last}");
2970 let drawn: usize = lines[..lines.len() - 1].iter().map(|(_, v)| v.len()).sum();
2971 assert_eq!(drawn, VALUE_SHOWN_BYTES);
2972 assert_eq!(short_count(8_030_261_248), "8.0B");
2973 assert_eq!(short_count(950), "950");
2974 }
2975
2976 #[test]
2977 fn tab_navigation_wraps_through_what_is_on_offer() {
2978 assert_eq!(
2980 InfoTab::Schema.next(offer(false, false)),
2981 InfoTab::Resources
2982 );
2983 assert_eq!(
2984 InfoTab::Resources.next(offer(false, false)),
2985 InfoTab::Schema
2986 );
2987 assert_eq!(
2988 InfoTab::Schema.prev(offer(false, false)),
2989 InfoTab::Resources
2990 );
2991
2992 assert_eq!(
2994 InfoTab::Resources.next(offer(true, true)),
2995 InfoTab::Partitions
2996 );
2997 assert_eq!(InfoTab::Partitions.next(offer(true, true)), InfoTab::Notes);
2998 assert_eq!(InfoTab::Notes.next(offer(true, true)), InfoTab::Schema);
2999 assert_eq!(InfoTab::Schema.prev(offer(true, true)), InfoTab::Notes);
3000
3001 assert_eq!(InfoTab::Resources.next(offer(false, true)), InfoTab::Notes);
3003 assert_eq!(InfoTab::Notes.prev(offer(false, true)), InfoTab::Resources);
3004 }
3005
3006 #[test]
3009 fn a_tab_that_is_no_longer_offered_falls_back_to_the_first() {
3010 assert_eq!(InfoTab::Notes.index(offer(false, false)), 0);
3011 assert_eq!(InfoTab::Notes.next(offer(false, false)), InfoTab::Resources);
3012 assert_eq!(InfoTab::Partitions.index(offer(false, false)), 0);
3013 assert_eq!(
3014 InfoTab::Partitions.prev(offer(false, false)),
3015 InfoTab::Resources
3016 );
3017 }
3018
3019 #[test]
3026 fn the_notes_window_always_shows_the_selected_note_and_wastes_no_room() {
3027 let shapes: Vec<Vec<usize>> = vec![
3028 vec![2, 2, 2, 2, 2, 2],
3029 vec![2],
3030 vec![3, 2, 4, 2],
3031 vec![2, 9, 2],
3032 vec![5, 5, 5],
3033 vec![1, 1, 1, 1, 1, 1, 1, 1],
3034 vec![4, 2, 2, 7, 2],
3035 ];
3036 let span = |h: &[usize], a: usize, b: usize| {
3037 h[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1)
3038 };
3039 for heights in &shapes {
3040 for show in 1..=30usize {
3041 for selected in 0..heights.len() {
3042 for stored in 0..heights.len() {
3043 let (first, last) = notes_window(heights, selected, stored, show);
3044 let at = format!(
3045 "heights {heights:?}, show {show}, selected {selected}, stored {stored}"
3046 );
3047
3048 assert!(first <= selected, "the cursor is above the window at {at}");
3049 assert!(selected < last, "the cursor is below the window at {at}");
3050
3051 let used = span(heights, first, last);
3052 if last - first > 1 {
3053 assert!(used <= show, "{used} rows in {show} at {at}");
3054 }
3055
3056 if last < heights.len() {
3058 assert!(
3059 span(heights, first, last + 1) > show,
3060 "another note below would have fitted at {at}"
3061 );
3062 }
3063 if first > 0 {
3064 assert!(
3065 span(heights, first - 1, last) > show,
3066 "another note above would have fitted at {at}"
3067 );
3068 }
3069 }
3070 }
3071 }
3072 }
3073 }
3074
3075 #[test]
3078 fn a_note_taller_than_the_panel_is_still_the_window() {
3079 let (first, last) = notes_window(&[2, 9, 2], 1, 0, 4);
3080 assert_eq!((first, last), (1, 2), "just the note that does not fit");
3081 }
3082
3083 #[test]
3086 fn the_notes_cursor_reaches_every_note() {
3087 let mut modal = InfoModal::new();
3088 assert!(!modal.notes_move(1, 0), "nothing to move through");
3089 for expected in 1..5 {
3090 assert!(modal.notes_move(1, 5));
3091 assert_eq!(modal.notes_selected_index, expected);
3092 }
3093 assert!(!modal.notes_move(1, 5), "and stops at the last");
3094 for expected in (0..4).rev() {
3095 assert!(modal.notes_move(-1, 5));
3096 assert_eq!(modal.notes_selected_index, expected);
3097 }
3098 assert!(!modal.notes_move(-1, 5), "and at the first");
3099 }
3100
3101 #[test]
3102 fn wrapping_measures_columns_not_characters() {
3103 assert_eq!(wrap_to("one two three", 9), ["one two", "three"]);
3104 assert_eq!(wrap_to("", 10), [""], "an empty line is still a line");
3105 assert_eq!(
3106 wrap_to("supercalifragilistic", 5),
3107 ["supercalifragilistic"],
3108 "a word longer than the panel is left whole rather than broken"
3109 );
3110 assert_eq!(wrap_to("日本語表 x", 8), ["日本語表", "x"]);
3112 }
3113
3114 #[test]
3115 fn test_format_bytes() {
3116 assert_eq!(format_bytes(0), "0 B");
3117 assert_eq!(format_bytes(500), "500 B");
3118 assert_eq!(format_bytes(1536), "1.5 KiB");
3119 assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
3120 }
3121
3122 #[test]
3123 fn test_format_int() {
3124 assert_eq!(format_int(0), "0");
3125 assert_eq!(format_int(1234), "1,234");
3126 assert_eq!(format_int(1_234_567), "1,234,567");
3127 }
3128}