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::formats::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 crate::export::export_modal::ExportFormat;
20use crate::render::context::RenderContext;
21use crate::table::DataTableState;
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) {
41 if heights.is_empty() {
42 return (0, 0);
43 }
44 let selected = selected.min(heights.len() - 1);
45 let span = |a: usize, b: usize| heights[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1);
47 let mut first = stored.min(selected);
49 while first < selected && span(first, selected + 1) > show {
50 first += 1;
51 }
52 let mut last = selected + 1;
54 while last < heights.len() && span(first, last + 1) <= show {
55 last += 1;
56 }
57 while first > 0 && span(first - 1, last) <= show {
60 first -= 1;
61 }
62 (first, last)
63}
64
65fn note_rows(note: &crate::notes::Note, selected: bool, width: usize) -> Vec<NoteRow> {
68 let mut rows = Vec::new();
69 let marker = if selected {
70 crate::glyphs::get().prompt
71 } else {
72 " "
73 };
74 let mut first = true;
75 for line in wrap_to(¬e.summary, width.saturating_sub(2)) {
76 rows.push(NoteRow {
77 text: format!("{}{line}", if first { marker } else { " " }),
78 dim: false,
79 });
80 first = false;
81 }
82 for line in wrap_to(¬e.scope, width.saturating_sub(4)) {
83 rows.push(NoteRow {
84 text: format!(" {line}"),
85 dim: true,
86 });
87 }
88 rows
89}
90
91pub(crate) fn wrap_to(text: &str, width: usize) -> Vec<String> {
95 use unicode_width::UnicodeWidthStr;
96 if width == 0 {
97 return vec![text.to_string()];
98 }
99 let mut lines = Vec::new();
100 let mut line = String::new();
101 for word in text.split_whitespace() {
102 let room = if line.is_empty() {
103 width
104 } else {
105 width.saturating_sub(line.width() + 1)
106 };
107 if word.width() > room && !line.is_empty() {
108 lines.push(std::mem::take(&mut line));
109 }
110 if !line.is_empty() {
111 line.push(' ');
112 }
113 line.push_str(word);
114 }
115 if !line.is_empty() || lines.is_empty() {
116 lines.push(line);
117 }
118 lines
119}
120
121pub(crate) fn group_u64(n: u64) -> String {
123 let mut out = String::new();
124 crate::numfmt::NumberFormat::CHROME.write_u64(n, &mut out);
125 out
126}
127
128pub(crate) fn count_of(n: u64, one: &str, many: &str) -> String {
130 format!("{} {}", group_u64(n), if n == 1 { one } else { many })
131}
132
133pub(crate) fn short_count(n: u64) -> String {
135 const STEPS: [(u64, &str); 4] = [
136 (1_000_000_000_000, "T"),
137 (1_000_000_000, "B"),
138 (1_000_000, "M"),
139 (1_000, "K"),
140 ];
141 for (size, suffix) in STEPS {
142 if n >= size {
143 return format!("{:.1}{suffix}", n as f64 / size as f64);
144 }
145 }
146 n.to_string()
147}
148
149fn wrap_words(text: &str, width: usize) -> Vec<String> {
153 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
154 let mut lines = Vec::new();
155 let mut line = String::new();
156 let mut line_width = 0;
157 let mut broken = false;
159 let mut rest = text;
160 while !rest.is_empty() {
161 let space = rest.starts_with(' ');
162 let end = rest
163 .find(|c: char| (c == ' ') != space)
164 .unwrap_or(rest.len());
165 let (token, after) = rest.split_at(end);
166 rest = after;
167 let w = token.width();
168 if space {
169 if broken && line.is_empty() {
170 continue;
171 }
172 if line_width + w <= width {
173 line.push_str(token);
174 line_width += w;
175 } else {
176 if !line.is_empty() {
178 lines.push(std::mem::take(&mut line));
179 }
180 line_width = 0;
181 broken = true;
182 }
183 continue;
184 }
185 if line_width + w > width && !line.is_empty() && w <= width {
186 lines.push(std::mem::take(&mut line).trim_end().to_string());
187 line_width = 0;
188 broken = true;
189 }
190 for c in token.chars() {
191 let cw = c.width().unwrap_or(0);
192 if line_width + cw > width && !line.is_empty() {
193 lines.push(std::mem::take(&mut line));
194 line_width = 0;
195 broken = true;
196 }
197 line.push(c);
198 line_width += cw;
199 }
200 }
201 if !(broken && line.is_empty()) || lines.is_empty() {
203 lines.push(line);
204 }
205 lines
206}
207
208pub(crate) fn meta_text(value: &crate::formats::model_files::MetaValue) -> String {
210 use crate::formats::model_files::MetaValue;
211 match value {
212 MetaValue::Text(text) => text.clone(),
213 MetaValue::List { of, len, items } if items.len() as u64 == *len && *len > 0 => {
214 let quote = *of == "strings";
215 let items: Vec<String> = items
216 .iter()
217 .map(|i| if quote { format!("{i:?}") } else { i.clone() })
218 .collect();
219 format!("[{}]", items.join(", "))
220 }
221 MetaValue::List { of, len, .. } => format!("[{} {of}]", group_u64(*len)),
222 }
223}
224
225pub(crate) const VALUE_SHOWN_BYTES: usize = 64 * 1024;
228
229pub(crate) fn metadata_lines(
233 metadata: &[(String, crate::formats::model_files::MetaValue)],
234 width: usize,
235) -> Vec<(String, String)> {
236 use unicode_width::UnicodeWidthStr;
237 let longest = metadata.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
238 let key_width = longest.min(width * 2 / 5).max(1);
240 let value_width = width.saturating_sub(key_width + 2).max(1);
241 let mut out = Vec::new();
242 for (key, value) in metadata {
243 let key_cell = format!(
244 "{:<w$} ",
245 crate::glyphs::fit(key, key_width),
246 w = key_width
247 );
248 let blank = " ".repeat(key_width + 2);
249 let listed;
251 let text = match value {
252 crate::formats::model_files::MetaValue::Text(text) => text.as_str(),
253 other => {
254 listed = meta_text(other);
255 listed.as_str()
256 }
257 };
258 let cut = text.floor_char_boundary(VALUE_SHOWN_BYTES);
259 let (text, more) = (&text[..cut], text.len() - cut);
260 let mut first = true;
261 for raw in text.split('\n') {
262 let clean: String = raw
265 .chars()
266 .filter_map(|c| match c {
267 '\t' => Some(' '),
268 c if c.is_control() => None,
269 c => Some(c),
270 })
271 .collect();
272 for line in wrap_words(&clean, value_width) {
273 let k = if first {
274 key_cell.clone()
275 } else {
276 blank.clone()
277 };
278 first = false;
279 out.push((k, line));
280 }
281 }
282 if more > 0 {
283 let g = crate::glyphs::get();
284 out.push((
285 blank,
286 format!("{} {} more", g.ellipsis, crate::numfmt::bytes(more as u64)),
287 ));
288 }
289 }
290 out
291}
292
293pub(crate) fn clock(seconds: f64) -> String {
295 let ms = (seconds.max(0.0) * 1000.0).round() as u64;
296 let (h, m, s, ms) = (ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000);
297 if h > 0 {
298 format!("{h}:{m:02}:{s:02}.{ms:03}")
299 } else {
300 format!("{m}:{s:02}.{ms:03}")
301 }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
305pub enum InfoTab {
306 #[default]
307 Schema,
308 Metadata,
310 Format,
313 Resources,
314 Partitions,
315 Notes,
316 Documentation,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
323pub struct TabsOffered {
324 pub metadata: bool,
326 pub format: bool,
328 pub partitions: bool,
329 pub notes: bool,
330 pub documentation: bool,
332}
333
334impl TabsOffered {
335 pub fn of(state: &DataTableState, facts_tab: Option<&'static str>) -> Self {
338 Self {
339 metadata: state
340 .delimited_read()
341 .is_some_and(|read| read.metadata.is_some()),
342 format: state.format_detail().is_some() || facts_tab.is_some(),
343 partitions: state
344 .partition_columns()
345 .map(|v| !v.is_empty())
346 .unwrap_or(false),
347 notes: state.has_notes(),
348 documentation: false,
349 }
350 }
351}
352
353impl InfoTab {
354 pub fn visible(offered: TabsOffered) -> Vec<InfoTab> {
357 let mut tabs = vec![InfoTab::Schema];
358 if offered.documentation {
359 tabs.push(InfoTab::Documentation);
360 }
361 if offered.metadata {
362 tabs.push(InfoTab::Metadata);
363 }
364 if offered.format {
365 tabs.push(InfoTab::Format);
366 }
367 tabs.push(InfoTab::Resources);
368 if offered.partitions {
369 tabs.push(InfoTab::Partitions);
370 }
371 if offered.notes {
372 tabs.push(InfoTab::Notes);
373 }
374 tabs
375 }
376
377 pub fn title(self) -> &'static str {
378 match self {
379 InfoTab::Schema => "Schema",
380 InfoTab::Metadata => "Metadata",
381 InfoTab::Format => "Format",
382 InfoTab::Resources => "Resources",
383 InfoTab::Partitions => "Partitions",
384 InfoTab::Notes => "Notes",
385 InfoTab::Documentation => "Documentation",
386 }
387 }
388
389 pub fn next(self, offered: TabsOffered) -> Self {
391 let tabs = Self::visible(offered);
392 let at = self.index(offered);
393 tabs[(at + 1) % tabs.len()]
394 }
395
396 pub fn prev(self, offered: TabsOffered) -> Self {
397 let tabs = Self::visible(offered);
398 let at = self.index(offered);
399 tabs[(at + tabs.len() - 1) % tabs.len()]
400 }
401
402 pub fn index(self, offered: TabsOffered) -> usize {
404 Self::visible(offered)
405 .iter()
406 .position(|tab| *tab == self)
407 .unwrap_or(0)
408 }
409}
410
411#[derive(Default)]
413pub struct InfoModal {
414 pub active_tab: InfoTab,
415 pub schema_selected_index: usize,
416 pub schema_scroll_offset: usize,
417 pub schema_table_state: ratatui::widgets::TableState,
418 pub schema_visible_height: usize,
420 pub notes_selected_index: usize,
422 pub notes_scroll_offset: usize,
425 pub detail_scroll: usize,
428 pub detail_visible: usize,
430 pub detail_selected: usize,
433}
434
435impl InfoModal {
436 pub fn new() -> Self {
437 Self::default()
438 }
439
440 pub fn open(&mut self) {
441 self.open_on(InfoTab::Schema);
442 }
443
444 pub fn open_on(&mut self, tab: InfoTab) {
447 self.active_tab = tab;
448 self.schema_selected_index = 0;
449 self.schema_scroll_offset = 0;
450 self.schema_table_state.select(Some(0));
451 self.notes_selected_index = 0;
452 self.notes_scroll_offset = 0;
453 self.detail_scroll = 0;
454 self.detail_selected = 0;
455 }
456
457 pub fn switch_tab(&mut self, offered: TabsOffered) {
459 self.active_tab = self.active_tab.next(offered);
460 if self.active_tab == InfoTab::Schema {
461 self.schema_selected_index = 0;
462 self.schema_scroll_offset = 0;
463 self.schema_table_state.select(Some(0));
464 }
465 }
466
467 pub fn switch_tab_prev(&mut self, offered: TabsOffered) {
469 self.active_tab = self.active_tab.prev(offered);
470 if self.active_tab == InfoTab::Schema {
471 self.schema_selected_index = 0;
472 self.schema_scroll_offset = 0;
473 self.schema_table_state.select(Some(0));
474 }
475 }
476
477 pub fn detail_scroll_by(&mut self, delta: isize) {
480 self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
481 }
482
483 pub fn notes_move(&mut self, delta: isize, total: usize) -> bool {
486 if total == 0 {
487 return false;
488 }
489 let last = total - 1;
490 let next = (self.notes_selected_index as isize + delta).clamp(0, last as isize) as usize;
491 if next == self.notes_selected_index {
492 return false;
493 }
494 self.notes_selected_index = next;
495 true
496 }
497
498 pub fn schema_table_down(&mut self, total_rows: usize, visible_height: usize) -> bool {
501 if total_rows == 0 {
502 return false;
503 }
504 let max_idx = total_rows.saturating_sub(1);
505 if self.schema_selected_index >= max_idx {
506 return false;
507 }
508 self.schema_selected_index += 1;
509 let visible_end = self.schema_scroll_offset + visible_height;
510 if visible_height > 0 && self.schema_selected_index >= visible_end {
511 self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
512 }
513 let local = self
514 .schema_selected_index
515 .saturating_sub(self.schema_scroll_offset);
516 self.schema_table_state.select(Some(local));
517 true
518 }
519
520 pub fn schema_table_up(&mut self, total_rows: usize, _visible_height: usize) -> bool {
521 if total_rows == 0 || self.schema_selected_index == 0 {
522 return false;
523 }
524 self.schema_selected_index -= 1;
525 if self.schema_selected_index < self.schema_scroll_offset {
526 self.schema_scroll_offset = self.schema_selected_index;
527 }
528 let local = self
529 .schema_selected_index
530 .saturating_sub(self.schema_scroll_offset);
531 self.schema_table_state.select(Some(local));
532 true
533 }
534
535 pub fn sync_schema_table_state(&mut self, total_rows: usize, visible_height: usize) {
537 if total_rows == 0 {
538 self.schema_table_state.select(None);
539 return;
540 }
541 let max_idx = total_rows.saturating_sub(1);
542 self.schema_selected_index = self.schema_selected_index.min(max_idx);
543 if self.schema_scroll_offset + visible_height <= self.schema_selected_index
544 && visible_height > 0
545 {
546 self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
547 }
548 if self.schema_selected_index < self.schema_scroll_offset {
549 self.schema_scroll_offset = self.schema_selected_index;
550 }
551 let local = self
552 .schema_selected_index
553 .saturating_sub(self.schema_scroll_offset);
554 self.schema_table_state.select(Some(local));
555 }
556}
557
558#[derive(Debug, Clone)]
562pub enum FileFacts {
563 Reading,
565 Read {
567 size: Option<u64>,
569 footer: Option<Footer>,
572 detail: Option<Arc<crate::formats::text_formats::Detail>>,
574 },
575 Failed(String),
578}
579
580impl FileFacts {
581 pub(crate) fn read(
584 path: &Path,
585 facts: Option<crate::formats::readers::Facts>,
586 ) -> std::result::Result<Self, String> {
587 let io = |e: std::io::Error| {
588 log::warn!(target: "datui", "file size of {}: {e}", path.display());
589 match e.kind() {
590 std::io::ErrorKind::NotFound => "file not found".to_string(),
591 std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
592 _ => {
594 let said = e.to_string();
595 match said.rsplit_once(" (os error") {
596 Some((words, _)) => words.to_string(),
597 None => said,
598 }
599 }
600 }
601 };
602 let meta = std::fs::metadata(path).map_err(io)?;
603 if meta.is_dir() {
604 return Ok(Self::Read {
605 size: None,
606 footer: None,
607 detail: None,
608 });
609 }
610 let read = match facts {
611 Some(facts) => (facts.read)(path).map_err(|e| {
612 log::warn!(target: "datui", "footer of {}: {e}", path.display());
613 "unreadable footer".to_string()
614 })?,
615 None => crate::formats::readers::FormatFacts::default(),
616 };
617 Ok(Self::Read {
618 size: Some(meta.len()),
619 footer: read.footer,
620 detail: read.detail,
621 })
622 }
623}
624
625pub struct InfoContext<'a> {
628 pub format: Option<ExportFormat>,
629 pub declared_types: bool,
631 pub facts: Option<&'a FileFacts>,
634 pub facts_tab: Option<&'static str>,
637 pub footer_expected: bool,
640}
641
642impl<'a> InfoContext<'a> {
643 pub fn schema_source(&self) -> &'static str {
645 if self.declared_types {
646 "types declared"
647 } else {
648 "types inferred"
649 }
650 }
651
652 pub fn footer(&self) -> Option<&'a Footer> {
654 match self.facts? {
655 FileFacts::Read { footer, .. } => footer.as_ref(),
656 FileFacts::Reading | FileFacts::Failed(_) => None,
657 }
658 }
659
660 fn facts_detail(&self) -> Option<&'a crate::formats::text_formats::Detail> {
662 match self.facts? {
663 FileFacts::Read { detail, .. } => detail.as_deref(),
664 FileFacts::Reading | FileFacts::Failed(_) => None,
665 }
666 }
667
668 fn reading(&self) -> bool {
670 matches!(self.facts, Some(FileFacts::Reading))
671 }
672}
673
674pub struct DataTableInfo<'a> {
675 pub state: &'a DataTableState,
676 pub ctx: InfoContext<'a>,
677 pub modal: &'a mut InfoModal,
678 pub theme: &'a RenderContext,
679 pub hex: bool,
681 pub header_toggle: bool,
683 pub codebook: Option<&'a crate::home::codebook::Codebook>,
685 pub documentation: Option<&'a mut crate::widgets::documentation::DocState>,
687 pub estimate: Option<crate::formats::schema_union::RowEstimate>,
689}
690
691fn read_line(state: &DataTableState) -> Option<String> {
694 let mode = state.read_mode()?.label();
695 Some(if state.fetched() {
696 format!("download {} {mode}", crate::glyphs::get().arrow_right)
697 } else {
698 mode.to_string()
699 })
700}
701
702fn rows_and_columns(rows: Option<usize>, columns: usize) -> String {
706 let middot = crate::glyphs::get().middot;
707 match rows {
708 Some(rows) => format!(
709 "Rows (total): {} {middot} Columns: {}",
710 format_int(rows),
711 columns
712 ),
713 None => format!("Rows (total): counting... {middot} Columns: {columns}"),
714 }
715}
716
717fn estimated_rows_and_columns(
720 estimate: crate::formats::schema_union::RowEstimate,
721 columns: usize,
722) -> String {
723 let middot = crate::glyphs::get().middot;
724 format!(
725 "Rows (total): ~{} (est. from {} of {} files; c counts) {middot} Columns: {columns}",
726 crate::home::discover::format_rows(estimate.rows as usize),
727 format_int(estimate.sampled),
728 format_int(estimate.files),
729 )
730}
731
732impl<'a> DataTableInfo<'a> {
733 pub fn new(
734 state: &'a DataTableState,
735 ctx: InfoContext<'a>,
736 modal: &'a mut InfoModal,
737 theme: &'a RenderContext,
738 ) -> Self {
739 Self {
740 state,
741 ctx,
742 modal,
743 theme,
744 hex: false,
745 header_toggle: false,
746 codebook: None,
747 documentation: None,
748 estimate: None,
749 }
750 }
751
752 fn codebook_here(&self) -> Option<&'a crate::home::codebook::Codebook> {
754 self.codebook
755 .filter(|book| book.covers(self.state.schema().iter_names().map(|n| n.as_str())))
756 }
757
758 fn render_schema_tab(&mut self, area: Rect, buf: &mut Buffer) {
759 let summary = self.render_schema_summary(area, buf);
760 let mut rest = Rect {
761 y: area.y + summary,
762 height: area.height.saturating_sub(summary),
763 ..area
764 };
765 if rest.height == 0 {
766 return;
767 }
768 let note_rows = if self.codebook_here().is_some() && rest.height >= 10 {
771 4
772 } else {
773 0
774 };
775 rest.height -= note_rows;
776 let used = self.render_schema_table(rest, buf);
777 if note_rows > 0 {
778 self.render_column_note(
780 Rect {
781 y: rest.y + used + 1,
782 height: note_rows - 1,
783 ..rest
784 },
785 buf,
786 );
787 }
788 }
789
790 fn render_column_note(&self, area: Rect, buf: &mut Buffer) {
793 let Some(book) = self.codebook_here() else {
794 return;
795 };
796 let Some((name, _)) = self
797 .state
798 .schema()
799 .get_at_index(self.modal.schema_selected_index)
800 else {
801 return;
802 };
803 let width = area.width as usize;
804 let rows = area.height as usize;
805 let mut lines: Vec<String> = Vec::new();
806 match book.column(name.as_str()) {
807 Some(column) => {
808 let about = column.about();
809 if !about.is_empty() {
810 lines.extend(wrap_to(&format!("{name}: {about}"), width));
811 }
812 if !column.values.is_empty() {
813 let sep = format!(" {} ", crate::glyphs::get().middot);
814 let codes: Vec<&str> = column
815 .values
816 .keys()
817 .map(|k| if k.is_empty() { "blank" } else { k.as_str() })
818 .collect();
819 let left = rows.saturating_sub(lines.len()).max(1);
820 let mut wrapped = wrap_to(&format!("Codes: {}", codes.join(&sep)), width);
821 if wrapped.len() > left {
822 let rest = wrapped[left - 1..].join(" ");
824 wrapped.truncate(left - 1);
825 wrapped.push(crate::glyphs::fit(&rest, width));
826 }
827 lines.extend(wrapped);
828 }
829 }
830 None => lines.push(format!("{name}: not documented")),
831 }
832 for (i, line) in lines.iter().take(rows).enumerate() {
833 Paragraph::new(crate::glyphs::fit(line, width))
834 .style(Style::default().fg(self.theme.text_secondary))
835 .render(
836 Rect {
837 y: area.y + i as u16,
838 height: 1,
839 ..area
840 },
841 buf,
842 );
843 }
844 }
845
846 fn render_schema_summary(&self, area: Rect, buf: &mut Buffer) -> u16 {
847 let ncols = self.state.schema().len();
848 let mut lines = vec![];
849 lines.push(match self.estimate {
851 Some(estimate) => estimated_rows_and_columns(estimate, ncols),
852 None => rows_and_columns(self.state.num_rows_if_valid(), ncols),
853 });
854 let by_type = columns_by_type(self.state.schema().as_ref());
855 if !by_type.is_empty() {
856 lines.push(by_type);
857 }
858 let others = self.state.other_tables();
860 if !others.is_empty() {
861 let sep = format!(" {} ", crate::glyphs::get().middot);
862 lines.push(format!("Other tables (--table): {}", others.join(&sep)));
863 }
864 if let Some(book) = self.codebook_here()
865 && !book.source.is_empty()
866 {
867 lines.push(crate::glyphs::fit(
868 &format!("Documentation: {}", book.source),
869 area.width as usize,
870 ));
871 }
872 for (i, s) in lines.iter().enumerate() {
873 Paragraph::new(s.as_str()).render(
874 Rect {
875 x: area.x,
876 y: area.y + i as u16,
877 width: area.width,
878 height: 1,
879 },
880 buf,
881 );
882 }
883 lines.len() as u16
884 }
885
886 fn render_schema_table(&mut self, area: Rect, buf: &mut Buffer) -> u16 {
888 let dataset = self.state.dataset_schema();
891 let src = match dataset {
892 Some(dataset) => dataset.origin.to_string(),
893 None if self.state.format_detail().is_some_and(|d| d.own_columns) => {
895 "types declared".to_string()
896 }
897 None => self.ctx.schema_source().to_string(),
898 };
899 let presence = dataset.map(|dataset| {
902 let readable = dataset.files.saturating_sub(dataset.unreadable.len());
903 let present_by_name: HashMap<&str, usize> = dataset
904 .columns
905 .iter()
906 .map(|c| (c.name.as_str(), c.present_in))
907 .collect();
908 (readable, present_by_name)
909 });
910 let has_files = presence.is_some();
911 let compression = self.ctx.footer().map(|m| {
912 crate::formats::parquet_footer::column_compression(
913 m.as_ref(),
914 self.state.schema().as_ref(),
915 )
916 });
917 let has_comp =
920 self.ctx.footer_expected || compression.as_ref().is_some_and(|c| !c.is_empty());
921 let has_units = !self.state.units().is_empty();
923 let book = self.codebook_here();
924 let mut header_cells = vec!["Column", "Type"];
925 if has_units {
926 header_cells.push("Unit");
927 }
928 if book.is_some() {
929 header_cells.push("About");
930 }
931 if has_files {
932 header_cells.push("Files");
933 }
934 if has_comp {
935 header_cells.push("Compression");
936 }
937 let header = Row::new(header_cells).bold();
938
939 let total_rows = self.state.schema().len();
940 let body_focused = true;
943 SectionRule {
946 title: "Schema",
947 chip: Some(&src),
948 }
949 .render(Rect { height: 1, ..area }, buf, self.theme);
950 let inner = Rect {
951 y: area.y + 1,
952 height: area.height.saturating_sub(1),
953 ..area
954 };
955 let visible_height = inner.height as usize;
956
957 let fits = total_rows <= visible_height.saturating_sub(1);
960 let data_height = visible_height.saturating_sub(1 + usize::from(!fits));
961 self.modal.schema_visible_height = data_height;
962 self.modal.sync_schema_table_state(total_rows, data_height);
963
964 let offset = self.modal.schema_scroll_offset;
965 let take = data_height.min(total_rows.saturating_sub(offset));
966 let mut rows: Vec<Vec<String>> = vec![];
967 for (idx, (name, dtype)) in self.state.schema().iter().enumerate() {
968 if idx < offset {
969 continue;
970 }
971 if idx >= offset + take {
972 break;
973 }
974 let name_str: &str = name.as_ref();
975 let mut cells = vec![name.to_string(), dtype.to_string()];
976 if has_units {
977 cells.push(self.state.unit_of(name_str).unwrap_or_default().to_string());
978 }
979 if let Some(book) = book {
980 cells.push(book.column(name_str).map(|c| c.about()).unwrap_or_default());
981 }
982 if let Some((readable, present_by_name)) = &presence {
983 let files_str = match present_by_name.get(name_str) {
986 Some(present) if present >= readable => {
987 format!("all {}", format_int(*readable))
988 }
989 Some(present) => {
990 format!("{} of {}", format_int(*present), format_int(*readable))
991 }
992 None => crate::glyphs::get().dash.to_string(),
993 };
994 cells.push(files_str);
995 }
996 if has_comp {
997 let comp_str = match compression.as_ref().map(|c| c.get(name_str)) {
998 Some(Some((codec, ratio))) => {
999 format!("{} {:.1}{}", codec, ratio, crate::glyphs::get().times)
1000 }
1001 None if self.ctx.reading() => String::new(),
1003 _ => crate::glyphs::get().dash.to_string(),
1004 };
1005 cells.push(comp_str);
1006 }
1007 rows.push(cells);
1008 }
1009
1010 let widths: Vec<Constraint> = if book.is_some() {
1011 let mut weights = vec![3, 2];
1013 weights.extend(has_units.then_some(2));
1014 weights.push(7);
1015 weights.extend(has_files.then_some(2));
1016 weights.extend(has_comp.then_some(3));
1017 weights.into_iter().map(Constraint::Fill).collect()
1018 } else if has_units {
1019 let mut weights = vec![3, 3, 2];
1021 weights.extend(has_files.then_some(2));
1022 weights.extend(has_comp.then_some(3));
1023 weights.into_iter().map(Constraint::Fill).collect()
1024 } else {
1025 match (has_files, has_comp) {
1026 (true, true) => vec![
1027 Constraint::Percentage(25),
1028 Constraint::Percentage(30),
1029 Constraint::Percentage(20),
1030 Constraint::Percentage(25),
1031 ],
1032 (true, false) => vec![
1033 Constraint::Percentage(35),
1034 Constraint::Percentage(40),
1035 Constraint::Percentage(25),
1036 ],
1037 (false, true) => vec![
1038 Constraint::Percentage(30),
1039 Constraint::Percentage(40),
1040 Constraint::Percentage(30),
1041 ],
1042 (false, false) => vec![Constraint::Percentage(50), Constraint::Percentage(50)],
1043 }
1044 };
1045 let g = crate::glyphs::get();
1048 let (highlight, symbol) = if body_focused {
1049 (self.theme.highlight_style(), g.selector)
1050 } else {
1051 (Style::default().fg(self.theme.accent), g.selector_blank)
1052 };
1053 let symbol = Span::styled(symbol, Style::default().fg(self.theme.accent));
1054 if book.is_some() {
1057 let about = 2 + usize::from(has_units);
1058 let room = Rect {
1059 width: inner
1060 .width
1061 .saturating_sub(crate::glyphs::cell_width(g.selector) as u16),
1062 ..inner
1063 };
1064 let cols = Layout::horizontal(widths.clone()).spacing(1).split(room);
1065 if let Some(col) = cols.get(about) {
1066 for cells in &mut rows {
1067 if let Some(cell) = cells.get_mut(about) {
1068 *cell = crate::glyphs::fit(cell, col.width as usize);
1069 }
1070 }
1071 }
1072 }
1073 let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
1074 let table = Table::new(rows, widths)
1075 .header(header)
1076 .column_spacing(1)
1077 .row_highlight_style(highlight)
1078 .highlight_symbol(symbol)
1079 .highlight_spacing(HighlightSpacing::Always);
1080 let table_area = Rect {
1081 height: inner.height.saturating_sub(u16::from(!fits)),
1082 ..inner
1083 };
1084 StatefulWidget::render(table, table_area, buf, &mut self.modal.schema_table_state);
1085
1086 if !fits && inner.height > 0 {
1087 let above = offset;
1088 let below = total_rows.saturating_sub(offset + take);
1089 let counted = match (above, below) {
1090 (0, 0) => None,
1091 (0, n) => Some(format!("{} below", format_int(n))),
1092 (n, 0) => Some(format!("{} above", format_int(n))),
1093 (a, b) => Some(format!("{} above, {} below", format_int(a), format_int(b))),
1094 };
1095 if let Some(text) = counted {
1096 Paragraph::new(text)
1097 .style(Style::default().fg(self.theme.dimmed))
1098 .alignment(ratatui::layout::Alignment::Right)
1099 .render(
1100 Rect {
1101 y: inner.y + inner.height - 1,
1102 height: 1,
1103 ..inner
1104 },
1105 buf,
1106 );
1107 }
1108 }
1109 (2 + take + usize::from(!fits)).min(area.height as usize) as u16
1111 }
1112
1113 fn render_resources_tab(&self, area: Rect, buf: &mut Buffer) {
1114 const LABEL_WIDTH: u16 = 17;
1116 let label_constraint = Constraint::Length(LABEL_WIDTH);
1117 let value_constraint = Constraint::Min(1);
1118 let mut y = area.y;
1119 let h = area.height;
1120 let w = area.width;
1121
1122 fn label_value_row(label: &str, value: &str, area: Rect, buf: &mut Buffer, label_w: u16) {
1123 let chunks = Layout::default()
1124 .direction(Direction::Horizontal)
1125 .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1126 .split(area);
1127 Paragraph::new(label).render(chunks[0], buf);
1128 Paragraph::new(value).render(chunks[1], buf);
1129 }
1130
1131 if y >= area.y + h {
1132 return;
1133 }
1134 let size_chunks = Layout::default()
1135 .direction(Direction::Horizontal)
1136 .constraints([label_constraint, value_constraint])
1137 .split(Rect {
1138 y,
1139 width: w,
1140 height: 1,
1141 ..area
1142 });
1143 let file_size = match self.ctx.facts {
1146 None | Some(FileFacts::Read { size: None, .. }) => Span::raw(crate::glyphs::get().dash),
1147 Some(FileFacts::Read {
1148 size: Some(size), ..
1149 }) => Span::raw(crate::numfmt::bytes(*size)),
1150 Some(FileFacts::Reading) => {
1151 Span::styled("reading...", Style::default().fg(self.theme.dimmed))
1152 }
1153 Some(FileFacts::Failed(why)) => Span::styled(
1154 crate::glyphs::fit(why, size_chunks[1].width as usize),
1155 Style::default().fg(self.theme.error),
1156 ),
1157 };
1158 Paragraph::new("File size:").render(size_chunks[0], buf);
1159 Paragraph::new(Line::from(file_size)).render(size_chunks[1], buf);
1160 y += 1;
1161
1162 if y >= area.y + h {
1163 return;
1164 }
1165 let fmt = self
1166 .ctx
1167 .format
1168 .map(|f| f.as_str())
1169 .unwrap_or(crate::glyphs::get().dash);
1170 label_value_row(
1171 "Format:",
1172 fmt,
1173 Rect {
1174 y,
1175 width: w,
1176 height: 1,
1177 ..area
1178 },
1179 buf,
1180 LABEL_WIDTH,
1181 );
1182 y += 1;
1183
1184 if let Some(read) = read_line(self.state) {
1186 if y >= area.y + h {
1187 return;
1188 }
1189 label_value_row(
1190 "Read:",
1191 &read,
1192 Rect {
1193 y,
1194 width: w,
1195 height: 1,
1196 ..area
1197 },
1198 buf,
1199 LABEL_WIDTH,
1200 );
1201 y += 1;
1202 }
1203
1204 if y >= area.y + h {
1205 return;
1206 }
1207 let buf_rows = self.state.buffered_rows();
1208 let max_rows = self.state.max_buffered_rows();
1209 let row_area = Rect {
1210 y,
1211 width: w,
1212 height: 1,
1213 ..area
1214 };
1215 let row_chunks = Layout::default()
1216 .direction(Direction::Horizontal)
1217 .constraints([label_constraint, value_constraint])
1218 .split(row_area);
1219 Paragraph::new("Buffer (Rows):").render(row_chunks[0], buf);
1220 if max_rows > 0 {
1222 let label = format!("{} / {}", format_int(buf_rows), format_int(max_rows));
1223 Paragraph::new(label).render(row_chunks[1], buf);
1224 } else {
1225 Paragraph::new(format_int(buf_rows)).render(row_chunks[1], buf);
1226 }
1227 y += 1;
1228
1229 if y >= area.y + h {
1230 return;
1231 }
1232 let buf_mb = self
1233 .state
1234 .buffered_memory_bytes()
1235 .map(|b| b / (1024 * 1024));
1236 let max_mb = self.state.max_buffered_mb();
1237 let mb_area = Rect {
1238 y,
1239 width: w,
1240 height: 1,
1241 ..area
1242 };
1243 let mb_chunks = Layout::default()
1244 .direction(Direction::Horizontal)
1245 .constraints([label_constraint, value_constraint])
1246 .split(mb_area);
1247 Paragraph::new("Buffer (MB):").render(mb_chunks[0], buf);
1248 if max_mb > 0 {
1249 let label = match buf_mb {
1250 Some(m) => format!("{:.1} / {} MiB", m as f64, max_mb),
1251 None => crate::glyphs::get().dash.to_string(),
1252 };
1253 Paragraph::new(label).render(mb_chunks[1], buf);
1254 } else {
1255 let value = buf_mb
1256 .map(|m| format!("{:.1} MiB", m as f64))
1257 .unwrap_or_else(|| {
1258 self.state
1259 .buffered_memory_bytes()
1260 .map(|b| crate::numfmt::bytes(b as u64))
1261 .unwrap_or_else(|| crate::glyphs::get().dash.to_string())
1262 });
1263 Paragraph::new(value).render(mb_chunks[1], buf);
1264 }
1265 y += 1;
1266
1267 self.render_measurements(area, buf, &mut y, LABEL_WIDTH);
1268 }
1269
1270 fn render_measurements(&self, area: Rect, buf: &mut Buffer, y: &mut u16, label_w: u16) {
1274 let meter = self.state.measurements();
1275 let mut rows: Vec<(&str, String)> = [
1276 ("Listing:", "files", "file", meter.listing()),
1277 ("Footers:", "footers read", "footer read", meter.footers()),
1278 ("Last page:", "files read", "file read", meter.last_page()),
1279 ]
1280 .into_iter()
1281 .filter_map(|(label, unit, singular, cost)| {
1282 Some((label, measurement_line(&cost?, unit, singular)))
1283 })
1284 .collect();
1285 if let Some(total) = meter.total() {
1286 rows.push(("Total:", total_line(&total)));
1287 }
1288 if rows.is_empty() {
1289 return;
1290 }
1291 let bottom = area.y + area.height;
1292 if *y + 2 >= bottom {
1295 return;
1296 }
1297 *y += 1;
1298 SectionRule {
1299 title: "Measurements",
1300 chip: None,
1301 }
1302 .render(
1303 Rect {
1304 y: *y,
1305 width: area.width,
1306 height: 1,
1307 ..area
1308 },
1309 buf,
1310 self.theme,
1311 );
1312 *y += 1;
1313 for (label, line) in rows {
1314 if *y >= bottom {
1315 return;
1316 }
1317 let chunks = Layout::default()
1318 .direction(Direction::Horizontal)
1319 .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1320 .split(Rect {
1321 y: *y,
1322 width: area.width,
1323 height: 1,
1324 ..area
1325 });
1326 Paragraph::new(label).render(chunks[0], buf);
1327 Paragraph::new(line).render(chunks[1], buf);
1328 *y += 1;
1329 }
1330 }
1331
1332 fn render_metadata_tab(&mut self, area: Rect, buf: &mut Buffer) {
1335 let Some(read) = self.state.delimited_read().cloned() else {
1336 return;
1337 };
1338 let Some(metadata) = &read.metadata else {
1339 return;
1340 };
1341 if area.height == 0 || area.width < 8 {
1342 return;
1343 }
1344 let mut lines = Vec::new();
1345 if let Some(title) = &metadata.title {
1346 lines.push((title.clone(), Style::default()));
1347 }
1348 if let Some(file) = &read.facts_from {
1349 lines.push((format!("From {file}"), Style::default()));
1350 }
1351 let shown: Vec<(String, crate::formats::model_files::MetaValue)> =
1352 if metadata.pairs.is_empty() {
1353 let line = read.delimited().metadata_line.unwrap_or(1);
1354 vec![(
1355 format!("line {line}"),
1356 crate::formats::model_files::MetaValue::Text(metadata.raw.clone()),
1357 )]
1358 } else {
1359 metadata
1360 .pairs
1361 .iter()
1362 .map(|(k, v)| {
1363 (
1364 k.clone(),
1365 crate::formats::model_files::MetaValue::Text(v.clone()),
1366 )
1367 })
1368 .collect()
1369 };
1370 self.render_detail(area, buf, &lines, "Metadata", &shown);
1371 }
1372
1373 fn render_format_tab(&mut self, area: Rect, buf: &mut Buffer) {
1375 let state = self.state;
1376 let Some(detail) = state.format_detail().or_else(|| self.ctx.facts_detail()) else {
1377 let (said, style) = match self.ctx.facts {
1379 Some(FileFacts::Failed(why)) => {
1380 (why.clone(), Style::default().fg(self.theme.error))
1381 }
1382 _ => (
1383 "reading...".to_string(),
1384 Style::default().fg(self.theme.dimmed),
1385 ),
1386 };
1387 Paragraph::new(crate::glyphs::fit(&said, area.width as usize))
1388 .style(style)
1389 .render(
1390 Rect {
1391 height: area.height.min(1),
1392 ..area
1393 },
1394 buf,
1395 );
1396 return;
1397 };
1398 let warn = Style::default().fg(self.theme.warning);
1399 let lines: Vec<(String, Style)> = detail
1400 .lines
1401 .iter()
1402 .map(|line| (line.clone(), Style::default()))
1403 .chain(detail.warnings.iter().map(|line| (line.clone(), warn)))
1404 .collect();
1405 let pick = !detail.tables.is_empty();
1406 self.render_detail_list(area, buf, &lines, detail.list_title, &detail.list, pick);
1407 }
1408
1409 fn render_detail(
1412 &mut self,
1413 area: Rect,
1414 buf: &mut Buffer,
1415 lines: &[(String, Style)],
1416 title: &str,
1417 list: &[(String, crate::formats::model_files::MetaValue)],
1418 ) {
1419 self.render_detail_list(area, buf, lines, title, list, false);
1420 }
1421
1422 fn render_detail_list(
1425 &mut self,
1426 area: Rect,
1427 buf: &mut Buffer,
1428 lines: &[(String, Style)],
1429 title: &str,
1430 list: &[(String, crate::formats::model_files::MetaValue)],
1431 pick: bool,
1432 ) {
1433 if area.height == 0 || area.width < 8 {
1434 return;
1435 }
1436 let width = area.width as usize;
1437 let mut y = area.y;
1438 let bottom = area.y + area.height;
1439 for (line, style) in lines {
1441 for part in wrap_to(line, width) {
1442 if y >= bottom {
1443 return;
1444 }
1445 Paragraph::new(crate::glyphs::fit(&part, width))
1446 .style(*style)
1447 .render(
1448 Rect {
1449 y,
1450 height: 1,
1451 ..area
1452 },
1453 buf,
1454 );
1455 y += 1;
1456 }
1457 }
1458 if list.is_empty() || y + 2 >= bottom {
1461 return;
1462 }
1463 y += 1;
1464 let count = group_u64(list.len() as u64);
1465 SectionRule {
1466 title,
1467 chip: Some(&count),
1468 }
1469 .render(
1470 Rect {
1471 y,
1472 height: 1,
1473 ..area
1474 },
1475 buf,
1476 self.theme,
1477 );
1478 y += 1;
1479
1480 let gutter = u16::from(pick);
1483 let rows = metadata_lines(list, width.saturating_sub(gutter as usize));
1484 let room = (bottom - y) as usize;
1485 let fits = rows.len() <= room;
1486 let shown = if fits { room } else { room.saturating_sub(1) };
1488 self.modal.detail_visible = shown;
1489 let max_scroll = rows.len().saturating_sub(shown);
1490 let picked = pick.then(|| {
1493 let starts: Vec<usize> = (0..rows.len())
1494 .filter(|&i| !rows[i].0.trim().is_empty())
1495 .collect();
1496 let at = self
1497 .modal
1498 .detail_selected
1499 .min(starts.len().saturating_sub(1));
1500 self.modal.detail_selected = at;
1501 let start = starts.get(at).copied().unwrap_or(0);
1502 let end = starts.get(at + 1).copied().unwrap_or(rows.len());
1503 if start < self.modal.detail_scroll {
1504 self.modal.detail_scroll = start;
1505 } else if end > self.modal.detail_scroll + shown {
1506 self.modal.detail_scroll = end.saturating_sub(shown).min(start);
1507 }
1508 start..end
1509 });
1510 self.modal.detail_scroll = self.modal.detail_scroll.min(max_scroll);
1511 let first = self.modal.detail_scroll;
1512 let key_style = Style::default().fg(self.theme.text_secondary);
1513 let rail = crate::glyphs::get().rail;
1514 for (i, (key, value)) in rows.iter().enumerate().skip(first).take(shown) {
1515 let row = Rect {
1516 y,
1517 height: 1,
1518 ..area
1519 };
1520 let on = picked.as_ref().is_some_and(|p| p.contains(&i));
1521 let (key_style, value_style) = if on {
1522 let hl = self.theme.highlight_style();
1523 (key_style.patch(hl), Style::default().patch(hl))
1524 } else {
1525 (key_style, Style::default())
1526 };
1527 if on && picked.as_ref().is_some_and(|p| p.start == i) {
1528 buf.set_string(row.x, row.y, rail, Style::default().fg(self.theme.accent));
1529 }
1530 Paragraph::new(Line::from(vec![
1531 Span::styled(key.clone(), key_style),
1532 Span::styled(value.clone(), value_style),
1533 ]))
1534 .render(
1535 Rect {
1536 x: row.x + gutter,
1537 width: row.width.saturating_sub(gutter),
1538 ..row
1539 },
1540 buf,
1541 );
1542 y += 1;
1543 }
1544 if !fits && shown > 0 {
1545 let above = first;
1546 let below = rows.len().saturating_sub(first + shown);
1547 let text = match (above, below) {
1548 (0, n) => format!("{} below", group_chrome(n)),
1549 (n, 0) => format!("{} above", group_chrome(n)),
1550 (a, b) => format!("{} above, {} below", group_chrome(a), group_chrome(b)),
1551 };
1552 Paragraph::new(text)
1553 .style(Style::default().fg(self.theme.dimmed))
1554 .alignment(ratatui::layout::Alignment::Right)
1555 .render(
1556 Rect {
1557 y: bottom - 1,
1558 height: 1,
1559 ..area
1560 },
1561 buf,
1562 );
1563 }
1564 }
1565
1566 fn render_notes_tab(&mut self, area: Rect, buf: &mut Buffer) {
1570 let notes = self.state.notes();
1571 if area.height == 0 || area.width <= 4 || notes.is_empty() {
1572 return;
1573 }
1574 let selected = self.modal.notes_selected_index.min(notes.len() - 1);
1575 let width = area.width as usize;
1576 let blocks: Vec<Vec<NoteRow>> = notes
1577 .iter()
1578 .enumerate()
1579 .map(|(index, note)| note_rows(note, index == selected, width))
1580 .collect();
1581 let heights: Vec<usize> = blocks.iter().map(Vec::len).collect();
1582 let dim = Style::default().fg(self.theme.dimmed);
1583
1584 let full = area.height as usize;
1586 let (first, last) = notes_window(&heights, selected, self.modal.notes_scroll_offset, full);
1587 let all_shown = first == 0 && last == heights.len();
1588 let offer = notes[selected]
1591 .read_as_text
1592 .as_ref()
1593 .map(|column| format!("Enter read {column} as text"));
1594 let reserve = (!all_shown || offer.is_some()) && heights[selected] < full;
1595 let show = if reserve { full - 1 } else { full };
1596 if heights[selected] > show {
1597 let count = notes.len();
1600 Paragraph::new(Line::from(Span::styled(
1601 format!(
1602 "{} {} {} selected: no room",
1603 group_chrome(count),
1604 if count == 1 { "note" } else { "notes" },
1605 crate::glyphs::get().middot
1606 ),
1607 dim,
1608 )))
1609 .render(Rect { height: 1, ..area }, buf);
1610 return;
1611 }
1612 let (first, last) = if reserve {
1613 notes_window(&heights, selected, self.modal.notes_scroll_offset, show)
1614 } else {
1615 (first, last)
1616 };
1617 self.modal.notes_scroll_offset = first;
1618
1619 let mut y = area.y;
1620 let bottom = area.y + show as u16;
1621 for (offset, block) in blocks[first..last].iter().enumerate() {
1622 if offset > 0 && y < bottom {
1623 y += 1;
1624 }
1625 for row in block {
1626 if y >= bottom {
1627 break;
1628 }
1629 let at = Rect {
1630 y,
1631 height: 1,
1632 ..area
1633 };
1634 if row.dim {
1635 Paragraph::new(Line::from(Span::styled(row.text.clone(), dim))).render(at, buf);
1636 } else {
1637 Paragraph::new(row.text.as_str()).render(at, buf);
1638 }
1639 y += 1;
1640 }
1641 }
1642
1643 let (above, below) = (first, notes.len() - last);
1646 let hidden = match (reserve, above, below) {
1647 (false, _, _) | (_, 0, 0) => None,
1648 (_, 0, n) => Some(format!("{} below", group_chrome(n))),
1649 (_, n, 0) => Some(format!("{} above", group_chrome(n))),
1650 (_, a, b) => Some(format!(
1651 "{} above, {} below",
1652 group_chrome(a),
1653 group_chrome(b)
1654 )),
1655 };
1656 if !reserve || (hidden.is_none() && offer.is_none()) {
1657 return;
1658 }
1659 let last_row = Rect {
1660 y: area.y + area.height - 1,
1661 height: 1,
1662 ..area
1663 };
1664 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1666 let taken = hidden
1667 .as_ref()
1668 .map(|text| (text.width() as u16).saturating_add(1))
1669 .unwrap_or(0);
1670 if let Some(offer) = offer.as_ref() {
1671 let room = last_row.width.saturating_sub(taken) as usize;
1672 let offer = if offer.width() > room {
1674 let mark = crate::glyphs::get().ellipsis;
1677 let mut kept = String::new();
1678 for ch in offer.chars() {
1679 if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
1680 break;
1681 }
1682 kept.push(ch);
1683 }
1684 Some(format!("{kept}{mark}"))
1685 } else {
1686 Some(offer.clone())
1687 };
1688 if let Some(offer) = offer.filter(|_| room >= 8) {
1690 Paragraph::new(Line::from(Span::styled(offer, dim))).render(
1691 Rect {
1692 width: room as u16,
1693 ..last_row
1694 },
1695 buf,
1696 );
1697 }
1698 }
1699 if let Some(hidden) = hidden {
1700 Paragraph::new(Line::from(Span::styled(hidden, dim)))
1701 .right_aligned()
1702 .render(last_row, buf);
1703 }
1704 }
1705
1706 fn render_partitioned_data_tab(&self, area: Rect, buf: &mut Buffer) {
1707 let y = area.y;
1708 let w = area.width;
1709
1710 let Some(partition_columns) = self.state.partition_columns() else {
1711 Paragraph::new("Partition columns: unknown").render(
1712 Rect {
1713 y,
1714 width: w,
1715 height: 1,
1716 ..area
1717 },
1718 buf,
1719 );
1720 return;
1721 };
1722
1723 if partition_columns.is_empty() {
1724 Paragraph::new("Partition columns: none").render(
1725 Rect {
1726 y,
1727 width: w,
1728 height: 1,
1729 ..area
1730 },
1731 buf,
1732 );
1733 return;
1734 }
1735
1736 let line = format!("Partition columns: {}", partition_columns.join(", "));
1737 Paragraph::new(line).render(
1738 Rect {
1739 y,
1740 width: w,
1741 height: 1,
1742 ..area
1743 },
1744 buf,
1745 );
1746 }
1747}
1748
1749fn format_took(took: std::time::Duration) -> String {
1753 let ms = took.as_secs_f64() * 1000.0;
1754 if (ms * 100.0).round() < 100_000.0 {
1757 format!("{ms:.2} ms")
1758 } else {
1759 format!("{:.2}s", took.as_secs_f64())
1760 }
1761}
1762
1763fn wire_line(wire: crate::loading::measurements::OverTheWire) -> String {
1765 let mut line = format!(
1766 ", {} request{}",
1767 format_int(wire.requests),
1768 if wire.requests == 1 { "" } else { "s" }
1769 );
1770 if let Some(bytes) = wire.bytes {
1772 line.push_str(&format!(", {}", crate::numfmt::bytes(bytes)));
1773 }
1774 line
1775}
1776
1777fn measurement_line(
1781 cost: &crate::loading::measurements::Cost,
1782 unit: &str,
1783 singular: &str,
1784) -> String {
1785 let mut line = format_took(cost.took);
1786 if let Some(files) = cost.files {
1788 let unit = if files == 1 { singular } else { unit };
1789 line.push_str(&format!(", {} {unit}", format_int(files)));
1790 }
1791 if let Some(wire) = cost.over_the_wire {
1792 line.push_str(&wire_line(wire));
1793 }
1794 line
1795}
1796
1797fn total_line(total: &crate::loading::measurements::Total) -> String {
1800 let mut line = format_took(total.took);
1801 if let Some(wire) = total.over_the_wire {
1802 line.push_str(&wire_line(wire));
1803 }
1804 line
1805}
1806
1807fn format_int(n: usize) -> String {
1810 crate::numfmt::group_chrome(n)
1811}
1812
1813fn columns_by_type(schema: &Schema) -> String {
1814 let mut counts: HashMap<String, usize> = HashMap::new();
1815 for (_, dtype) in schema.iter() {
1816 let k = dtype.to_string();
1817 *counts.entry(k).or_default() += 1;
1818 }
1819 let mut pairs: Vec<_> = counts.into_iter().collect();
1820 pairs.sort_by(|a, b| a.0.cmp(&b.0));
1821 pairs
1822 .into_iter()
1823 .map(|(k, v)| format!("{}: {}", k, v))
1824 .collect::<Vec<_>>()
1825 .join(&format!(" {} ", crate::glyphs::get().middot))
1826}
1827
1828impl<'a> Widget for &mut DataTableInfo<'a> {
1829 fn render(self, area: Rect, buf: &mut Buffer) {
1830 let ctx = self.theme;
1831 let offered = TabsOffered {
1832 documentation: self.documentation.is_some(),
1833 ..TabsOffered::of(self.state, self.ctx.facts_tab)
1834 };
1835 let tab = self.modal.active_tab;
1836
1837 let g = crate::glyphs::get();
1840 let scrolls = match tab {
1841 InfoTab::Schema => true,
1842 InfoTab::Notes => offered.notes,
1843 InfoTab::Metadata => offered.metadata,
1844 InfoTab::Format => offered.format,
1845 InfoTab::Documentation => offered.documentation,
1846 _ => false,
1847 };
1848 let tables = tab == InfoTab::Format
1850 && offered.format
1851 && self
1852 .state
1853 .format_detail()
1854 .is_some_and(|d| !d.tables.is_empty());
1855 let mut footer = HintBar::from_ctx(ctx)
1856 .screen(datui_cli::keys::Context::Info)
1857 .key("← / →")
1858 .weight(3);
1859 if tables {
1860 footer = footer.key("Enter").weight(2).key("↑ / ↓").weight(2);
1861 } else if scrolls {
1862 footer = footer.key_as("↑ / ↓", "Scroll").weight(2);
1863 }
1864 if tab == InfoTab::Documentation && offered.documentation {
1865 footer = footer.key_as("Enter", "Values").weight(1);
1866 if self
1867 .documentation
1868 .as_deref()
1869 .is_some_and(|d| d.offers_open())
1870 {
1871 footer = footer.key("o").weight(1);
1872 }
1873 footer = footer.key("y").weight(1);
1874 }
1875 if tab == InfoTab::Schema && self.header_toggle {
1876 footer = footer.key("H").weight(-1);
1877 }
1878 if self.hex {
1879 footer = footer.key("x").weight(0);
1880 }
1881 let footer = footer.key("Esc").weight(4);
1882 let surface = Surface::new("Info");
1885 let surface = if area.height > 3 {
1886 surface.footer(&footer)
1887 } else {
1888 surface
1889 };
1890 let content = surface.render(area, buf, ctx);
1891 if content.height == 0 || content.width < 4 {
1892 return;
1893 }
1894 let tab_rows = u16::from(content.height >= 4);
1897 let gap = u16::from(content.height >= 6);
1898
1899 let tabs = InfoTab::visible(offered);
1902 let active = tabs[tab.index(offered)];
1903 let current = tab.index(offered);
1904 let mut spans = Vec::new();
1905 let mut clicks = Vec::new();
1906 for (i, t) in tabs.iter().enumerate() {
1907 let is_active = *t == active;
1908 if i > 0 {
1909 spans.push(Span::styled(
1910 format!(" {}", g.rule),
1911 Style::default().fg(ctx.dimmed),
1912 ));
1913 }
1914 spans.push(Span::raw(" "));
1915 let style = if is_active {
1916 Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
1917 } else {
1918 Style::default().fg(ctx.text_secondary)
1919 };
1920 let title = match (t, self.state.format_detail()) {
1923 (InfoTab::Format, Some(detail)) => detail.tab,
1924 (InfoTab::Format, None) => self.ctx.facts_tab.unwrap_or(t.title()),
1925 _ => t.title(),
1926 };
1927 clicks.push((
1929 spans.len(),
1930 crate::app::pointer::Hit::Option {
1931 field: None,
1932 index: i,
1933 current,
1934 },
1935 ));
1936 spans.push(Span::styled(title, style));
1937 }
1938 let tab_area = Rect {
1939 height: tab_rows,
1940 ..content
1941 };
1942 let line = Line::from(spans);
1943 if tab_rows > 0 {
1944 crate::app::pointer::record_spans(tab_area, &line, clicks);
1945 }
1946 Paragraph::new(line).render(tab_area, buf);
1947
1948 let body = Rect {
1951 y: content.y + tab_rows + gap,
1952 height: content.height - tab_rows - gap,
1953 ..content
1954 };
1955 match tab {
1956 InfoTab::Schema => self.render_schema_tab(body, buf),
1957 InfoTab::Resources => self.render_resources_tab(body, buf),
1958 InfoTab::Metadata if offered.metadata => self.render_metadata_tab(body, buf),
1959 InfoTab::Format if offered.format => self.render_format_tab(body, buf),
1960 InfoTab::Partitions if offered.partitions => {
1961 self.render_partitioned_data_tab(body, buf)
1962 }
1963 InfoTab::Notes if offered.notes => self.render_notes_tab(body, buf),
1964 InfoTab::Documentation if offered.documentation => {
1965 if let Some(page) = self.documentation.as_deref_mut() {
1966 crate::widgets::documentation::render_page(page, body, buf, ctx);
1967 }
1968 }
1969 InfoTab::Metadata
1970 | InfoTab::Format
1971 | InfoTab::Partitions
1972 | InfoTab::Notes
1973 | InfoTab::Documentation => self.render_schema_tab(body, buf),
1974 }
1975 }
1976}
1977
1978#[cfg(test)]
1979mod tests;