use std::collections::HashMap;
use crate::numfmt::group_chrome;
use std::path::Path;
use std::sync::Arc;
use polars::prelude::*;
use crate::formats::parquet_footer::Footer;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::Stylize;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{HighlightSpacing, Paragraph, Row, StatefulWidget, Table, Widget};
use crate::export::export_modal::ExportFormat;
use crate::render::context::RenderContext;
use crate::table::DataTableState;
use crate::widgets::ui::{HintBar, SectionRule, Surface};
struct NoteRow {
text: String,
dim: bool,
}
fn notes_window(heights: &[usize], selected: usize, stored: usize, show: usize) -> (usize, usize) {
if heights.is_empty() {
return (0, 0);
}
let selected = selected.min(heights.len() - 1);
let span = |a: usize, b: usize| heights[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1);
let mut first = stored.min(selected);
while first < selected && span(first, selected + 1) > show {
first += 1;
}
let mut last = selected + 1;
while last < heights.len() && span(first, last + 1) <= show {
last += 1;
}
while first > 0 && span(first - 1, last) <= show {
first -= 1;
}
(first, last)
}
fn note_rows(note: &crate::notes::Note, selected: bool, width: usize) -> Vec<NoteRow> {
let mut rows = Vec::new();
let marker = if selected {
crate::glyphs::get().prompt
} else {
" "
};
let mut first = true;
for line in wrap_to(¬e.summary, width.saturating_sub(2)) {
rows.push(NoteRow {
text: format!("{}{line}", if first { marker } else { " " }),
dim: false,
});
first = false;
}
for line in wrap_to(¬e.scope, width.saturating_sub(4)) {
rows.push(NoteRow {
text: format!(" {line}"),
dim: true,
});
}
rows
}
pub(crate) fn wrap_to(text: &str, width: usize) -> Vec<String> {
use unicode_width::UnicodeWidthStr;
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let room = if line.is_empty() {
width
} else {
width.saturating_sub(line.width() + 1)
};
if word.width() > room && !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
lines
}
pub(crate) fn group_u64(n: u64) -> String {
let mut out = String::new();
crate::numfmt::NumberFormat::CHROME.write_u64(n, &mut out);
out
}
pub(crate) fn count_of(n: u64, one: &str, many: &str) -> String {
format!("{} {}", group_u64(n), if n == 1 { one } else { many })
}
pub(crate) fn short_count(n: u64) -> String {
const STEPS: [(u64, &str); 4] = [
(1_000_000_000_000, "T"),
(1_000_000_000, "B"),
(1_000_000, "M"),
(1_000, "K"),
];
for (size, suffix) in STEPS {
if n >= size {
return format!("{:.1}{suffix}", n as f64 / size as f64);
}
}
n.to_string()
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let mut lines = Vec::new();
let mut line = String::new();
let mut line_width = 0;
let mut broken = false;
let mut rest = text;
while !rest.is_empty() {
let space = rest.starts_with(' ');
let end = rest
.find(|c: char| (c == ' ') != space)
.unwrap_or(rest.len());
let (token, after) = rest.split_at(end);
rest = after;
let w = token.width();
if space {
if broken && line.is_empty() {
continue;
}
if line_width + w <= width {
line.push_str(token);
line_width += w;
} else {
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
line_width = 0;
broken = true;
}
continue;
}
if line_width + w > width && !line.is_empty() && w <= width {
lines.push(std::mem::take(&mut line).trim_end().to_string());
line_width = 0;
broken = true;
}
for c in token.chars() {
let cw = c.width().unwrap_or(0);
if line_width + cw > width && !line.is_empty() {
lines.push(std::mem::take(&mut line));
line_width = 0;
broken = true;
}
line.push(c);
line_width += cw;
}
}
if !(broken && line.is_empty()) || lines.is_empty() {
lines.push(line);
}
lines
}
pub(crate) fn meta_text(value: &crate::formats::model_files::MetaValue) -> String {
use crate::formats::model_files::MetaValue;
match value {
MetaValue::Text(text) => text.clone(),
MetaValue::List { of, len, items } if items.len() as u64 == *len && *len > 0 => {
let quote = *of == "strings";
let items: Vec<String> = items
.iter()
.map(|i| if quote { format!("{i:?}") } else { i.clone() })
.collect();
format!("[{}]", items.join(", "))
}
MetaValue::List { of, len, .. } => format!("[{} {of}]", group_u64(*len)),
}
}
pub(crate) const VALUE_SHOWN_BYTES: usize = 64 * 1024;
pub(crate) fn metadata_lines(
metadata: &[(String, crate::formats::model_files::MetaValue)],
width: usize,
) -> Vec<(String, String)> {
use unicode_width::UnicodeWidthStr;
let longest = metadata.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
let key_width = longest.min(width * 2 / 5).max(1);
let value_width = width.saturating_sub(key_width + 2).max(1);
let mut out = Vec::new();
for (key, value) in metadata {
let key_cell = format!(
"{:<w$} ",
crate::glyphs::fit(key, key_width),
w = key_width
);
let blank = " ".repeat(key_width + 2);
let listed;
let text = match value {
crate::formats::model_files::MetaValue::Text(text) => text.as_str(),
other => {
listed = meta_text(other);
listed.as_str()
}
};
let cut = text.floor_char_boundary(VALUE_SHOWN_BYTES);
let (text, more) = (&text[..cut], text.len() - cut);
let mut first = true;
for raw in text.split('\n') {
let clean: String = raw
.chars()
.filter_map(|c| match c {
'\t' => Some(' '),
c if c.is_control() => None,
c => Some(c),
})
.collect();
for line in wrap_words(&clean, value_width) {
let k = if first {
key_cell.clone()
} else {
blank.clone()
};
first = false;
out.push((k, line));
}
}
if more > 0 {
let g = crate::glyphs::get();
out.push((
blank,
format!("{} {} more", g.ellipsis, crate::numfmt::bytes(more as u64)),
));
}
}
out
}
pub(crate) fn clock(seconds: f64) -> String {
let ms = (seconds.max(0.0) * 1000.0).round() as u64;
let (h, m, s, ms) = (ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000);
if h > 0 {
format!("{h}:{m:02}:{s:02}.{ms:03}")
} else {
format!("{m}:{s:02}.{ms:03}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InfoTab {
#[default]
Schema,
Metadata,
Format,
Resources,
Partitions,
Notes,
Documentation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TabsOffered {
pub metadata: bool,
pub format: bool,
pub partitions: bool,
pub notes: bool,
pub documentation: bool,
}
impl TabsOffered {
pub fn of(state: &DataTableState, facts_tab: Option<&'static str>) -> Self {
Self {
metadata: state
.delimited_read()
.is_some_and(|read| read.metadata.is_some()),
format: state.format_detail().is_some() || facts_tab.is_some(),
partitions: state
.partition_columns()
.map(|v| !v.is_empty())
.unwrap_or(false),
notes: state.has_notes(),
documentation: false,
}
}
}
impl InfoTab {
pub fn visible(offered: TabsOffered) -> Vec<InfoTab> {
let mut tabs = vec![InfoTab::Schema];
if offered.documentation {
tabs.push(InfoTab::Documentation);
}
if offered.metadata {
tabs.push(InfoTab::Metadata);
}
if offered.format {
tabs.push(InfoTab::Format);
}
tabs.push(InfoTab::Resources);
if offered.partitions {
tabs.push(InfoTab::Partitions);
}
if offered.notes {
tabs.push(InfoTab::Notes);
}
tabs
}
pub fn title(self) -> &'static str {
match self {
InfoTab::Schema => "Schema",
InfoTab::Metadata => "Metadata",
InfoTab::Format => "Format",
InfoTab::Resources => "Resources",
InfoTab::Partitions => "Partitions",
InfoTab::Notes => "Notes",
InfoTab::Documentation => "Documentation",
}
}
pub fn next(self, offered: TabsOffered) -> Self {
let tabs = Self::visible(offered);
let at = self.index(offered);
tabs[(at + 1) % tabs.len()]
}
pub fn prev(self, offered: TabsOffered) -> Self {
let tabs = Self::visible(offered);
let at = self.index(offered);
tabs[(at + tabs.len() - 1) % tabs.len()]
}
pub fn index(self, offered: TabsOffered) -> usize {
Self::visible(offered)
.iter()
.position(|tab| *tab == self)
.unwrap_or(0)
}
}
#[derive(Default)]
pub struct InfoModal {
pub active_tab: InfoTab,
pub schema_selected_index: usize,
pub schema_scroll_offset: usize,
pub schema_table_state: ratatui::widgets::TableState,
pub schema_visible_height: usize,
pub notes_selected_index: usize,
pub notes_scroll_offset: usize,
pub detail_scroll: usize,
pub detail_visible: usize,
pub detail_selected: usize,
}
impl InfoModal {
pub fn new() -> Self {
Self::default()
}
pub fn open(&mut self) {
self.open_on(InfoTab::Schema);
}
pub fn open_on(&mut self, tab: InfoTab) {
self.active_tab = tab;
self.schema_selected_index = 0;
self.schema_scroll_offset = 0;
self.schema_table_state.select(Some(0));
self.notes_selected_index = 0;
self.notes_scroll_offset = 0;
self.detail_scroll = 0;
self.detail_selected = 0;
}
pub fn switch_tab(&mut self, offered: TabsOffered) {
self.active_tab = self.active_tab.next(offered);
if self.active_tab == InfoTab::Schema {
self.schema_selected_index = 0;
self.schema_scroll_offset = 0;
self.schema_table_state.select(Some(0));
}
}
pub fn switch_tab_prev(&mut self, offered: TabsOffered) {
self.active_tab = self.active_tab.prev(offered);
if self.active_tab == InfoTab::Schema {
self.schema_selected_index = 0;
self.schema_scroll_offset = 0;
self.schema_table_state.select(Some(0));
}
}
pub fn detail_scroll_by(&mut self, delta: isize) {
self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
}
pub fn notes_move(&mut self, delta: isize, total: usize) -> bool {
if total == 0 {
return false;
}
let last = total - 1;
let next = (self.notes_selected_index as isize + delta).clamp(0, last as isize) as usize;
if next == self.notes_selected_index {
return false;
}
self.notes_selected_index = next;
true
}
pub fn schema_table_down(&mut self, total_rows: usize, visible_height: usize) -> bool {
if total_rows == 0 {
return false;
}
let max_idx = total_rows.saturating_sub(1);
if self.schema_selected_index >= max_idx {
return false;
}
self.schema_selected_index += 1;
let visible_end = self.schema_scroll_offset + visible_height;
if visible_height > 0 && self.schema_selected_index >= visible_end {
self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
}
let local = self
.schema_selected_index
.saturating_sub(self.schema_scroll_offset);
self.schema_table_state.select(Some(local));
true
}
pub fn schema_table_up(&mut self, total_rows: usize, _visible_height: usize) -> bool {
if total_rows == 0 || self.schema_selected_index == 0 {
return false;
}
self.schema_selected_index -= 1;
if self.schema_selected_index < self.schema_scroll_offset {
self.schema_scroll_offset = self.schema_selected_index;
}
let local = self
.schema_selected_index
.saturating_sub(self.schema_scroll_offset);
self.schema_table_state.select(Some(local));
true
}
pub fn sync_schema_table_state(&mut self, total_rows: usize, visible_height: usize) {
if total_rows == 0 {
self.schema_table_state.select(None);
return;
}
let max_idx = total_rows.saturating_sub(1);
self.schema_selected_index = self.schema_selected_index.min(max_idx);
if self.schema_scroll_offset + visible_height <= self.schema_selected_index
&& visible_height > 0
{
self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
}
if self.schema_selected_index < self.schema_scroll_offset {
self.schema_scroll_offset = self.schema_selected_index;
}
let local = self
.schema_selected_index
.saturating_sub(self.schema_scroll_offset);
self.schema_table_state.select(Some(local));
}
}
#[derive(Debug, Clone)]
pub enum FileFacts {
Reading,
Read {
size: Option<u64>,
footer: Option<Footer>,
detail: Option<Arc<crate::formats::text_formats::Detail>>,
},
Failed(String),
}
impl FileFacts {
pub(crate) fn read(
path: &Path,
facts: Option<crate::formats::readers::Facts>,
) -> std::result::Result<Self, String> {
let io = |e: std::io::Error| {
log::warn!(target: "datui", "file size of {}: {e}", path.display());
match e.kind() {
std::io::ErrorKind::NotFound => "file not found".to_string(),
std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
_ => {
let said = e.to_string();
match said.rsplit_once(" (os error") {
Some((words, _)) => words.to_string(),
None => said,
}
}
}
};
let meta = std::fs::metadata(path).map_err(io)?;
if meta.is_dir() {
return Ok(Self::Read {
size: None,
footer: None,
detail: None,
});
}
let read = match facts {
Some(facts) => (facts.read)(path).map_err(|e| {
log::warn!(target: "datui", "footer of {}: {e}", path.display());
"unreadable footer".to_string()
})?,
None => crate::formats::readers::FormatFacts::default(),
};
Ok(Self::Read {
size: Some(meta.len()),
footer: read.footer,
detail: read.detail,
})
}
}
pub struct InfoContext<'a> {
pub format: Option<ExportFormat>,
pub declared_types: bool,
pub facts: Option<&'a FileFacts>,
pub facts_tab: Option<&'static str>,
pub footer_expected: bool,
}
impl<'a> InfoContext<'a> {
pub fn schema_source(&self) -> &'static str {
if self.declared_types {
"types declared"
} else {
"types inferred"
}
}
pub fn footer(&self) -> Option<&'a Footer> {
match self.facts? {
FileFacts::Read { footer, .. } => footer.as_ref(),
FileFacts::Reading | FileFacts::Failed(_) => None,
}
}
fn facts_detail(&self) -> Option<&'a crate::formats::text_formats::Detail> {
match self.facts? {
FileFacts::Read { detail, .. } => detail.as_deref(),
FileFacts::Reading | FileFacts::Failed(_) => None,
}
}
fn reading(&self) -> bool {
matches!(self.facts, Some(FileFacts::Reading))
}
}
pub struct DataTableInfo<'a> {
pub state: &'a DataTableState,
pub ctx: InfoContext<'a>,
pub modal: &'a mut InfoModal,
pub theme: &'a RenderContext,
pub hex: bool,
pub header_toggle: bool,
pub codebook: Option<&'a crate::home::codebook::Codebook>,
pub documentation: Option<&'a mut crate::widgets::documentation::DocState>,
pub estimate: Option<crate::formats::schema_union::RowEstimate>,
}
fn read_line(state: &DataTableState) -> Option<String> {
let mode = state.read_mode()?.label();
Some(if state.fetched() {
format!("download {} {mode}", crate::glyphs::get().arrow_right)
} else {
mode.to_string()
})
}
fn rows_and_columns(rows: Option<usize>, columns: usize) -> String {
let middot = crate::glyphs::get().middot;
match rows {
Some(rows) => format!(
"Rows (total): {} {middot} Columns: {}",
format_int(rows),
columns
),
None => format!("Rows (total): counting... {middot} Columns: {columns}"),
}
}
fn estimated_rows_and_columns(
estimate: crate::formats::schema_union::RowEstimate,
columns: usize,
) -> String {
let middot = crate::glyphs::get().middot;
format!(
"Rows (total): ~{} (est. from {} of {} files; c counts) {middot} Columns: {columns}",
crate::home::discover::format_rows(estimate.rows as usize),
format_int(estimate.sampled),
format_int(estimate.files),
)
}
impl<'a> DataTableInfo<'a> {
pub fn new(
state: &'a DataTableState,
ctx: InfoContext<'a>,
modal: &'a mut InfoModal,
theme: &'a RenderContext,
) -> Self {
Self {
state,
ctx,
modal,
theme,
hex: false,
header_toggle: false,
codebook: None,
documentation: None,
estimate: None,
}
}
fn codebook_here(&self) -> Option<&'a crate::home::codebook::Codebook> {
self.codebook
.filter(|book| book.covers(self.state.schema().iter_names().map(|n| n.as_str())))
}
fn render_schema_tab(&mut self, area: Rect, buf: &mut Buffer) {
let summary = self.render_schema_summary(area, buf);
let mut rest = Rect {
y: area.y + summary,
height: area.height.saturating_sub(summary),
..area
};
if rest.height == 0 {
return;
}
let note_rows = if self.codebook_here().is_some() && rest.height >= 10 {
4
} else {
0
};
rest.height -= note_rows;
let used = self.render_schema_table(rest, buf);
if note_rows > 0 {
self.render_column_note(
Rect {
y: rest.y + used + 1,
height: note_rows - 1,
..rest
},
buf,
);
}
}
fn render_column_note(&self, area: Rect, buf: &mut Buffer) {
let Some(book) = self.codebook_here() else {
return;
};
let Some((name, _)) = self
.state
.schema()
.get_at_index(self.modal.schema_selected_index)
else {
return;
};
let width = area.width as usize;
let rows = area.height as usize;
let mut lines: Vec<String> = Vec::new();
match book.column(name.as_str()) {
Some(column) => {
let about = column.about();
if !about.is_empty() {
lines.extend(wrap_to(&format!("{name}: {about}"), width));
}
if !column.values.is_empty() {
let sep = format!(" {} ", crate::glyphs::get().middot);
let codes: Vec<&str> = column
.values
.keys()
.map(|k| if k.is_empty() { "blank" } else { k.as_str() })
.collect();
let left = rows.saturating_sub(lines.len()).max(1);
let mut wrapped = wrap_to(&format!("Codes: {}", codes.join(&sep)), width);
if wrapped.len() > left {
let rest = wrapped[left - 1..].join(" ");
wrapped.truncate(left - 1);
wrapped.push(crate::glyphs::fit(&rest, width));
}
lines.extend(wrapped);
}
}
None => lines.push(format!("{name}: not documented")),
}
for (i, line) in lines.iter().take(rows).enumerate() {
Paragraph::new(crate::glyphs::fit(line, width))
.style(Style::default().fg(self.theme.text_secondary))
.render(
Rect {
y: area.y + i as u16,
height: 1,
..area
},
buf,
);
}
}
fn render_schema_summary(&self, area: Rect, buf: &mut Buffer) -> u16 {
let ncols = self.state.schema().len();
let mut lines = vec![];
lines.push(match self.estimate {
Some(estimate) => estimated_rows_and_columns(estimate, ncols),
None => rows_and_columns(self.state.num_rows_if_valid(), ncols),
});
let by_type = columns_by_type(self.state.schema().as_ref());
if !by_type.is_empty() {
lines.push(by_type);
}
let others = self.state.other_tables();
if !others.is_empty() {
let sep = format!(" {} ", crate::glyphs::get().middot);
lines.push(format!("Other tables (--table): {}", others.join(&sep)));
}
if let Some(book) = self.codebook_here()
&& !book.source.is_empty()
{
lines.push(crate::glyphs::fit(
&format!("Documentation: {}", book.source),
area.width as usize,
));
}
for (i, s) in lines.iter().enumerate() {
Paragraph::new(s.as_str()).render(
Rect {
x: area.x,
y: area.y + i as u16,
width: area.width,
height: 1,
},
buf,
);
}
lines.len() as u16
}
fn render_schema_table(&mut self, area: Rect, buf: &mut Buffer) -> u16 {
let dataset = self.state.dataset_schema();
let src = match dataset {
Some(dataset) => dataset.origin.to_string(),
None if self.state.format_detail().is_some_and(|d| d.own_columns) => {
"types declared".to_string()
}
None => self.ctx.schema_source().to_string(),
};
let presence = dataset.map(|dataset| {
let readable = dataset.files.saturating_sub(dataset.unreadable.len());
let present_by_name: HashMap<&str, usize> = dataset
.columns
.iter()
.map(|c| (c.name.as_str(), c.present_in))
.collect();
(readable, present_by_name)
});
let has_files = presence.is_some();
let compression = self.ctx.footer().map(|m| {
crate::formats::parquet_footer::column_compression(
m.as_ref(),
self.state.schema().as_ref(),
)
});
let has_comp =
self.ctx.footer_expected || compression.as_ref().is_some_and(|c| !c.is_empty());
let has_units = !self.state.units().is_empty();
let book = self.codebook_here();
let mut header_cells = vec!["Column", "Type"];
if has_units {
header_cells.push("Unit");
}
if book.is_some() {
header_cells.push("About");
}
if has_files {
header_cells.push("Files");
}
if has_comp {
header_cells.push("Compression");
}
let header = Row::new(header_cells).bold();
let total_rows = self.state.schema().len();
let body_focused = true;
SectionRule {
title: "Schema",
chip: Some(&src),
}
.render(Rect { height: 1, ..area }, buf, self.theme);
let inner = Rect {
y: area.y + 1,
height: area.height.saturating_sub(1),
..area
};
let visible_height = inner.height as usize;
let fits = total_rows <= visible_height.saturating_sub(1);
let data_height = visible_height.saturating_sub(1 + usize::from(!fits));
self.modal.schema_visible_height = data_height;
self.modal.sync_schema_table_state(total_rows, data_height);
let offset = self.modal.schema_scroll_offset;
let take = data_height.min(total_rows.saturating_sub(offset));
let mut rows: Vec<Vec<String>> = vec![];
for (idx, (name, dtype)) in self.state.schema().iter().enumerate() {
if idx < offset {
continue;
}
if idx >= offset + take {
break;
}
let name_str: &str = name.as_ref();
let mut cells = vec![name.to_string(), dtype.to_string()];
if has_units {
cells.push(self.state.unit_of(name_str).unwrap_or_default().to_string());
}
if let Some(book) = book {
cells.push(book.column(name_str).map(|c| c.about()).unwrap_or_default());
}
if let Some((readable, present_by_name)) = &presence {
let files_str = match present_by_name.get(name_str) {
Some(present) if present >= readable => {
format!("all {}", format_int(*readable))
}
Some(present) => {
format!("{} of {}", format_int(*present), format_int(*readable))
}
None => crate::glyphs::get().dash.to_string(),
};
cells.push(files_str);
}
if has_comp {
let comp_str = match compression.as_ref().map(|c| c.get(name_str)) {
Some(Some((codec, ratio))) => {
format!("{} {:.1}{}", codec, ratio, crate::glyphs::get().times)
}
None if self.ctx.reading() => String::new(),
_ => crate::glyphs::get().dash.to_string(),
};
cells.push(comp_str);
}
rows.push(cells);
}
let widths: Vec<Constraint> = if book.is_some() {
let mut weights = vec![3, 2];
weights.extend(has_units.then_some(2));
weights.push(7);
weights.extend(has_files.then_some(2));
weights.extend(has_comp.then_some(3));
weights.into_iter().map(Constraint::Fill).collect()
} else if has_units {
let mut weights = vec![3, 3, 2];
weights.extend(has_files.then_some(2));
weights.extend(has_comp.then_some(3));
weights.into_iter().map(Constraint::Fill).collect()
} else {
match (has_files, has_comp) {
(true, true) => vec![
Constraint::Percentage(25),
Constraint::Percentage(30),
Constraint::Percentage(20),
Constraint::Percentage(25),
],
(true, false) => vec![
Constraint::Percentage(35),
Constraint::Percentage(40),
Constraint::Percentage(25),
],
(false, true) => vec![
Constraint::Percentage(30),
Constraint::Percentage(40),
Constraint::Percentage(30),
],
(false, false) => vec![Constraint::Percentage(50), Constraint::Percentage(50)],
}
};
let g = crate::glyphs::get();
let (highlight, symbol) = if body_focused {
(self.theme.highlight_style(), g.selector)
} else {
(Style::default().fg(self.theme.accent), g.selector_blank)
};
let symbol = Span::styled(symbol, Style::default().fg(self.theme.accent));
if book.is_some() {
let about = 2 + usize::from(has_units);
let room = Rect {
width: inner
.width
.saturating_sub(crate::glyphs::cell_width(g.selector) as u16),
..inner
};
let cols = Layout::horizontal(widths.clone()).spacing(1).split(room);
if let Some(col) = cols.get(about) {
for cells in &mut rows {
if let Some(cell) = cells.get_mut(about) {
*cell = crate::glyphs::fit(cell, col.width as usize);
}
}
}
}
let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
let table = Table::new(rows, widths)
.header(header)
.column_spacing(1)
.row_highlight_style(highlight)
.highlight_symbol(symbol)
.highlight_spacing(HighlightSpacing::Always);
let table_area = Rect {
height: inner.height.saturating_sub(u16::from(!fits)),
..inner
};
StatefulWidget::render(table, table_area, buf, &mut self.modal.schema_table_state);
if !fits && inner.height > 0 {
let above = offset;
let below = total_rows.saturating_sub(offset + take);
let counted = match (above, below) {
(0, 0) => None,
(0, n) => Some(format!("{} below", format_int(n))),
(n, 0) => Some(format!("{} above", format_int(n))),
(a, b) => Some(format!("{} above, {} below", format_int(a), format_int(b))),
};
if let Some(text) = counted {
Paragraph::new(text)
.style(Style::default().fg(self.theme.dimmed))
.alignment(ratatui::layout::Alignment::Right)
.render(
Rect {
y: inner.y + inner.height - 1,
height: 1,
..inner
},
buf,
);
}
}
(2 + take + usize::from(!fits)).min(area.height as usize) as u16
}
fn render_resources_tab(&self, area: Rect, buf: &mut Buffer) {
const LABEL_WIDTH: u16 = 17;
let label_constraint = Constraint::Length(LABEL_WIDTH);
let value_constraint = Constraint::Min(1);
let mut y = area.y;
let h = area.height;
let w = area.width;
fn label_value_row(label: &str, value: &str, area: Rect, buf: &mut Buffer, label_w: u16) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(label_w), Constraint::Min(1)])
.split(area);
Paragraph::new(label).render(chunks[0], buf);
Paragraph::new(value).render(chunks[1], buf);
}
if y >= area.y + h {
return;
}
let size_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([label_constraint, value_constraint])
.split(Rect {
y,
width: w,
height: 1,
..area
});
let file_size = match self.ctx.facts {
None | Some(FileFacts::Read { size: None, .. }) => Span::raw(crate::glyphs::get().dash),
Some(FileFacts::Read {
size: Some(size), ..
}) => Span::raw(crate::numfmt::bytes(*size)),
Some(FileFacts::Reading) => {
Span::styled("reading...", Style::default().fg(self.theme.dimmed))
}
Some(FileFacts::Failed(why)) => Span::styled(
crate::glyphs::fit(why, size_chunks[1].width as usize),
Style::default().fg(self.theme.error),
),
};
Paragraph::new("File size:").render(size_chunks[0], buf);
Paragraph::new(Line::from(file_size)).render(size_chunks[1], buf);
y += 1;
if y >= area.y + h {
return;
}
let fmt = self
.ctx
.format
.map(|f| f.as_str())
.unwrap_or(crate::glyphs::get().dash);
label_value_row(
"Format:",
fmt,
Rect {
y,
width: w,
height: 1,
..area
},
buf,
LABEL_WIDTH,
);
y += 1;
if let Some(read) = read_line(self.state) {
if y >= area.y + h {
return;
}
label_value_row(
"Read:",
&read,
Rect {
y,
width: w,
height: 1,
..area
},
buf,
LABEL_WIDTH,
);
y += 1;
}
if y >= area.y + h {
return;
}
let buf_rows = self.state.buffered_rows();
let max_rows = self.state.max_buffered_rows();
let row_area = Rect {
y,
width: w,
height: 1,
..area
};
let row_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([label_constraint, value_constraint])
.split(row_area);
Paragraph::new("Buffer (Rows):").render(row_chunks[0], buf);
if max_rows > 0 {
let label = format!("{} / {}", format_int(buf_rows), format_int(max_rows));
Paragraph::new(label).render(row_chunks[1], buf);
} else {
Paragraph::new(format_int(buf_rows)).render(row_chunks[1], buf);
}
y += 1;
if y >= area.y + h {
return;
}
let buf_mb = self
.state
.buffered_memory_bytes()
.map(|b| b / (1024 * 1024));
let max_mb = self.state.max_buffered_mb();
let mb_area = Rect {
y,
width: w,
height: 1,
..area
};
let mb_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([label_constraint, value_constraint])
.split(mb_area);
Paragraph::new("Buffer (MB):").render(mb_chunks[0], buf);
if max_mb > 0 {
let label = match buf_mb {
Some(m) => format!("{:.1} / {} MiB", m as f64, max_mb),
None => crate::glyphs::get().dash.to_string(),
};
Paragraph::new(label).render(mb_chunks[1], buf);
} else {
let value = buf_mb
.map(|m| format!("{:.1} MiB", m as f64))
.unwrap_or_else(|| {
self.state
.buffered_memory_bytes()
.map(|b| crate::numfmt::bytes(b as u64))
.unwrap_or_else(|| crate::glyphs::get().dash.to_string())
});
Paragraph::new(value).render(mb_chunks[1], buf);
}
y += 1;
self.render_measurements(area, buf, &mut y, LABEL_WIDTH);
}
fn render_measurements(&self, area: Rect, buf: &mut Buffer, y: &mut u16, label_w: u16) {
let meter = self.state.measurements();
let mut rows: Vec<(&str, String)> = [
("Listing:", "files", "file", meter.listing()),
("Footers:", "footers read", "footer read", meter.footers()),
("Last page:", "files read", "file read", meter.last_page()),
]
.into_iter()
.filter_map(|(label, unit, singular, cost)| {
Some((label, measurement_line(&cost?, unit, singular)))
})
.collect();
if let Some(total) = meter.total() {
rows.push(("Total:", total_line(&total)));
}
if rows.is_empty() {
return;
}
let bottom = area.y + area.height;
if *y + 2 >= bottom {
return;
}
*y += 1;
SectionRule {
title: "Measurements",
chip: None,
}
.render(
Rect {
y: *y,
width: area.width,
height: 1,
..area
},
buf,
self.theme,
);
*y += 1;
for (label, line) in rows {
if *y >= bottom {
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(label_w), Constraint::Min(1)])
.split(Rect {
y: *y,
width: area.width,
height: 1,
..area
});
Paragraph::new(label).render(chunks[0], buf);
Paragraph::new(line).render(chunks[1], buf);
*y += 1;
}
}
fn render_metadata_tab(&mut self, area: Rect, buf: &mut Buffer) {
let Some(read) = self.state.delimited_read().cloned() else {
return;
};
let Some(metadata) = &read.metadata else {
return;
};
if area.height == 0 || area.width < 8 {
return;
}
let mut lines = Vec::new();
if let Some(title) = &metadata.title {
lines.push((title.clone(), Style::default()));
}
if let Some(file) = &read.facts_from {
lines.push((format!("From {file}"), Style::default()));
}
let shown: Vec<(String, crate::formats::model_files::MetaValue)> =
if metadata.pairs.is_empty() {
let line = read.delimited().metadata_line.unwrap_or(1);
vec![(
format!("line {line}"),
crate::formats::model_files::MetaValue::Text(metadata.raw.clone()),
)]
} else {
metadata
.pairs
.iter()
.map(|(k, v)| {
(
k.clone(),
crate::formats::model_files::MetaValue::Text(v.clone()),
)
})
.collect()
};
self.render_detail(area, buf, &lines, "Metadata", &shown);
}
fn render_format_tab(&mut self, area: Rect, buf: &mut Buffer) {
let state = self.state;
let Some(detail) = state.format_detail().or_else(|| self.ctx.facts_detail()) else {
let (said, style) = match self.ctx.facts {
Some(FileFacts::Failed(why)) => {
(why.clone(), Style::default().fg(self.theme.error))
}
_ => (
"reading...".to_string(),
Style::default().fg(self.theme.dimmed),
),
};
Paragraph::new(crate::glyphs::fit(&said, area.width as usize))
.style(style)
.render(
Rect {
height: area.height.min(1),
..area
},
buf,
);
return;
};
let warn = Style::default().fg(self.theme.warning);
let lines: Vec<(String, Style)> = detail
.lines
.iter()
.map(|line| (line.clone(), Style::default()))
.chain(detail.warnings.iter().map(|line| (line.clone(), warn)))
.collect();
let pick = !detail.tables.is_empty();
self.render_detail_list(area, buf, &lines, detail.list_title, &detail.list, pick);
}
fn render_detail(
&mut self,
area: Rect,
buf: &mut Buffer,
lines: &[(String, Style)],
title: &str,
list: &[(String, crate::formats::model_files::MetaValue)],
) {
self.render_detail_list(area, buf, lines, title, list, false);
}
fn render_detail_list(
&mut self,
area: Rect,
buf: &mut Buffer,
lines: &[(String, Style)],
title: &str,
list: &[(String, crate::formats::model_files::MetaValue)],
pick: bool,
) {
if area.height == 0 || area.width < 8 {
return;
}
let width = area.width as usize;
let mut y = area.y;
let bottom = area.y + area.height;
for (line, style) in lines {
for part in wrap_to(line, width) {
if y >= bottom {
return;
}
Paragraph::new(crate::glyphs::fit(&part, width))
.style(*style)
.render(
Rect {
y,
height: 1,
..area
},
buf,
);
y += 1;
}
}
if list.is_empty() || y + 2 >= bottom {
return;
}
y += 1;
let count = group_u64(list.len() as u64);
SectionRule {
title,
chip: Some(&count),
}
.render(
Rect {
y,
height: 1,
..area
},
buf,
self.theme,
);
y += 1;
let gutter = u16::from(pick);
let rows = metadata_lines(list, width.saturating_sub(gutter as usize));
let room = (bottom - y) as usize;
let fits = rows.len() <= room;
let shown = if fits { room } else { room.saturating_sub(1) };
self.modal.detail_visible = shown;
let max_scroll = rows.len().saturating_sub(shown);
let picked = pick.then(|| {
let starts: Vec<usize> = (0..rows.len())
.filter(|&i| !rows[i].0.trim().is_empty())
.collect();
let at = self
.modal
.detail_selected
.min(starts.len().saturating_sub(1));
self.modal.detail_selected = at;
let start = starts.get(at).copied().unwrap_or(0);
let end = starts.get(at + 1).copied().unwrap_or(rows.len());
if start < self.modal.detail_scroll {
self.modal.detail_scroll = start;
} else if end > self.modal.detail_scroll + shown {
self.modal.detail_scroll = end.saturating_sub(shown).min(start);
}
start..end
});
self.modal.detail_scroll = self.modal.detail_scroll.min(max_scroll);
let first = self.modal.detail_scroll;
let key_style = Style::default().fg(self.theme.text_secondary);
let rail = crate::glyphs::get().rail;
for (i, (key, value)) in rows.iter().enumerate().skip(first).take(shown) {
let row = Rect {
y,
height: 1,
..area
};
let on = picked.as_ref().is_some_and(|p| p.contains(&i));
let (key_style, value_style) = if on {
let hl = self.theme.highlight_style();
(key_style.patch(hl), Style::default().patch(hl))
} else {
(key_style, Style::default())
};
if on && picked.as_ref().is_some_and(|p| p.start == i) {
buf.set_string(row.x, row.y, rail, Style::default().fg(self.theme.accent));
}
Paragraph::new(Line::from(vec![
Span::styled(key.clone(), key_style),
Span::styled(value.clone(), value_style),
]))
.render(
Rect {
x: row.x + gutter,
width: row.width.saturating_sub(gutter),
..row
},
buf,
);
y += 1;
}
if !fits && shown > 0 {
let above = first;
let below = rows.len().saturating_sub(first + shown);
let text = match (above, below) {
(0, n) => format!("{} below", group_chrome(n)),
(n, 0) => format!("{} above", group_chrome(n)),
(a, b) => format!("{} above, {} below", group_chrome(a), group_chrome(b)),
};
Paragraph::new(text)
.style(Style::default().fg(self.theme.dimmed))
.alignment(ratatui::layout::Alignment::Right)
.render(
Rect {
y: bottom - 1,
height: 1,
..area
},
buf,
);
}
}
fn render_notes_tab(&mut self, area: Rect, buf: &mut Buffer) {
let notes = self.state.notes();
if area.height == 0 || area.width <= 4 || notes.is_empty() {
return;
}
let selected = self.modal.notes_selected_index.min(notes.len() - 1);
let width = area.width as usize;
let blocks: Vec<Vec<NoteRow>> = notes
.iter()
.enumerate()
.map(|(index, note)| note_rows(note, index == selected, width))
.collect();
let heights: Vec<usize> = blocks.iter().map(Vec::len).collect();
let dim = Style::default().fg(self.theme.dimmed);
let full = area.height as usize;
let (first, last) = notes_window(&heights, selected, self.modal.notes_scroll_offset, full);
let all_shown = first == 0 && last == heights.len();
let offer = notes[selected]
.read_as_text
.as_ref()
.map(|column| format!("Enter read {column} as text"));
let reserve = (!all_shown || offer.is_some()) && heights[selected] < full;
let show = if reserve { full - 1 } else { full };
if heights[selected] > show {
let count = notes.len();
Paragraph::new(Line::from(Span::styled(
format!(
"{} {} {} selected: no room",
group_chrome(count),
if count == 1 { "note" } else { "notes" },
crate::glyphs::get().middot
),
dim,
)))
.render(Rect { height: 1, ..area }, buf);
return;
}
let (first, last) = if reserve {
notes_window(&heights, selected, self.modal.notes_scroll_offset, show)
} else {
(first, last)
};
self.modal.notes_scroll_offset = first;
let mut y = area.y;
let bottom = area.y + show as u16;
for (offset, block) in blocks[first..last].iter().enumerate() {
if offset > 0 && y < bottom {
y += 1;
}
for row in block {
if y >= bottom {
break;
}
let at = Rect {
y,
height: 1,
..area
};
if row.dim {
Paragraph::new(Line::from(Span::styled(row.text.clone(), dim))).render(at, buf);
} else {
Paragraph::new(row.text.as_str()).render(at, buf);
}
y += 1;
}
}
let (above, below) = (first, notes.len() - last);
let hidden = match (reserve, above, below) {
(false, _, _) | (_, 0, 0) => None,
(_, 0, n) => Some(format!("{} below", group_chrome(n))),
(_, n, 0) => Some(format!("{} above", group_chrome(n))),
(_, a, b) => Some(format!(
"{} above, {} below",
group_chrome(a),
group_chrome(b)
)),
};
if !reserve || (hidden.is_none() && offer.is_none()) {
return;
}
let last_row = Rect {
y: area.y + area.height - 1,
height: 1,
..area
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let taken = hidden
.as_ref()
.map(|text| (text.width() as u16).saturating_add(1))
.unwrap_or(0);
if let Some(offer) = offer.as_ref() {
let room = last_row.width.saturating_sub(taken) as usize;
let offer = if offer.width() > room {
let mark = crate::glyphs::get().ellipsis;
let mut kept = String::new();
for ch in offer.chars() {
if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
break;
}
kept.push(ch);
}
Some(format!("{kept}{mark}"))
} else {
Some(offer.clone())
};
if let Some(offer) = offer.filter(|_| room >= 8) {
Paragraph::new(Line::from(Span::styled(offer, dim))).render(
Rect {
width: room as u16,
..last_row
},
buf,
);
}
}
if let Some(hidden) = hidden {
Paragraph::new(Line::from(Span::styled(hidden, dim)))
.right_aligned()
.render(last_row, buf);
}
}
fn render_partitioned_data_tab(&self, area: Rect, buf: &mut Buffer) {
let y = area.y;
let w = area.width;
let Some(partition_columns) = self.state.partition_columns() else {
Paragraph::new("Partition columns: unknown").render(
Rect {
y,
width: w,
height: 1,
..area
},
buf,
);
return;
};
if partition_columns.is_empty() {
Paragraph::new("Partition columns: none").render(
Rect {
y,
width: w,
height: 1,
..area
},
buf,
);
return;
}
let line = format!("Partition columns: {}", partition_columns.join(", "));
Paragraph::new(line).render(
Rect {
y,
width: w,
height: 1,
..area
},
buf,
);
}
}
fn format_took(took: std::time::Duration) -> String {
let ms = took.as_secs_f64() * 1000.0;
if (ms * 100.0).round() < 100_000.0 {
format!("{ms:.2} ms")
} else {
format!("{:.2}s", took.as_secs_f64())
}
}
fn wire_line(wire: crate::loading::measurements::OverTheWire) -> String {
let mut line = format!(
", {} request{}",
format_int(wire.requests),
if wire.requests == 1 { "" } else { "s" }
);
if let Some(bytes) = wire.bytes {
line.push_str(&format!(", {}", crate::numfmt::bytes(bytes)));
}
line
}
fn measurement_line(
cost: &crate::loading::measurements::Cost,
unit: &str,
singular: &str,
) -> String {
let mut line = format_took(cost.took);
if let Some(files) = cost.files {
let unit = if files == 1 { singular } else { unit };
line.push_str(&format!(", {} {unit}", format_int(files)));
}
if let Some(wire) = cost.over_the_wire {
line.push_str(&wire_line(wire));
}
line
}
fn total_line(total: &crate::loading::measurements::Total) -> String {
let mut line = format_took(total.took);
if let Some(wire) = total.over_the_wire {
line.push_str(&wire_line(wire));
}
line
}
fn format_int(n: usize) -> String {
crate::numfmt::group_chrome(n)
}
fn columns_by_type(schema: &Schema) -> String {
let mut counts: HashMap<String, usize> = HashMap::new();
for (_, dtype) in schema.iter() {
let k = dtype.to_string();
*counts.entry(k).or_default() += 1;
}
let mut pairs: Vec<_> = counts.into_iter().collect();
pairs.sort_by(|a, b| a.0.cmp(&b.0));
pairs
.into_iter()
.map(|(k, v)| format!("{}: {}", k, v))
.collect::<Vec<_>>()
.join(&format!(" {} ", crate::glyphs::get().middot))
}
impl<'a> Widget for &mut DataTableInfo<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let ctx = self.theme;
let offered = TabsOffered {
documentation: self.documentation.is_some(),
..TabsOffered::of(self.state, self.ctx.facts_tab)
};
let tab = self.modal.active_tab;
let g = crate::glyphs::get();
let scrolls = match tab {
InfoTab::Schema => true,
InfoTab::Notes => offered.notes,
InfoTab::Metadata => offered.metadata,
InfoTab::Format => offered.format,
InfoTab::Documentation => offered.documentation,
_ => false,
};
let tables = tab == InfoTab::Format
&& offered.format
&& self
.state
.format_detail()
.is_some_and(|d| !d.tables.is_empty());
let mut footer = HintBar::from_ctx(ctx)
.screen(datui_cli::keys::Context::Info)
.key("← / →")
.weight(3);
if tables {
footer = footer.key("Enter").weight(2).key("↑ / ↓").weight(2);
} else if scrolls {
footer = footer.key_as("↑ / ↓", "Scroll").weight(2);
}
if tab == InfoTab::Documentation && offered.documentation {
footer = footer.key_as("Enter", "Values").weight(1);
if self
.documentation
.as_deref()
.is_some_and(|d| d.offers_open())
{
footer = footer.key("o").weight(1);
}
footer = footer.key("y").weight(1);
}
if tab == InfoTab::Schema && self.header_toggle {
footer = footer.key("H").weight(-1);
}
if self.hex {
footer = footer.key("x").weight(0);
}
let footer = footer.key("Esc").weight(4);
let surface = Surface::new("Info");
let surface = if area.height > 3 {
surface.footer(&footer)
} else {
surface
};
let content = surface.render(area, buf, ctx);
if content.height == 0 || content.width < 4 {
return;
}
let tab_rows = u16::from(content.height >= 4);
let gap = u16::from(content.height >= 6);
let tabs = InfoTab::visible(offered);
let active = tabs[tab.index(offered)];
let current = tab.index(offered);
let mut spans = Vec::new();
let mut clicks = Vec::new();
for (i, t) in tabs.iter().enumerate() {
let is_active = *t == active;
if i > 0 {
spans.push(Span::styled(
format!(" {}", g.rule),
Style::default().fg(ctx.dimmed),
));
}
spans.push(Span::raw(" "));
let style = if is_active {
Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ctx.text_secondary)
};
let title = match (t, self.state.format_detail()) {
(InfoTab::Format, Some(detail)) => detail.tab,
(InfoTab::Format, None) => self.ctx.facts_tab.unwrap_or(t.title()),
_ => t.title(),
};
clicks.push((
spans.len(),
crate::app::pointer::Hit::Option {
field: None,
index: i,
current,
},
));
spans.push(Span::styled(title, style));
}
let tab_area = Rect {
height: tab_rows,
..content
};
let line = Line::from(spans);
if tab_rows > 0 {
crate::app::pointer::record_spans(tab_area, &line, clicks);
}
Paragraph::new(line).render(tab_area, buf);
let body = Rect {
y: content.y + tab_rows + gap,
height: content.height - tab_rows - gap,
..content
};
match tab {
InfoTab::Schema => self.render_schema_tab(body, buf),
InfoTab::Resources => self.render_resources_tab(body, buf),
InfoTab::Metadata if offered.metadata => self.render_metadata_tab(body, buf),
InfoTab::Format if offered.format => self.render_format_tab(body, buf),
InfoTab::Partitions if offered.partitions => {
self.render_partitioned_data_tab(body, buf)
}
InfoTab::Notes if offered.notes => self.render_notes_tab(body, buf),
InfoTab::Documentation if offered.documentation => {
if let Some(page) = self.documentation.as_deref_mut() {
crate::widgets::documentation::render_page(page, body, buf, ctx);
}
}
InfoTab::Metadata
| InfoTab::Format
| InfoTab::Partitions
| InfoTab::Notes
| InfoTab::Documentation => self.render_schema_tab(body, buf),
}
}
}
#[cfg(test)]
mod tests;