use crate::discover::{self, Entry, EntryKind};
use crate::formats::MatchChip;
use crate::glyphs;
use crate::render::context::RenderContext;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph, Widget};
const META_WIDTH: u16 = 34;
const PREVIEW_WIDTH: u16 = 40;
const PREVIEW_MIN_WIDTH: u16 = META_MIN_WIDTH + PREVIEW_WIDTH + 6;
const META_MIN_WIDTH: u16 = 56;
fn truncate_start(text: &str, width: usize) -> String {
if glyphs::display_width(text) <= width {
return text.to_string();
}
let ellipsis = glyphs::get().ellipsis;
let ellipsis_width = glyphs::display_width(ellipsis);
if width <= ellipsis_width {
return glyphs::take_columns_end(text, width).to_string();
}
let tail = glyphs::take_columns_end(text, width - ellipsis_width);
format!("{ellipsis}{tail}")
}
fn meta_columns(entry: &Entry, unmeasured: bool, hint: Option<u64>, gone: Option<&str>) -> String {
let times = glyphs::get().times;
let more = if entry.cols_sampled { "+" } else { "" };
let shape = match (entry.rows, entry.cols) {
(Some(r), Some(c)) => format!("{} {times} {c}{more}", discover::format_rows(r)),
(None, Some(c)) if entry.kind == EntryKind::Directory => format!("{c}{more} cols"),
(None, Some(c)) => format!("? {times} {c}{more}"),
_ if unmeasured => glyphs::get().ellipsis.to_string(),
_ => String::new(),
};
let size = match (gone, entry.size, hint) {
(Some(gone), _, _) => gone.to_string(),
(None, Some(size), _) => discover::format_size(size),
(None, None, Some(hint)) => format!("~{}", discover::format_size(hint)),
(None, None, None) => String::new(),
};
let age = entry.modified.map(discover::format_age).unwrap_or_default();
format!("{shape:>13} {size:>9} {age:>4}")
}
pub fn render(area: Rect, buf: &mut Buffer, app: &mut crate::App, ctx: &RenderContext) {
Clear.render(area, buf);
let padded = Rect {
x: area.x.saturating_add(1),
y: area.y,
width: area.width.saturating_sub(2),
height: area.height,
};
if padded.width < 20 || padded.height < 5 {
return;
}
let wordmark = glyphs::get()
.wordmark
.filter(|_| padded.height >= WORDMARK_MIN_HEIGHT && padded.width >= WORDMARK_MIN_WIDTH);
let title_h = wordmark.map(|w| w.len() as u16).unwrap_or(1);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(title_h), Constraint::Length(1), Constraint::Fill(1), ])
.split(padded);
match wordmark {
Some(lines) => render_wordmark(rows[0], buf, app, ctx, lines),
None => render_title_bar(rows[0], buf, app, ctx),
}
render_prompt(rows[1], buf, app, ctx);
let show_preview = padded.width >= PREVIEW_MIN_WIDTH;
if show_preview {
let list_w = padded
.width
.saturating_sub(3 + PREVIEW_WIDTH)
.min(LIST_MAX_WIDTH);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(list_w),
Constraint::Length(3), Constraint::Fill(1),
])
.split(rows[2]);
render_list(body[0], buf, app, ctx);
render_rule(body[1], buf, ctx);
render_preview(body[2], buf, app, ctx, area.height);
} else {
let used = render_list(rows[2], buf, app, ctx);
render_rows_strip(rows[2], used, buf, app, ctx, area.height);
}
}
const LIST_MAX_WIDTH: u16 = 84;
const STRIP_MIN_HEIGHT: usize = 4;
fn render_rows_strip(
area: Rect,
used: usize,
buf: &mut Buffer,
app: &mut crate::App,
ctx: &RenderContext,
screen_height: u16,
) {
let free = (area.height as usize).saturating_sub(used);
if free < STRIP_MIN_HEIGHT || app.home.path_input_active {
return;
}
let Some(entry) = app.home.selected_entry() else {
return;
};
let Some(preview) = app.home_preview_rows(&entry, screen_height) else {
return;
};
let room = free.saturating_sub(1).max(STRIP_MIN_HEIGHT);
let lines = rows_block(&preview, area.width as usize, room, ctx);
let height = lines.len() as u16;
let strip = Rect {
x: area.x,
y: area.y + area.height - height,
width: area.width,
height,
};
Paragraph::new(lines).render(strip, buf);
}
fn rows_block(
preview: &crate::home_preview::PreviewRows,
width: usize,
room: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
const CELL_W: usize = 20;
const GAP: usize = 2;
let g = glyphs::get();
let null_w = glyphs::display_width(g.null);
let widths: Vec<usize> = preview
.columns
.iter()
.enumerate()
.map(|(i, (name, _))| {
preview
.rows
.iter()
.map(|row| match &row[i] {
Some(text) => glyphs::display_width(text),
None => null_w,
})
.chain([glyphs::display_width(name)])
.max()
.unwrap_or(1)
.clamp(1, CELL_W)
})
.collect();
let mut shown = 0;
let mut used = 0;
for w in &widths {
let need = if shown == 0 { *w } else { GAP + *w };
if used + need > width {
break;
}
used += need;
shown += 1;
}
let widths: Vec<usize> = if shown == 0 {
vec![width.max(1)]
} else {
widths[..shown].to_vec()
};
let shown = widths.len().min(preview.columns.len());
let mut lines = vec![rows_heading(shown, preview.total_columns, width, ctx)];
let cell = |text: &str, w: usize, right: bool| -> String {
let text = if glyphs::display_width(text) > w {
let cut =
glyphs::take_columns(text, w.saturating_sub(glyphs::display_width(g.ellipsis)));
format!("{cut}{}", g.ellipsis)
} else {
text.to_string()
};
let pad = w.saturating_sub(glyphs::display_width(&text));
if right {
format!("{}{text}", " ".repeat(pad))
} else {
format!("{text}{}", " ".repeat(pad))
}
};
let numeric: Vec<bool> = preview.columns[..shown]
.iter()
.map(|(_, dtype)| crate::numfmt::is_numeric_dtype(dtype))
.collect();
let mut header = Vec::new();
for (i, (name, dtype)) in preview.columns[..shown].iter().enumerate() {
if i > 0 {
header.push(Span::raw(" ".repeat(GAP)));
}
header.push(Span::styled(
cell(name, widths[i], numeric[i]),
Style::default().fg(ctx.type_color(dtype)),
));
}
lines.push(Line::from(header));
let rows_room = room.saturating_sub(lines.len());
for row in preview.rows.iter().take(rows_room) {
let mut spans = Vec::new();
for (i, value) in row[..shown].iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".repeat(GAP)));
}
spans.push(match value {
Some(text) => Span::styled(
cell(text, widths[i], numeric[i]),
Style::default().fg(ctx.text_primary),
),
None => Span::styled(
cell(g.null, widths[i], numeric[i]),
Style::default().fg(ctx.dimmed),
),
});
}
lines.push(Line::from(spans));
}
lines
}
fn rows_heading(shown: usize, total: usize, width: usize, ctx: &RenderContext) -> Line<'static> {
let note = (shown < total).then(|| format!("{shown} of {total} columns"));
pane_heading_counted("ROWS", note.as_deref(), width, ctx)
}
const WORDMARK_MIN_HEIGHT: u16 = 28;
const WORDMARK_MIN_WIDTH: u16 = 52;
fn mix(a: Color, b: Color, t: f32) -> Color {
match (a, b) {
(Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => {
let lerp =
|x: u8, y: u8| -> u8 { (x as f32 + (y as f32 - x as f32) * t).round() as u8 };
Color::Rgb(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2))
}
_ => a,
}
}
fn render_wordmark(
area: Rect,
buf: &mut Buffer,
app: &crate::App,
ctx: &RenderContext,
lines: &[&str],
) {
let location = app
.home
.browsing
.as_ref()
.map(|p| app.home.location_label(p))
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| crate::home::display_path(&p))
})
.unwrap_or_default();
let mark_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let start = ctx.gradient_start;
let end = ctx.gradient_end;
for (row, line) in lines.iter().enumerate() {
if row as u16 >= area.height {
break;
}
let mut spans: Vec<Span> = Vec::with_capacity(mark_w + 2);
spans.push(Span::raw(" "));
for (i, ch) in line.chars().enumerate() {
let t = if mark_w > 1 {
i as f32 / (mark_w - 1) as f32
} else {
0.0
};
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(mix(start, end, t))
.add_modifier(Modifier::BOLD),
));
}
if row == lines.len() / 2 {
let room = (area.width as usize).saturating_sub(mark_w + 4);
spans.push(Span::styled(
format!(" {}", truncate_start(&location, room)),
Style::default().fg(ctx.text_secondary),
));
}
Paragraph::new(Line::from(spans)).render(
Rect {
x: area.x,
y: area.y + row as u16,
width: area.width,
height: 1,
},
buf,
);
}
}
fn render_title_bar(area: Rect, buf: &mut Buffer, app: &crate::App, ctx: &RenderContext) {
let location = app
.home
.browsing
.as_ref()
.map(|p| app.home.location_label(p))
.or_else(|| {
std::env::current_dir()
.ok()
.map(|p| crate::home::display_path(&p))
})
.unwrap_or_default();
let bar = Style::default().bg(ctx.controls_bg);
let left = Span::styled(
" datui ",
bar.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
);
let location = truncate_start(&location, (area.width as usize).saturating_sub(12));
let pad = (area.width as usize).saturating_sub(8 + location.chars().count());
Paragraph::new(Line::from(vec![
left,
Span::styled(" ".repeat(pad), bar),
Span::styled(location, bar.fg(ctx.text_secondary)),
Span::styled(" ", bar),
]))
.render(area, buf);
}
fn render_prompt(area: Rect, buf: &mut Buffer, app: &crate::App, ctx: &RenderContext) {
let home = &app.home;
let g = glyphs::get();
let (glyph, value, hint) = if home.path_input_active {
(" ~ ", home.path_input.as_str(), "file or directory")
} else {
(g.prompt, home.filter.as_str(), "narrow and search")
};
let mut spans = vec![Span::styled(
glyph,
Style::default()
.fg(ctx.keybind_hints)
.add_modifier(Modifier::BOLD),
)];
let value_style = if !home.path_input_active && home.filter_selected {
app.theme.text_selection_style()
} else {
Style::default().fg(ctx.text_primary)
};
spans.push(Span::styled(value, value_style));
spans.push(Span::styled(
g.cursor,
Style::default().fg(ctx.keybind_hints),
));
if value.is_empty() {
spans.push(Span::styled(
format!(" {hint}"),
Style::default().fg(ctx.dimmed),
));
}
if let Some(status) = &home.status {
let used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
let room = (area.width as usize).saturating_sub(used + 3);
spans.push(Span::styled(
format!(" {}", truncate_start(status, room)),
Style::default().fg(ctx.warning),
));
}
Paragraph::new(Line::from(spans)).render(area, buf);
}
fn render_rule(area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
if area.width == 0 {
return;
}
let x = area.x + area.width / 2;
for y in area.y..area.y.saturating_add(area.height) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(glyphs::get().rule);
cell.set_fg(ctx.column_separator);
}
}
}
fn render_list(area: Rect, buf: &mut Buffer, app: &mut crate::App, ctx: &RenderContext) -> usize {
if app.home.path_input_active {
return render_path_list(area, buf, app, ctx);
}
let height = area.height as usize;
let spaced = height >= SPACED_LIST_HEIGHT;
let headers = app
.home
.visible()
.iter()
.filter(|row| matches!(row, crate::home::Row::Header { .. }))
.count();
let spacers = if spaced { headers.saturating_sub(1) } else { 0 };
app.home.set_view_height(height.saturating_sub(spacers));
let row_lines: Vec<usize> = {
let mut line = 0;
app.home
.visible()
.iter()
.enumerate()
.map(|(i, row)| {
if spaced && i > 0 && matches!(row, crate::home::Row::Header { .. }) {
line += 1;
}
let at = line;
line += 1;
at
})
.collect()
};
let total = row_lines.last().map_or(0, |line| line + 1);
let first_line = crate::home::settle_top(
row_lines.get(app.home.scroll).copied().unwrap_or(total),
row_lines.get(app.home.selected).copied().unwrap_or(0),
height,
total,
);
app.home.scroll = row_lines.partition_point(|line| *line < first_line);
let first_line = row_lines
.get(app.home.scroll)
.copied()
.unwrap_or(first_line);
let lines_drawn = (0..height)
.map(|dy| row_lines.binary_search(&(first_line + dy)).ok())
.collect();
app.pointer.home_list_drawn(area, lines_drawn);
app.home.pending_enrich = !app.home.unmeasured_visible(1).is_empty();
app.home.pending_classify = !app.home.unclassified_visible(1).is_empty();
app.home.pending_peek = !app.home.cloud_directories_to_peek(1).is_empty();
let awaiting = app.home.awaiting_listing().map(|d| d.to_path_buf());
let since = match awaiting {
Some(_) => Some(
*app.home
.waiting_since
.get_or_insert_with(std::time::Instant::now),
),
None => {
app.home.waiting_since = None;
None
}
};
let visible = app.home.visible();
if let (Some(dir), Some(since), true) = (&awaiting, since, visible.is_empty()) {
let spinner = glyphs::get().spinner;
let mut spans = vec![
Span::styled(
spinner[app.throbber_frame as usize % spinner.len()],
Style::default().fg(ctx.accent),
),
Span::raw(" "),
Span::styled(
format!("Listing {}", crate::home::display_path(dir)),
Style::default().fg(ctx.text_secondary),
),
];
let secs = since.elapsed().as_secs();
if secs >= 1 {
spans.push(Span::styled(
format!(" {secs}s"),
Style::default().fg(ctx.dimmed),
));
}
Paragraph::new(Line::from(spans)).render(area, buf);
return area.height as usize;
}
if app.home.listing_in_flight && visible.is_empty() {
Paragraph::new(Line::from(Span::styled(
"Looking...",
Style::default().fg(ctx.dimmed),
)))
.render(area, buf);
return area.height as usize;
}
if visible.is_empty() && !app.home.filter.is_empty() {
let text = match &app.home.browsing {
Some(dir) => format!("No match under {}.", crate::home::display_path(dir)),
None => "No match.".to_string(),
};
Paragraph::new(Line::from(Span::styled(
text,
Style::default().fg(ctx.dimmed),
)))
.render(area, buf);
return area.height as usize;
}
let has_dataset = app.home.has_any_dataset();
let guidance = if has_dataset || !app.home.filter.is_empty() || !app.home.cloud.is_empty() {
Vec::new()
} else {
vec![
Line::from(""),
Line::from(Span::styled(
"No datasets here.",
Style::default().fg(ctx.text_secondary),
)),
Line::from(""),
Line::from(vec![
Span::styled(" ~ ", Style::default().fg(ctx.keybind_hints)),
Span::styled("type a path", Style::default().fg(ctx.dimmed)),
]),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
"or list datasets in catalog.toml",
Style::default().fg(ctx.dimmed),
),
]),
]
};
let show_meta = area.width >= META_MIN_WIDTH;
let name_width = if show_meta {
area.width.saturating_sub(META_WIDTH) as usize
} else {
area.width as usize
};
let known_sources: Vec<String> = app
.app_config
.cloud
.connections
.iter()
.map(|s| s.name.clone())
.collect();
let list = ListDraw {
ctx,
width: area.width as usize,
name_width,
show_meta,
filter: &app.home.filter,
frame: app.throbber_frame as usize,
};
let mut lines: Vec<Line> = Vec::new();
let last_line = first_line + height;
for (idx, row) in visible.iter().enumerate() {
let at = row_lines.get(idx).copied().unwrap_or(usize::MAX);
if at >= last_line {
break;
}
let spacer = spaced && idx > 0 && matches!(row, crate::home::Row::Header { .. });
if spacer && at > first_line {
lines.push(Line::from(""));
}
if at < first_line {
continue;
}
let selected = idx == app.home.selected;
match row {
crate::home::Row::Header {
section,
matches,
collapsed,
} => {
lines.push(section_header(
&app.home.sections[*section],
*matches,
*collapsed,
selected,
&list,
));
}
crate::home::Row::Entry { entry, .. }
if crate::home::cloud_source_id(&entry.path).is_some() =>
{
let source = app.home.cloud_source_of(&entry.path);
lines.push(source_line(
entry,
source,
selected,
area.width as usize,
app.throbber_frame as usize,
&app.home.filter,
ctx,
));
}
crate::home::Row::Entry { entry, nested, .. } => {
let via = if crate::home::fuzzy_score(&app.home.filter, &entry.name).is_some() {
None
} else {
crate::home::matching_column(&app.home.filter, entry)
};
lines.push(entry_line(
entry,
selected,
EntryNotes {
matched_column: via,
known_sources: app
.home
.browsing
.is_none()
.then_some(known_sources.as_slice()),
place_kind: app.home.place_kind(&entry.path),
looking: looking_glyph(app, entry),
indent: if *nested { NEST_INDENT } else { 0 },
size_hint: app.home.size_hint(&entry.path),
gone: app.home.web_gone.get(&entry.path).map(|g| g.cell.as_str()),
},
&list,
));
}
crate::home::Row::Door { entry, .. } => {
lines.push(entry_line(
entry,
selected,
EntryNotes {
place_kind: app.home.place_kind(&entry.path),
looking: looking_glyph(app, entry),
..EntryNotes::default()
},
&list,
));
}
crate::home::Row::Place {
path,
label,
source,
..
} => {
lines.push(place_line(
path,
label.as_deref(),
source.as_deref(),
selected,
name_width,
show_meta,
ctx,
));
}
crate::home::Row::More { hidden, places, .. } => {
lines.push(more_line(
*hidden, *places, selected, name_width, show_meta, ctx,
));
}
crate::home::Row::Hidden { section, count } => {
let tables = holds_tables(&app.home.sections[*section]);
lines.push(hidden_line(
*count, tables, selected, name_width, show_meta, ctx,
));
}
}
}
let mut body: Vec<Line> = lines;
body.extend(guidance);
let used = body.len();
Paragraph::new(body).render(area, buf);
used
}
fn render_path_list(
area: Rect,
buf: &mut Buffer,
app: &mut crate::App,
ctx: &RenderContext,
) -> usize {
let g = glyphs::get();
let height = area.height as usize;
let width = area.width as usize;
app.pointer.home_list_drawn(area, vec![None; height]);
let home = &app.home;
let dir = crate::home::typed_dir(&home.path_input);
let segment = &home.path_input[dir.len()..];
let listing = home.path_listing.as_ref().filter(|l| l.dir == dir);
let candidates = home.path_candidates();
let shown_dir = if dir.is_empty() { "./" } else { dir };
let count = format!("{}", candidates.len());
let rule_w = width.saturating_sub(shown_dir.chars().count() + count.chars().count() + 6);
let mut lines = vec![Line::from(vec![
Span::styled(g.expanded, Style::default().fg(ctx.accent)),
Span::styled(
truncate_start(shown_dir, width.saturating_sub(count.len() + 8)),
Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {count} "), Style::default().fg(ctx.dimmed)),
Span::styled(
g.rule_h.repeat(rule_w),
Style::default().fg(ctx.column_separator),
),
])];
let note = match listing {
None => Some(format!("Listing {shown_dir}...")),
Some(l) if l.failed => Some(format!("Nothing to list at {shown_dir}")),
Some(_) if candidates.is_empty() && !segment.is_empty() => {
Some(format!("No name here starts like {segment}"))
}
Some(_) if candidates.is_empty() => Some("Nothing here".to_string()),
Some(_) => None,
};
if let Some(note) = note {
lines.push(Line::from(Span::styled(
format!(" {note}"),
Style::default().fg(ctx.dimmed),
)));
}
let room = height.saturating_sub(lines.len());
let pick = home.path_pick;
let first = match pick {
Some(p) if p >= room.saturating_sub(1) => p + 2 - room.max(1),
_ => 0,
};
let hidden_after = candidates.len().saturating_sub(first + room);
let take = if hidden_after > 0 {
room.saturating_sub(1)
} else {
room
};
for (i, name) in candidates.iter().enumerate().skip(first).take(take) {
let selected = pick == Some(i);
let base = if selected {
ctx.highlight_style()
} else {
Style::default()
};
let marker = if selected {
g.selector
} else {
g.selector_blank
};
let mut text = name.name.clone();
if name.dir {
text.push('/');
}
let positions = crate::home::fuzzy_positions(segment, &name.name);
let name_style = if selected {
base.fg(ctx.text_primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ctx.text_primary)
};
let hit_style = base
.fg(ctx.keybind_hints)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let mut spans = vec![Span::styled(
marker,
base.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
)];
spans.extend(highlight_spans(&text, &positions, name_style, hit_style));
let used = glyphs::display_width(marker) + glyphs::display_width(&text);
spans.push(Span::styled(" ".repeat(width.saturating_sub(used)), base));
lines.push(Line::from(spans));
}
if hidden_after > 0 {
lines.push(Line::from(Span::styled(
format!(" {} {hidden_after} more", g.ellipsis),
Style::default().fg(ctx.dimmed),
)));
}
let used = lines.len();
Paragraph::new(lines).render(area, buf);
used
}
const NEST_INDENT: usize = 2;
const SPACED_LIST_HEIGHT: usize = 30;
const META_COLUMNS_WIDTH: usize = 13 + 2 + 9 + 2 + 4;
const SHAPE_COLUMNS_WIDTH: usize = 13 + 2;
fn row_width(name_width: usize, show_meta: bool) -> usize {
let marker = glyphs::get().selector_blank.chars().count();
marker + name_width + if show_meta { META_COLUMNS_WIDTH } else { 0 } + 1
}
fn place_line(
path: &std::path::Path,
label: Option<&str>,
source: Option<&str>,
selected: bool,
name_width: usize,
show_meta: bool,
ctx: &RenderContext,
) -> Line<'static> {
let g = glyphs::get();
let width = row_width(name_width, show_meta);
let marker = if selected {
g.selector
} else {
g.selector_blank
};
let base = if selected {
ctx.highlight_style()
} else {
Style::default()
};
let source = source.unwrap_or("").to_string();
let locality = crate::locality::Locality::of_fstype(&source);
let source = match locality {
crate::locality::Locality::Object
| crate::locality::Locality::Network
| crate::locality::Locality::Memory => source,
_ => String::new(),
};
let source_style = base.fg(match locality {
crate::locality::Locality::Object => ctx.keybind_hints,
crate::locality::Locality::Network => ctx.warning,
crate::locality::Locality::Memory => ctx.temporal_col,
_ => ctx.dimmed,
});
let mut name = crate::home::display_path(path);
if !name.ends_with('/')
&& !crate::discover::data_format(path).is_some_and(crate::FileFormat::holds_tables)
{
name.push('/');
}
let label = label.map(|l| format!(" {l}")).unwrap_or_default();
let fixed = marker.chars().count() + source.chars().count() + 1;
let room = width.saturating_sub(fixed + 1).max(1);
let label = if name.chars().count() + label.chars().count() <= room {
label
} else {
String::new()
};
let name = truncate_start(&name, room.saturating_sub(label.chars().count()).max(1));
let pad = width.saturating_sub(fixed + name.chars().count() + label.chars().count());
let name_style = if selected {
base.fg(ctx.text_secondary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ctx.text_secondary)
};
Line::from(vec![
Span::styled(
marker,
base.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
),
Span::styled(name, name_style),
Span::styled(label, base.fg(ctx.dimmed)),
Span::styled(" ".repeat(pad), base),
Span::styled(source, source_style),
Span::styled(" ", base),
])
}
fn more_line(
hidden: usize,
places: usize,
selected: bool,
name_width: usize,
show_meta: bool,
ctx: &RenderContext,
) -> Line<'static> {
let text = format!(
"{} {hidden} more in {places} {}",
glyphs::get().ellipsis,
if places == 1 { "place" } else { "places" }
);
note_row(text, selected, name_width, show_meta, ctx)
}
fn hidden_line(
count: usize,
tables: bool,
selected: bool,
name_width: usize,
show_meta: bool,
ctx: &RenderContext,
) -> Line<'static> {
let ellipsis = glyphs::get().ellipsis;
let text = match (tables, count) {
(true, 1) => format!("{ellipsis} 1 internal table"),
(true, _) => format!("{ellipsis} {count} internal tables"),
(false, 1) => format!("{ellipsis} 1 file with no reader"),
(false, _) => format!("{ellipsis} {count} files with no reader"),
};
note_row(text, selected, name_width, show_meta, ctx)
}
fn holds_tables(section: &crate::home::Section) -> bool {
section.rows.iter().any(|row| row.table.is_some())
}
fn note_row(
text: String,
selected: bool,
name_width: usize,
show_meta: bool,
ctx: &RenderContext,
) -> Line<'static> {
let g = glyphs::get();
let width = row_width(name_width, show_meta);
let marker = if selected {
g.selector
} else {
g.selector_blank
};
let base = if selected {
ctx.highlight_style()
} else {
Style::default()
};
let pad = width.saturating_sub(marker.chars().count() + text.chars().count() + 1);
Line::from(vec![
Span::styled(
marker,
base.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
),
Span::styled(text, base.fg(ctx.dimmed)),
Span::styled(" ".repeat(pad), base),
Span::styled(" ", base),
])
}
#[derive(Clone, Copy)]
struct ListDraw<'f> {
ctx: &'f RenderContext,
width: usize,
name_width: usize,
show_meta: bool,
filter: &'f str,
frame: usize,
}
#[derive(Default)]
struct EntryNotes<'a> {
matched_column: Option<&'a str>,
known_sources: Option<&'a [String]>,
place_kind: Option<&'static str>,
looking: Option<&'static str>,
indent: usize,
size_hint: Option<u64>,
gone: Option<&'a str>,
}
fn section_header<'a>(
section: &'a crate::home::Section,
matches: usize,
collapsed: bool,
selected: bool,
list: &ListDraw,
) -> Line<'a> {
let ListDraw {
ctx, width, frame, ..
} = *list;
let g = glyphs::get();
let note = if section.unavailable {
section
.unavailable_note
.clone()
.unwrap_or_else(|| "unavailable".to_string())
} else if section.waiting {
match §ion.subtitle {
Some(subtitle) => format!("listing {} {subtitle}", g.middot),
None => "listing".to_string(),
}
} else if matches == 0
&& section.door.is_none()
&& section.origin == Some(crate::home::RootOrigin::Cwd.note())
{
format!("nothing to open here {} ~ types a path", g.middot)
} else {
section.subtitle.clone().unwrap_or_default()
};
let note_room = if section.title == crate::home::HomeState::SEARCH_SECTION {
width.saturating_sub(section.title.chars().count() + 16)
} else {
width / 2
};
let note = if note.starts_with(g.warning) {
glyphs::fit_cells(¬e, note_room, g.ellipsis).into_owned()
} else {
truncate_start(¬e, note_room)
};
let is_path = section.title.starts_with('/')
|| section.title.starts_with('~')
|| section.title.contains("://")
|| std::path::Path::new(§ion.title).is_absolute();
let mut title = if is_path {
section.title.clone()
} else {
section.title.to_uppercase()
};
let marker = if collapsed { g.collapsed } else { g.expanded };
let chip = if section.waiting {
let spinner = g.spinner;
format!(" {} ", spinner[frame % spinner.len()])
} else {
format!(" {matches} ")
};
let origin = section
.origin
.map(|origin| format!(" {origin} "))
.unwrap_or_default();
let origin_cells = if origin.is_empty() {
0
} else {
origin.chars().count() + 1
};
const MIN_RULE: usize = 1;
let mut note = note;
let mut origin = origin;
let mut origin_cells = origin_cells;
let mut fixed = marker.chars().count()
+ 1
+ chip.chars().count()
+ 1
+ origin_cells
+ note.chars().count()
+ 2
+ MIN_RULE;
if width.saturating_sub(fixed) < 3 {
note = String::new();
fixed = marker.chars().count() + 1 + chip.chars().count() + 1 + origin_cells + 2 + MIN_RULE;
}
if width.saturating_sub(fixed) < 3 {
origin = String::new();
origin_cells = 0;
fixed = marker.chars().count() + 1 + chip.chars().count() + 1 + 2 + MIN_RULE;
}
title = truncate_start(&title, width.saturating_sub(fixed));
let rule_w = width.saturating_sub(fixed + title.chars().count()) + MIN_RULE;
let title_style = if selected {
Style::default()
.fg(ctx.accent_bright)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
};
let chip_style = Style::default().bg(ctx.controls_bg).fg(ctx.text_primary);
let origin_style = Style::default()
.bg(ctx.controls_bg)
.fg(ctx.accent)
.add_modifier(Modifier::BOLD);
let rule_glyph = if selected { g.rule_h_focused } else { g.rule_h };
let rule_style = Style::default().fg(if selected {
ctx.accent
} else {
ctx.column_separator
});
let mut spans = vec![
Span::styled(marker, Style::default().fg(ctx.accent)),
Span::styled(title, title_style),
Span::raw(" "),
Span::styled(chip, chip_style),
Span::raw(" "),
];
if origin_cells > 0 {
spans.push(Span::styled(origin, origin_style));
spans.push(Span::raw(" "));
}
spans.extend([
Span::styled(rule_glyph.repeat(rule_w), rule_style),
Span::raw(" "),
Span::styled(
note,
Style::default().fg(if section.unavailable {
ctx.warning
} else {
ctx.text_secondary
}),
),
Span::raw(" "),
]);
Line::from(spans)
}
fn highlight_spans(
text: &str,
positions: &[usize],
plain: Style,
hit: Style,
) -> Vec<Span<'static>> {
if positions.is_empty() {
return vec![Span::styled(text.to_string(), plain)];
}
let marked: std::collections::HashSet<usize> = positions.iter().copied().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut run = String::new();
let mut run_is_hit = false;
for (i, ch) in text.chars().enumerate() {
let is_hit = marked.contains(&i);
if !run.is_empty() && is_hit != run_is_hit {
spans.push(Span::styled(
std::mem::take(&mut run),
if run_is_hit { hit } else { plain },
));
}
run_is_hit = is_hit;
run.push(ch);
}
if !run.is_empty() {
spans.push(Span::styled(run, if run_is_hit { hit } else { plain }));
}
spans
}
fn source_line<'a>(
entry: &'a Entry,
source: Option<&crate::home::CloudSource>,
selected: bool,
width: usize,
frame: usize,
filter: &str,
ctx: &RenderContext,
) -> Line<'a> {
let g = glyphs::get();
let base = if selected {
ctx.highlight_style()
} else {
Style::default()
};
let marker = if selected {
g.selector
} else {
g.selector_blank
};
let name_style = if selected {
base.fg(ctx.text_primary).add_modifier(Modifier::BOLD)
} else {
base.fg(ctx.text_primary)
};
let hit_style = base
.fg(ctx.keybind_hints)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let api = source.map(|s| s.api.as_str()).unwrap_or("");
let count = match source {
Some(s) if s.busy() => {
let spinner = g.spinner[frame % g.spinner.len()];
match s.count_text() {
text if text.is_empty() => spinner.to_string(),
text => format!("{text} {spinner}"),
}
}
Some(s) => s.count_text(),
None => String::new(),
};
let failed = source.is_some_and(|s| s.failed());
let note = source.map(|s| s.note.clone()).unwrap_or_default();
const API_W: usize = 7;
const COUNT_W: usize = 14;
let place = format!("{} ", g.in_object_store);
let fixed = marker.chars().count() + place.chars().count() + API_W + COUNT_W + 3;
let name_w = entry
.name
.chars()
.count()
.min((width / 3).max(12))
.max(16)
.min(width.saturating_sub(fixed));
let mut name = entry.name.clone();
let mut positions = crate::home::fuzzy_positions(filter, &name);
if name.chars().count() > name_w && name_w > 1 {
let kept = name_w - 1;
name = name.chars().take(kept).collect::<String>() + g.ellipsis;
positions.retain(|p| *p < kept);
}
let name_pad = name_w.saturating_sub(name.chars().count());
let note_w = width.saturating_sub(fixed + name_w);
let note = if note.chars().count() > note_w && note_w > 1 {
note.chars().take(note_w - 1).collect::<String>() + g.ellipsis
} else {
note
};
let mut spans = vec![
Span::styled(
marker,
base.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
),
Span::styled(place, base.fg(ctx.keybind_hints)),
];
spans.extend(highlight_spans(&name, &positions, name_style, hit_style));
spans.push(Span::styled(" ".repeat(name_pad + 1), base));
spans.push(Span::styled(format!("{api:<API_W$}"), base.fg(ctx.dimmed)));
let count_style = if failed {
base.fg(ctx.warning)
} else {
base.fg(ctx.text_secondary)
};
let count = truncate_start(&count, COUNT_W - 1);
spans.push(Span::styled(format!("{count:<COUNT_W$}"), count_style));
spans.push(Span::styled(" ".to_string(), base));
let note_pad = note_w.saturating_sub(note.chars().count());
spans.push(Span::styled(note, base.fg(ctx.dimmed)));
spans.push(Span::styled(" ".repeat(note_pad + 1), base));
Line::from(spans)
}
fn entry_line<'a>(
entry: &'a Entry,
selected: bool,
notes: EntryNotes<'a>,
list: &ListDraw,
) -> Line<'a> {
let EntryNotes {
matched_column,
known_sources,
place_kind,
looking,
indent,
size_hint,
gone,
} = notes;
let ListDraw {
ctx,
name_width,
show_meta,
filter,
..
} = *list;
let g = glyphs::get();
let marker = if selected {
g.selector
} else {
g.selector_blank
};
let name_width = name_width.saturating_sub(indent);
let mut name = entry.name.clone();
if shows_as_a_place(entry) && place_kind != Some("missing") {
name.push('/');
}
let path_text = entry.path.to_string_lossy();
let named_source = crate::source::split_source_id(&path_text).0;
let described = entry.label();
let mut shows_curated = false;
let kind = match entry.kind {
EntryKind::Directory => match (place_kind, entry.holds.formats.is_empty()) {
(Some(curated), _) => {
shows_curated = true;
curated
}
(None, false) => described.as_ref(),
(None, true) => crate::home::object_place_label(&entry.path)
.or(looking)
.unwrap_or(described.as_ref()),
},
EntryKind::Unknown if place_kind.is_some() => {
shows_curated = true;
place_kind.unwrap_or_default()
}
_ => described.as_ref(),
};
let missing_source = named_source
.is_some_and(|id| known_sources.is_some_and(|known| !known.iter().any(|k| k == id)));
let missing_note = named_source.map(|id| format!("source not found: {id}"));
let mut shows_source = true;
let kind: &str = match (named_source, kind, known_sources) {
(Some(_), "", Some(_)) if missing_source => missing_note.as_deref().unwrap_or(""),
(Some(id), "", Some(_)) => id,
_ => {
shows_source = false;
kind
}
};
let (kind, shows_curated, shows_source) = if entry.opens_whole_directory {
("", false, false)
} else {
(kind, shows_curated, shows_source)
};
let kind = if kind.is_empty() && entry.kind == EntryKind::Unknown {
g.ellipsis
} else {
kind
};
let kind_is_chip = matched_column.is_none()
&& !kind.is_empty()
&& matches!(entry.kind, EntryKind::Hive | EntryKind::MultiFile);
let kind_cell = match matched_column {
Some(column) => format!(" {}{column}", g.middot),
None if kind.is_empty() => String::new(),
None if kind_is_chip => format!(" {kind} "),
None => format!(" {kind}"),
};
let locality = entry
.cost
.source
.as_deref()
.map(crate::locality::Locality::of_fstype);
let place = match locality {
Some(crate::locality::Locality::Object) => g.in_object_store,
Some(crate::locality::Locality::Network) => g.over_network,
Some(crate::locality::Locality::Memory) => g.in_memory,
Some(crate::locality::Locality::Local) => g.here,
Some(crate::locality::Locality::Unknown) | None => g.place_unknown,
};
let place_cell = format!("{place} ");
let unmeasured = indent > 0
&& place_kind.is_none()
&& entry.rows.is_none()
&& entry.cols.is_none()
&& entry.kind != EntryKind::Unknown
&& matches!(
locality,
Some(crate::locality::Locality::Object | crate::locality::Locality::Network)
);
let meta = show_meta.then(|| meta_columns(entry, unmeasured, size_hint, gone));
let (name_width, meta) = match meta {
Some(meta) if meta.trim().is_empty() => (name_width + META_COLUMNS_WIDTH, None),
Some(meta) if meta.chars().take(SHAPE_COLUMNS_WIDTH).all(|c| c == ' ') => (
name_width + SHAPE_COLUMNS_WIDTH,
Some(meta.chars().skip(SHAPE_COLUMNS_WIDTH).collect()),
),
meta => (name_width, meta),
};
let is_label = matched_column.is_none() && !shows_source && !shows_curated;
let fits = |cell: &str| {
name_width.saturating_sub(2 + place_cell.chars().count() + cell.chars().count() + 1) > 1
};
let ellipsis = glyphs::get().ellipsis;
let ellipsis_len = ellipsis.chars().count();
let shown_column: Option<(String, usize)> = matched_column.map(|column| {
let room = name_width.saturating_sub(2 + place_cell.chars().count() + 1 + 2 + 2);
let whole = column.chars().count();
match room.checked_sub(ellipsis_len) {
Some(kept) if kept > 0 && whole > room => (
column.chars().take(kept).collect::<String>() + ellipsis,
kept,
),
_ => (column.to_string(), whole),
}
});
let kind_cell = match &shown_column {
Some((column, _)) => format!(" {}{column}", g.middot),
None => kind_cell,
};
let (kind_cell, kind_is_chip) = if fits(&kind_cell) {
(kind_cell, kind_is_chip)
} else if is_label {
(String::new(), false)
} else if shows_source && matched_column.is_none() {
let room = name_width.saturating_sub(2 + place_cell.chars().count() + 1 + 2);
(crate::discover::shorten(&kind_cell, room), false)
} else {
(kind_cell, kind_is_chip)
};
let kind_cell = match discover::how_read(entry).and_then(read_marker) {
Some(marker) if matched_column.is_none() && kind_cell.is_empty() => {
let cell = format!(" {marker}");
let room = name_width.saturating_sub(2 + place_cell.chars().count() + 1);
if name.chars().count() + cell.chars().count() <= room {
cell
} else {
kind_cell
}
}
_ => kind_cell,
};
let mut name_positions = if matched_column.is_none() {
crate::home::fuzzy_positions(filter, &name)
} else {
Vec::new()
};
let budget =
name_width.saturating_sub(2 + place_cell.chars().count() + kind_cell.chars().count() + 1);
let door_cut = name
.rfind(" (")
.filter(|_| entry.opens_whole_directory)
.map(|at| name.split_at(at))
.map(|(base, what)| (base.to_string(), what.to_string()));
if name.chars().count() > budget
&& budget > ellipsis_len
&& let Some((base, what)) = door_cut
{
name = if budget > what.chars().count() + ellipsis_len {
let room = budget - what.chars().count() - ellipsis_len;
let kept: String = base.chars().take(room).collect();
format!("{kept}{}{what}", g.ellipsis)
} else {
let what = what.trim_start();
if what.chars().count() <= budget {
what.to_string()
} else {
let kept: String = what.chars().take(budget - ellipsis_len).collect();
format!("{kept}{}", g.ellipsis)
}
};
name_positions.clear();
} else if name.chars().count() > budget && budget > 1 {
let original_len = name.chars().count();
if name.starts_with('/')
|| name.starts_with('~')
|| name.trim_end_matches('/').contains('/')
{
name = truncate_start(&name, budget);
let kept = name.chars().count();
let ellipsis = g.ellipsis.chars().count();
let dropped = original_len + ellipsis - kept;
name_positions.retain(|p| *p >= dropped);
for p in &mut name_positions {
*p = *p - dropped + ellipsis;
}
} else {
let kept = budget - 1;
name = name.chars().take(kept).collect::<String>() + g.ellipsis;
name_positions.retain(|p| *p < kept);
}
}
let pad = name_width.saturating_sub(2 + name.chars().count() + kind_cell.chars().count());
let base = if selected {
ctx.highlight_style()
} else {
Style::default()
};
let name_fg = if entry.kind == EntryKind::Other {
ctx.dimmed
} else {
ctx.text_primary
};
let name_style = if selected {
base.fg(name_fg).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(name_fg)
};
let kind_style = if matched_column.is_some() {
base.fg(ctx.keybind_hints)
} else if missing_source {
base.fg(ctx.warning)
} else {
match entry.kind {
EntryKind::Hive | EntryKind::MultiFile => Style::default()
.bg(ctx.controls_bg)
.fg(ctx.accent)
.add_modifier(Modifier::BOLD),
_ => base.fg(ctx.dimmed),
}
};
let hit_style = base
.fg(ctx.keybind_hints)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
let place_style = base.fg(match locality {
Some(crate::locality::Locality::Object) => ctx.keybind_hints,
Some(crate::locality::Locality::Network) => ctx.warning,
Some(crate::locality::Locality::Memory) => ctx.temporal_col,
_ => ctx.dimmed,
});
let mut spans = vec![
Span::styled(
marker,
base.fg(ctx.keybind_hints).add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(indent), base),
Span::styled(place_cell, place_style),
];
spans.extend(highlight_spans(
&name,
&name_positions,
name_style,
hit_style,
));
match matched_column {
Some(column) => {
spans.push(Span::styled(format!(" {}", g.middot), kind_style));
let (shown, kept) = match &shown_column {
Some((shown, kept)) => (shown.as_str(), *kept),
None => (column, column.chars().count()),
};
let mut positions = crate::home::substring_positions(filter, column);
positions.retain(|p| *p < kept);
spans.extend(highlight_spans(shown, &positions, kind_style, hit_style));
}
None if kind_is_chip => {
spans.push(Span::styled(" ".to_string(), base));
let chip = kind_cell.strip_prefix(' ').unwrap_or(&kind_cell);
spans.push(Span::styled(chip.to_string(), kind_style));
}
None => spans.push(Span::styled(kind_cell.clone(), kind_style)),
}
match meta {
Some(meta) => {
spans.push(Span::styled(" ".repeat(pad), base));
spans.push(Span::styled(meta, base.fg(ctx.dimmed)));
}
None if show_meta => spans.push(Span::styled(" ".repeat(pad), base)),
None => {}
}
spans.push(Span::styled(" ", base));
Line::from(spans)
}
fn read_marker(how: discover::HowRead) -> Option<&'static str> {
if how.download {
return Some("downloads");
}
how.mode.marker()
}
fn read_words(how: discover::HowRead) -> String {
let mode = how.mode.label();
if how.download {
format!("download {} {mode}", glyphs::get().arrow_right)
} else {
mode.to_string()
}
}
fn pane_heading(text: &str, width: usize, ctx: &RenderContext) -> Line<'static> {
pane_heading_counted(text, None, width, ctx)
}
fn pane_heading_counted(
text: &str,
chip: Option<&str>,
width: usize,
ctx: &RenderContext,
) -> Line<'static> {
let g = glyphs::get();
let label = text.to_string();
let mut used = label.chars().count() + 2;
let mut spans = vec![
Span::styled(
label,
Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
];
if let Some(chip) = chip {
let chip = format!(" {chip} ");
used += chip.chars().count() + 1;
spans.push(Span::styled(
chip,
Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
g.rule_h.repeat(width.saturating_sub(used)),
Style::default().fg(ctx.column_separator),
));
Line::from(spans)
}
const CODEBOOK_ROWS: usize = 12;
fn column_notes_block(
columns: &[(String, crate::catalog::ColumnNote)],
width: usize,
room: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(""), pane_heading("COLUMNS", width, ctx)];
let key_w = key_column(columns.iter().map(|(name, _)| name.as_str())).min(22);
let style = Style::default().fg(ctx.text_secondary);
let about = |note: &crate::catalog::ColumnNote| match note.about() {
about if about.is_empty() && !note.values.is_empty() => {
format!("{} values", note.values.len())
}
about => about,
};
let wrapped: Vec<Line<'static>> = columns
.iter()
.flat_map(|(name, note)| fact_lines(name, about(note), key_w, width, style, ctx))
.collect();
if lines.len() + wrapped.len() <= room {
lines.extend(wrapped);
return lines;
}
let value_w = width.saturating_sub(key_w + 2);
let fits = room.saturating_sub(lines.len() + 1).clamp(1, CODEBOOK_ROWS);
for (name, note) in columns.iter().take(fits) {
let name = glyphs::fit_cells(name, key_w, glyphs::get().ellipsis);
lines.push(Line::from(vec![
Span::styled(format!("{name:<key_w$} "), Style::default().fg(ctx.dimmed)),
Span::styled(
glyphs::fit_cells(&about(note), value_w, glyphs::get().ellipsis).into_owned(),
style,
),
]));
}
if columns.len() > fits {
lines.push(Line::from(Span::styled(
format!("{} {} more", glyphs::get().ellipsis, columns.len() - fits),
Style::default().fg(ctx.dimmed),
)));
}
lines
}
pub(crate) fn wrapped_rows(line: &Line<'_>, width: usize) -> usize {
use unicode_width::UnicodeWidthStr;
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if width == 0 {
return 1;
}
let mut rows = 1;
let mut used = 0;
for word in text.split_inclusive(' ') {
let len = UnicodeWidthStr::width(word);
if used + len > width && used > 0 {
rows += 1;
used = 0;
}
used += len;
while used > width {
rows += 1;
used -= width;
}
}
rows
}
fn schema_lines(
schema: &[(String, polars::prelude::DataType)],
name_w: usize,
width: usize,
room: usize,
ctx: &RenderContext,
) -> (usize, Vec<Line<'static>>) {
let g = glyphs::get();
let mut lines = Vec::new();
let mut used = 0;
for (name, dtype) in schema {
let mut display = name.clone();
if display.chars().count() > name_w {
display = display.chars().take(name_w - 1).collect::<String>() + g.ellipsis;
}
let line = Line::from(vec![
Span::styled(
format!("{display:<name_w$} "),
Style::default().fg(ctx.text_secondary),
),
Span::styled(
format!("{dtype}"),
Style::default().fg(ctx.type_color(dtype)),
),
]);
let takes = wrapped_rows(&line, width);
if used + takes > room {
break;
}
used += takes;
lines.push(line);
}
(lines.len(), lines)
}
fn fact_lines(
key: &str,
value: String,
key_w: usize,
width: usize,
style: Style,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let indent = key_w + 2;
let room = width.saturating_sub(indent);
let key_span = Span::styled(format!("{key:<key_w$} "), Style::default().fg(ctx.dimmed));
if room < 12 || value.chars().count() <= room {
return vec![Line::from(vec![key_span, Span::styled(value, style)])];
}
let pieces = crate::render::overlays::wrap_help_line(&value, room);
pieces
.into_iter()
.enumerate()
.map(|(i, piece)| {
let lead = if i == 0 {
key_span.clone()
} else {
Span::raw(" ".repeat(indent))
};
Line::from(vec![lead, Span::styled(piece, style)])
})
.collect()
}
fn key_column<'a>(keys: impl IntoIterator<Item = &'a str>) -> usize {
keys.into_iter()
.map(|k| k.chars().count())
.max()
.unwrap_or(0)
}
fn ratio_of(size: Option<u64>, uncompressed: Option<u64>) -> Option<f64> {
let (on_disk, in_memory) = (size?, uncompressed?);
if on_disk == 0 {
return None;
}
let ratio = in_memory as f64 / on_disk as f64;
(ratio >= 1.2).then_some(ratio)
}
fn looking_glyph(app: &crate::App, entry: &Entry) -> Option<&'static str> {
let g = glyphs::get();
Some(match app.home.cloud_look(entry)? {
crate::home::CloudLook::Waiting => g.ellipsis,
crate::home::CloudLook::Looking => g.spinner[app.throbber_frame as usize % g.spinner.len()],
crate::home::CloudLook::Failed => "?",
})
}
fn kind_words(
entry: &Entry,
place_kind: Option<&'static str>,
looking: Option<crate::home::CloudLook>,
) -> String {
if entry.opens_whole_directory {
return String::new();
}
if let Some(table) = &entry.table {
let of = match (&entry.format_spec, table.format) {
(Some(spec), _) => spec.clone(),
(None, Some(format)) => format.name().to_string(),
(None, None) => String::new(),
};
return format!("{of} {}", table.kind).trim_start().to_string();
}
match entry.kind {
EntryKind::File => match crate::FileFormat::from_path(&entry.path) {
_ if entry.format_spec.is_some() => {
format!("{} file", entry.format_spec.as_deref().unwrap_or_default())
}
Some(format) => format!("{} file", format.name()),
None => "data file".to_string(),
},
EntryKind::Other => String::new(),
EntryKind::Hive => "hive table".to_string(),
EntryKind::MultiFile => "multi-file table".to_string(),
k if k.is_lake_table() => {
format!("{} table", k.lake_name().unwrap_or_default().to_lowercase())
}
EntryKind::Directory => match place_kind {
Some(curated) => curated.to_string(),
None if looking == Some(crate::home::CloudLook::Failed) => {
format!("? {} listing failed, Ctrl+R retries", glyphs::get().middot)
}
None if looking.is_some() => String::new(),
None => crate::home::object_place_label(&entry.path)
.unwrap_or("directory")
.to_string(),
},
EntryKind::Unknown if place_kind == Some("missing") => "missing".to_string(),
_ => String::new(),
}
}
#[cfg(test)]
fn preview_head(
entry: &Entry,
place_kind: Option<&'static str>,
looking: Option<crate::home::CloudLook>,
width: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
preview_head_keyed(entry, place_kind, looking, None, width, 0, ctx).0
}
fn preview_head_keyed(
entry: &Entry,
place_kind: Option<&'static str>,
looking: Option<crate::home::CloudLook>,
spec: Option<&crate::formats::Spec>,
width: usize,
key_w: usize,
ctx: &RenderContext,
) -> (Vec<Line<'static>>, usize) {
let g = glyphs::get();
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
entry.name.clone(),
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
truncate_start(&crate::home::display_path(&entry.path), width),
Style::default().fg(ctx.dimmed),
)),
];
let mut facts: Vec<(&str, String, Style)> = Vec::new();
let plain = Style::default().fg(ctx.text_secondary);
let kind = kind_words(entry, place_kind, looking);
if !kind.is_empty() {
facts.push(("kind", kind, plain));
}
let spec_at = facts.len();
let spec_path = spec
.and_then(|s| s.path.as_deref())
.map(crate::home::display_path);
let chips = spec.map(|s| s.match_chips_for(&entry.path));
if let Some(how) = discover::how_read(entry) {
facts.push(("read", read_words(how), plain));
}
if let Some(source) = entry
.cost
.source
.as_deref()
.map(crate::locality::Source::from_fstype)
{
let style = match source.locality {
crate::locality::Locality::Network | crate::locality::Locality::Object => {
Style::default().fg(ctx.warning)
}
crate::locality::Locality::Memory => Style::default().fg(ctx.success),
_ => plain,
};
facts.push(("storage", source.label().to_string(), style));
}
match entry.holds.line(entry.cost.partitions.is_none()) {
Some(line) => {
for (i, part) in line.split(" · ").enumerate() {
let key = if i == 0 { "contains" } else { "" };
facts.push((key, part.to_string(), plain));
}
}
None if entry.kind == EntryKind::Directory
&& place_kind.is_none()
&& looking.is_none()
&& !entry.opens_whole_directory =>
{
facts.push(("contains", "no data files".to_string(), plain));
}
None => {}
}
if let Some(n) = entry.cost.tables.filter(|_| entry.format_spec.is_none()) {
let what = if n == 1 { "table" } else { "tables" };
facts.push(("contains", format!("{n} {what}"), plain));
}
if entry.opens_whole_directory
&& let Some((reads, skips)) = crate::home::door_reads(entry)
{
facts.push(("reads", reads, plain));
if let Some(skips) = skips {
facts.push(("skips", skips, plain));
}
}
if let Some(rows) = entry.rows {
facts.push(("rows", discover::format_rows(rows), plain));
}
if let Some(cols) = entry.cols {
let more = if entry.cols_sampled { "+" } else { "" };
facts.push(("columns", format!("{cols}{more}"), plain));
}
if let Some(size) = entry.size {
facts.push(("on disk", discover::format_size(size), plain));
}
if let Some(uncompressed) = entry.cost.uncompressed {
facts.push((
"in memory",
discover::format_size(uncompressed),
Style::default().fg(ctx.float_col),
));
}
let ratio = ratio_of(entry.size, entry.cost.uncompressed).map(|r| format!("{r:.1}{}", g.times));
match (&entry.cost.codec, ratio) {
(Some(codec), Some(r)) => facts.push(("compression", format!("{codec}, {r}"), plain)),
(Some(codec), None) => facts.push(("compression", codec.clone(), plain)),
(None, Some(r)) => facts.push(("compression", r, plain)),
(None, None) => {}
}
if let Some(groups) = entry.cost.row_groups {
facts.push(("row groups", groups.to_string(), plain));
}
if let Some(parts) = &entry.cost.partitions {
let count = if parts.more {
format!("{}+", parts.count)
} else {
parts.count.to_string()
};
facts.push((
"partitions",
format!("{count} by {}", parts.keys.join(", ")),
Style::default().fg(ctx.temporal_col),
));
if let (Some(first), Some(last)) = (
parts.first_key_values.first(),
parts.first_key_values.last(),
) {
let key = parts.keys.first().map(String::as_str).unwrap_or("");
let range = if first == last {
first.clone()
} else {
format!("{first} to {last}")
};
facts.push(("range", format!("{key} {range}"), plain));
}
}
if let Some(modified) = entry.modified {
let age = discover::format_age(modified);
if age == "now" {
facts.push(("modified", age, plain));
} else if !age.is_empty() {
facts.push(("modified", format!("{age} ago"), plain));
}
}
let spec_keys = spec_path
.as_ref()
.map(|_| "spec")
.into_iter()
.chain(chips.as_ref().map(|_| "match"));
let key_w = key_column(facts.iter().map(|(k, _, _)| *k).chain(spec_keys)).max(key_w);
if !facts.is_empty() || chips.is_some() {
lines.push(Line::from(""));
lines.push(pane_heading("DETAILS", width, ctx));
let mut spec_lines = Vec::new();
if let Some(path) = &spec_path {
let room = width.saturating_sub(key_w + 2);
spec_lines.extend(fact_lines(
"spec",
elide_path(path, room),
key_w,
width,
plain,
ctx,
));
}
if let Some(chips) = &chips {
let filled = chips_filled(ctx);
spec_lines.extend(chip_fact_lines("match", chips, key_w, width, filled, ctx));
}
let mut spec_lines = Some(spec_lines);
for (i, (key, value, style)) in facts.into_iter().enumerate() {
if i == spec_at {
lines.extend(spec_lines.take().unwrap_or_default());
}
lines.extend(fact_lines(key, value, key_w, width, style, ctx));
}
lines.extend(spec_lines.take().unwrap_or_default());
}
(lines, key_w)
}
fn elide_path(path: &str, width: usize) -> String {
if glyphs::display_width(path) <= width {
return path.to_string();
}
let ellipsis = glyphs::get().ellipsis;
let seps: Vec<(char, usize)> = path
.char_indices()
.filter(|(_, c)| std::path::is_separator(*c))
.map(|(i, c)| (c, i + c.len_utf8()))
.collect();
let Some(&(first_sep, after_first)) = seps.first() else {
return truncate_start(path, width);
};
let first = &path[..after_first - first_sep.len_utf8()];
for with_first in [true, false] {
for &(sep, start) in &seps[1..] {
let tail = &path[start..];
let cut = if with_first {
format!("{first}{first_sep}{ellipsis}{sep}{tail}")
} else {
format!("{ellipsis}{sep}{tail}")
};
if glyphs::display_width(&cut) <= width {
return cut;
}
}
}
let (_, last) = seps[seps.len() - 1];
truncate_start(&path[last..], width)
}
fn chips_filled(ctx: &RenderContext) -> bool {
glyphs::get().unicode && crate::config::tint_shows(Some(ctx.table_header_bg)).is_some()
}
fn chip_spans(
chip: &MatchChip,
room: usize,
filled: bool,
ctx: &RenderContext,
) -> (Vec<Span<'static>>, usize) {
use crate::formats::ChipKind;
let value_color = match chip.kind {
ChipKind::Int | ChipKind::Hex => ctx.int_col,
ChipKind::Magic | ChipKind::Text | ChipKind::Glob => ctx.str_col,
};
let base = if filled {
Style::default().bg(ctx.table_header_bg)
} else {
Style::default()
};
let dim = base.fg(ctx.dimmed);
let (open, close) = if filled { (" ", " ") } else { ("[", "]") };
let ellipsis = glyphs::get().ellipsis;
let value = chip.value_text(!filled);
let (name_w, value_w) = (
glyphs::display_width(&chip.name),
glyphs::display_width(&value),
);
let inner = room.saturating_sub(3);
let name_floor = name_w.min(1 + glyphs::display_width(ellipsis));
let name_room = name_w.min(inner.saturating_sub(value_w.min(inner / 2)).max(name_floor));
let name = glyphs::fit_cells(&chip.name, name_room, ellipsis).into_owned();
let name_w = glyphs::display_width(&name);
let value = glyphs::fit_cells(&value, inner.saturating_sub(name_w), ellipsis).into_owned();
let value_w = glyphs::display_width(&value);
let width = 3 + name_w + value_w;
let spans = vec![
Span::styled(open, dim),
Span::styled(name, dim),
Span::styled(" ", base),
Span::styled(value, base.fg(value_color)),
Span::styled(close, dim),
];
(spans, width)
}
fn chip_fact_lines(
key: &str,
chips: &[MatchChip],
key_w: usize,
width: usize,
filled: bool,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
if chips.is_empty() {
let said = crate::formats::FORMAT_ONLY.to_string();
let style = Style::default().fg(ctx.text_secondary);
return fact_lines(key, said, key_w, width, style, ctx);
}
let indent = key_w + 2;
let room = width.saturating_sub(indent).max(1);
let mut rows: Vec<(Vec<Span<'static>>, usize)> = vec![(Vec::new(), 0)];
for chip in chips {
let (spans, w) = chip_spans(chip, room, filled, ctx);
let row = rows.last_mut().expect("one row at least");
if row.1 > 0 && row.1 + 1 + w > room {
rows.push((spans, w));
} else {
if row.1 > 0 {
row.0.push(Span::raw(" "));
row.1 += 1;
}
row.0.extend(spans);
row.1 += w;
}
}
rows.into_iter()
.enumerate()
.map(|(i, (spans, _))| {
let lead = if i == 0 {
Span::styled(format!("{key:<key_w$} "), Style::default().fg(ctx.dimmed))
} else {
Span::raw(" ".repeat(indent))
};
Line::from(std::iter::once(lead).chain(spans).collect::<Vec<_>>())
})
.collect()
}
fn catalog_details(
catalog: &crate::home::ShownCatalog,
width: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let plain = Style::default().fg(ctx.text_secondary);
let middot = glyphs::get().middot;
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
catalog.label.clone(),
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
pane_heading("DETAILS", width, ctx),
];
let bundled = catalog.origin == crate::catalog::Origin::Bundled;
let for_good = format!("[home] hide = [\"{}\"]", catalog.id);
let mut facts: Vec<(&str, String)> = vec![("datasets", catalog.datasets.len().to_string())];
match (&catalog.file, bundled) {
(_, true) => facts.push((
"source",
format!(
"{} {middot} datui catalog show {}",
crate::home::BUNDLED_ORIGIN,
catalog.id
),
)),
(Some(file), false) => facts.push(("file", crate::home::display_path(file))),
(None, false) => {}
}
if !catalog.description.is_empty() {
facts.push(("about", catalog.description.clone()));
}
if bundled {
facts.push(("hide", "Del hides it until datui cache clear".to_string()));
facts.push(("", format!("{for_good} for good")));
} else {
facts.push(("hide", for_good));
}
let key_w = key_column(facts.iter().map(|(k, _)| *k));
for (key, value) in facts {
lines.extend(fact_lines(key, value, key_w, width, plain, ctx));
}
lines
}
fn source_details(
entry: &Entry,
source: &crate::home::CloudSource,
width: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let plain = Style::default().fg(ctx.text_secondary);
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
entry.name.clone(),
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
))];
let mut facts: Vec<(String, String, Style)> = source
.details
.iter()
.map(|(k, v)| (k.clone(), v.clone(), plain))
.collect();
let middot = glyphs::get().middot;
let listed = match source.listed_at.map(discover::format_age) {
Some(age) if age == "now" => format!(" {middot} listed now"),
Some(age) if !age.is_empty() => format!(" {middot} listed {age} ago"),
_ => String::new(),
};
let noun = match source.api.as_str() {
"azure" => "accounts",
_ => "buckets",
};
match &source.status {
crate::home::CloudStatus::Listing if source.buckets.is_empty() => {
facts.push((noun.to_string(), "listing".to_string(), plain));
}
crate::home::CloudStatus::Unlisted if source.buckets.is_empty() => {
facts.push((noun.to_string(), "not listed".to_string(), plain));
}
_ => facts.push((
noun.to_string(),
format!("{}{listed}", source.buckets.len()),
plain,
)),
}
lines.push(Line::from(""));
lines.push(pane_heading("DETAILS", width, ctx));
let key_w = key_column(facts.iter().map(|(k, _, _)| k.as_str()));
for (key, value, style) in facts {
lines.extend(fact_lines(&key, value, key_w, width, style, ctx));
}
if let crate::home::CloudStatus::Failed { short, detail } = &source.status {
lines.push(Line::from(""));
lines.push(pane_heading(&short.to_uppercase(), width, ctx));
lines.push(Line::from(Span::styled(
detail.clone(),
Style::default().fg(ctx.warning),
)));
}
lines
}
fn render_preview(
area: Rect,
buf: &mut Buffer,
app: &mut crate::App,
ctx: &RenderContext,
screen_height: u16,
) {
let width = area.width as usize;
if app.home.path_input_active {
return;
}
if let Some(crate::home::Row::Place {
path, source, held, ..
}) = app.home.selected_row()
{
Paragraph::new(place_details(&path, source.as_deref(), held, width, ctx))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, buf);
return;
}
if let Some(crate::home::Row::Hidden { section, count }) = app.home.selected_row() {
let names: Vec<&str> = app.home.sections[section]
.rows
.iter()
.filter(|row| row.hidden_by_default())
.map(|row| row.name.as_str())
.collect();
let tables = holds_tables(&app.home.sections[section]);
Paragraph::new(hidden_details(
&names,
count,
tables,
area.height as usize,
ctx,
))
.render(area, buf);
return;
}
if let Some(catalog) = app.home.selected_catalog() {
Paragraph::new(catalog_details(catalog, width, ctx))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, buf);
return;
}
let Some(entry) = app.home.selected_entry() else {
return;
};
if crate::home::cloud_source_id(&entry.path).is_some() {
if let Some(source) = app.home.cloud_source_of(&entry.path) {
Paragraph::new(source_details(&entry, source, width, ctx))
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, buf);
}
return;
}
let g = glyphs::get();
let place_details = app.home.place_details(&entry.path).map(<[_]>::to_vec);
let key_w = place_details
.as_ref()
.map(|details| key_column(details.iter().map(|(k, _)| k.as_str())))
.unwrap_or(0);
let spec = entry
.format_spec
.as_deref()
.filter(|_| entry.kind == EntryKind::File && entry.table.is_none())
.and_then(|name| app.home.formats.get(name))
.cloned();
let (mut lines, key_w) = preview_head_keyed(
&entry,
app.home.place_kind(&entry.path),
app.home.cloud_look(&entry),
spec.as_deref(),
width,
key_w,
ctx,
);
#[cfg(feature = "cloud")]
if entry.opens_whole_directory && crate::App::why_a_door_reads_nothing(&entry).is_some() {
lines.push(Line::from(Span::styled(
format!("{} prefix opens Parquet only", g.warning),
Style::default().fg(ctx.warning),
)));
}
if let Some(details) = place_details {
for (key, value) in details {
let style = if key == "network" || key == "shared keys" || key == "access" {
Style::default().fg(ctx.warning)
} else {
Style::default().fg(ctx.text_secondary)
};
lines.extend(fact_lines(&key, value, key_w, width, style, ctx));
}
}
if let Some((_, mut doc)) = app.home_documented_row().filter(|(p, _)| *p == entry.path) {
if app.home.catalog_dataset(&entry.path).is_none()
&& app.home.bookmark(&entry.path).is_none()
{
doc.catalog = None;
}
let columns = doc.columns();
if !columns.is_empty() {
let drawn: usize = lines.iter().map(|line| wrapped_rows(line, width)).sum();
let room = (area.height as usize).saturating_sub(drawn);
lines.extend(column_notes_block(&columns, width, room, ctx));
}
}
if let Some(preview) = app.home_preview_rows(&entry, screen_height) {
let drawn: usize = lines.iter().map(|line| wrapped_rows(line, width)).sum();
let room = (area.height as usize)
.saturating_sub(drawn + 1)
.min(2 + crate::home_preview::PREVIEW_ROWS);
if room >= STRIP_MIN_HEIGHT {
lines.push(Line::from(""));
lines.extend(rows_block(&preview, width, room, ctx));
}
}
lines.push(Line::from(""));
match app.home_schema(&entry) {
Some(schema) if !schema.is_empty() => {
lines.push(pane_heading_counted(
"COLUMNS",
Some(&crate::numfmt::group_chrome(schema.len())),
width,
ctx,
));
let name_w = schema
.iter()
.map(|(n, _)| n.chars().count())
.max()
.unwrap_or(0)
.min(22);
let drawn: usize = lines.iter().map(|line| wrapped_rows(line, width)).sum();
let room = (area.height as usize).saturating_sub(drawn + 1);
let (shown, mut schema_lines) = schema_lines(&schema, name_w, width, room, ctx);
lines.append(&mut schema_lines);
if schema.len() > shown {
lines.push(Line::from(Span::styled(
format!("{} {} more", g.ellipsis, schema.len() - shown),
Style::default().fg(ctx.dimmed),
)));
}
}
_ => {
let reading =
app.home_schema_pending(&entry.path) || app.home_preview_pending(&entry.path);
let door_in_there = !crate::home::holds_nothing_to_open(&entry.holds);
let web_gone = app.home.web_gone.get(&entry.path);
let footer_unreadable = entry.kind == EntryKind::File
&& entry.rows.is_none()
&& discover::is_parquet_path(&entry.path)
&& app.home.enriched.contains_key(&entry.path);
let opens = discover::data_format(&entry.path)
.and_then(|f| f.descriptor().tables.as_ref())
.and_then(|t| t.opens);
let inside = if entry.format_spec.is_some() {
(g.arrow_right, "its record types".to_string())
} else {
(g.arrow_right, "its tables".to_string())
};
let variants = spec
.as_deref()
.filter(|s| s.lists_variants())
.map(crate::members::variant_tables);
let spec_columns = spec
.as_deref()
.and_then(crate::formats::Spec::static_columns)
.filter(|c| !c.is_empty());
let step_in = |then: &str| format!("step in {} {then}", g.middot);
let notes: Vec<(&str, String)> = match entry.kind {
_ if entry.opens_whole_directory => {
let does = match crate::home::door_kind(&entry) {
crate::home::DoorKind::Lake => DOOR_OF_A_LAKE_TABLE,
crate::home::DoorKind::Hive => DOOR_OF_A_HIVE_TABLE,
crate::home::DoorKind::SchemasDiffer => DOOR_OF_FILES_THAT_DIFFER,
crate::home::DoorKind::Mixed | crate::home::DoorKind::Single => {
DOOR_OF_A_MIX
}
crate::home::DoorKind::OneSchema | crate::home::DoorKind::Unknown => {
THE_DOOR
}
};
vec![("Enter", does.to_string())]
}
_ if web_gone.is_some() => {
if let Some(gone) = web_gone {
lines.push(Line::from(Span::styled(
format!("{} {}", g.warning, gone.message),
Style::default().fg(ctx.warning),
)));
}
Vec::new()
}
EntryKind::Directory if door_in_there => {
vec![("Enter", step_in(INSIDE_AND_THE_DOOR))]
}
EntryKind::Directory => Vec::new(),
EntryKind::Unknown if app.home.missing.contains(&entry.path) => Vec::new(),
EntryKind::Unknown => vec![("schema", "not read".to_string())],
EntryKind::Other => {
vec![("format", format!("unknown {} Enter shows hex", g.middot))]
}
EntryKind::File if entry.enter_lists_tables() => {
vec![("Enter", "its tables".to_string())]
}
EntryKind::File
if variants.is_some() || entry.cost.tables.is_some_and(|n| n > 1) =>
{
let opens = opens.filter(|_| entry.cost.opens_one);
let mut notes = vec![
("Enter", opens.unwrap_or("every record").to_string()),
inside,
];
if let Some(variants) = &variants {
let n = variants.len();
let what = if n == 1 { "type" } else { "types" };
notes.push(("records", format!("{n} {what} (spec)")));
}
notes
}
k if k.is_lake_table() && door_in_there => {
vec![("Enter", step_in(INSIDE_A_LAKE_TABLE))]
}
k if k.is_lake_table() => vec![("Enter", step_in("log not read"))],
EntryKind::File if footer_unreadable => {
lines.push(Line::from(Span::styled(
format!("{} {FOOTER_UNREADABLE}", g.warning),
Style::default().fg(ctx.warning),
)));
Vec::new()
}
_ if spec_columns.is_some() => {
let n = spec_columns.as_ref().map_or(0, Vec::len);
let what = if n == 1 { "column" } else { "columns" };
vec![("schema", format!("{n} {what} (spec)"))]
}
_ if reading => vec![("schema", "reading...".to_string())],
_ => vec![("schema", "on open".to_string())],
};
let note_w = key_column(notes.iter().map(|(k, _)| *k)).max(key_w);
let style = Style::default().fg(ctx.text_secondary);
let said_spec = notes.iter().any(|(_, v)| v.ends_with("(spec)"));
for (key, value) in notes {
lines.extend(fact_lines(key, value, note_w, width, style, ctx));
}
if said_spec {
let drawn: usize = lines.iter().map(|line| wrapped_rows(line, width)).sum();
let room = (area.height as usize).saturating_sub(drawn + 1);
lines.extend(spec_schema_lines(
variants.as_deref(),
spec_columns.as_deref(),
note_w + 2,
width,
room,
ctx,
));
}
}
}
Paragraph::new(lines)
.wrap(ratatui::widgets::Wrap { trim: false })
.render(area, buf);
}
fn spec_schema_lines(
variants: Option<&[crate::members::Table]>,
columns: Option<&[(String, polars::prelude::DataType)]>,
indent: usize,
width: usize,
room: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
if let Some(variants) = variants {
return variant_lines(variants, indent, width, room, ctx);
}
let Some(columns) = columns else {
return Vec::new();
};
let name_w = columns
.iter()
.map(|(n, _)| n.chars().count())
.max()
.unwrap_or(0)
.min(22);
let (_, mut lines) = schema_lines(columns, name_w, width, room, ctx);
if columns.len() > lines.len() {
if lines.len() == room && !lines.is_empty() {
lines.pop();
}
lines.push(Line::from(Span::styled(
format!(
"{} {} more",
glyphs::get().ellipsis,
columns.len() - lines.len()
),
Style::default().fg(ctx.dimmed),
)));
}
lines
}
fn variant_lines(
variants: &[crate::members::Table],
indent: usize,
width: usize,
room: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let g = glyphs::get();
let avail = width.saturating_sub(indent).max(1);
let sep = format!(" {} ", g.middot);
let sep_w = glyphs::display_width(&sep);
let name_style = Style::default().fg(ctx.text_secondary);
let count_style = Style::default().fg(ctx.dimmed);
let mut rows: Vec<(Vec<Span<'static>>, usize, usize)> = Vec::new();
for variant in variants {
let count = variant.columns.len().to_string();
let count_w = count.len() + 1;
let name = glyphs::fit_cells(&variant.name, avail.saturating_sub(count_w), g.ellipsis)
.into_owned();
let w = glyphs::display_width(&name) + count_w;
let item = [
Span::styled(name, name_style),
Span::styled(format!(" {count}"), count_style),
];
match rows.last_mut() {
Some((spans, used, n)) if *used + sep_w + w <= avail => {
spans.push(Span::styled(sep.clone(), count_style));
spans.extend(item);
*used += sep_w + w;
*n += 1;
}
_ => rows.push((item.to_vec(), w, 1)),
}
}
if room == 0 {
return Vec::new();
}
let mut shown = 0;
let mut lines: Vec<Line<'static>> = Vec::new();
let lead = || Span::raw(" ".repeat(indent));
for (spans, _, n) in rows.iter().take(room) {
lines.push(Line::from(
std::iter::once(lead())
.chain(spans.iter().cloned())
.collect::<Vec<_>>(),
));
shown += n;
}
if shown < variants.len() {
if lines.len() == room {
if let Some((_, _, n)) = rows.get(lines.len() - 1) {
shown -= n;
}
lines.pop();
}
lines.push(Line::from(vec![
lead(),
Span::styled(
format!("{} {} more", g.ellipsis, variants.len() - shown),
count_style,
),
]));
}
lines
}
const FOOTER_UNREADABLE: &str = "footer unreadable";
const INSIDE_AND_THE_DOOR: &str = "first row opens all";
const INSIDE_A_LAKE_TABLE: &str = "first row reads files, log ignored";
const THE_DOOR: &str = "all files as one table";
const DOOR_OF_A_HIVE_TABLE: &str = "all partitions as one table";
const DOOR_OF_A_LAKE_TABLE: &str = "all files, log ignored";
const DOOR_OF_FILES_THAT_DIFFER: &str = "files stacked, columns matched by name";
const DOOR_OF_A_MIX: &str = "matching files as one table";
#[cfg(test)]
fn guidance_notes() -> [&'static str; 8] {
[
FOOTER_UNREADABLE,
INSIDE_AND_THE_DOOR,
INSIDE_A_LAKE_TABLE,
THE_DOOR,
DOOR_OF_A_HIVE_TABLE,
DOOR_OF_A_LAKE_TABLE,
DOOR_OF_FILES_THAT_DIFFER,
DOOR_OF_A_MIX,
]
}
fn hidden_details(
names: &[&str],
count: usize,
tables: bool,
height: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let g = glyphs::get();
let (title, what) = match (tables, count) {
(true, 1) => ("Hidden tables", "1 internal SQLite table".to_string()),
(true, _) => ("Hidden tables", format!("{count} internal SQLite tables")),
(false, 1) => ("Hidden files", "1 file with no reader".to_string()),
(false, _) => ("Hidden files", format!("{count} files with no reader")),
};
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
title.to_string(),
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(what, Style::default().fg(ctx.dimmed))),
Line::from(""),
];
let room = height.saturating_sub(lines.len());
let shown = if names.len() > room {
room.saturating_sub(1)
} else {
names.len()
};
for name in &names[..shown] {
lines.push(Line::from(Span::styled(
name.to_string(),
Style::default().fg(ctx.text_secondary),
)));
}
if shown < names.len() {
lines.push(Line::from(Span::styled(
format!("{} {} more", g.ellipsis, names.len() - shown),
Style::default().fg(ctx.dimmed),
)));
}
lines
}
fn place_details(
path: &std::path::Path,
source: Option<&str>,
held: usize,
width: usize,
ctx: &RenderContext,
) -> Vec<Line<'static>> {
let plain = Style::default().fg(ctx.text_secondary);
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| crate::home::display_path(path));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
name,
Style::default()
.fg(ctx.text_primary)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
truncate_start(&crate::home::display_path(path), width),
Style::default().fg(ctx.dimmed),
)),
];
let mut facts: Vec<(&str, String, Style)> = Vec::new();
if let Some(source) = source.map(crate::locality::Source::from_fstype) {
let style = match source.locality {
crate::locality::Locality::Network | crate::locality::Locality::Object => {
Style::default().fg(ctx.warning)
}
crate::locality::Locality::Memory => Style::default().fg(ctx.success),
_ => plain,
};
facts.push(("storage", source.label().to_string(), style));
}
facts.push(("opened here", held.to_string(), plain));
if !crate::home::place_is_browsable(path) {
facts.push(("listing", "none over HTTP".to_string(), plain));
}
let key_w = key_column(facts.iter().map(|(k, _, _)| *k));
for (key, value, style) in facts {
lines.extend(fact_lines(key, value, key_w, width, style, ctx));
}
lines
}
fn shows_as_a_place(entry: &Entry) -> bool {
if entry.opens_whole_directory {
return false;
}
if matches!(
entry.kind,
EntryKind::Directory | EntryKind::Hive | EntryKind::MultiFile
) || entry.kind.is_lake_table()
{
return true;
}
if entry.kind != EntryKind::Unknown {
return false;
}
!crate::home::is_remote_path(&entry.path)
|| (!crate::discover::is_data_file(&entry.path) && entry.path.extension().is_none())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::Entry;
use crate::home::Section;
fn rows<'f>(
ctx: &'f RenderContext,
name_width: usize,
show_meta: bool,
filter: &'f str,
) -> ListDraw<'f> {
ListDraw {
ctx,
width: name_width,
name_width,
show_meta,
filter,
frame: 0,
}
}
fn header_row(ctx: &RenderContext, width: usize) -> ListDraw<'_> {
rows(ctx, width, true, "")
}
#[test]
fn the_pane_s_guidance_is_a_value_not_a_sentence() {
for note in guidance_notes() {
assert!(
!note.contains(" "),
"a run of spaces in the middle of {note:?}"
);
assert!(
!note.ends_with('.') && !note.contains('\n') && !note.contains(';'),
"a value, not a sentence: {note:?}"
);
assert!(
note.chars().count() <= 40,
"short enough for the pane: {note:?}"
);
}
}
fn row(path: &str, kind: crate::discover::EntryKind) -> Entry {
Entry {
path: std::path::PathBuf::from(path),
kind,
name: path.rsplit('/').next().unwrap_or(path).to_string(),
size: None,
modified: None,
rows: None,
cols: None,
cols_sampled: false,
columns: Vec::new(),
cost: Default::default(),
holds: Default::default(),
opens_whole_directory: false,
format_spec: None,
table: None,
}
}
#[test]
fn blank_meta_columns_go_to_the_name() {
let ctx = RenderContext::for_test();
let text = |entry: &Entry| -> String {
entry_line(
entry,
false,
EntryNotes::default(),
&rows(&ctx, 26, true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let mut public = row("/data/food_nutrition.csv", EntryKind::File);
public.name = "Food nutrition (fast food)".to_string();
assert!(text(&public).contains("Food nutrition (fast food)"));
let mut sized = row("/data/chart_export_overwrite_test.csv", EntryKind::File);
sized.size = Some(25);
let mut shaped = sized.clone();
shaped.rows = Some(3);
shaped.cols = Some(2);
let (sized, shaped) = (text(&sized), text(&shaped));
assert!(
sized.contains("chart_export_overwrite_test.csv"),
"{sized:?}"
);
assert!(
!shaped.contains("chart_export_overwrite_test.csv"),
"{shaped:?}"
);
assert_eq!(
sized.chars().count(),
shaped.chars().count(),
"the same width either way"
);
assert_eq!(
sized.find("25 B").map(|at| sized[..at].chars().count()),
shaped.find("25 B").map(|at| shaped[..at].chars().count()),
"size stays in its column"
);
let mut hit = row(
"/data/tests/sample-data/sales_by_region.csv",
EntryKind::File,
);
hit.name = "tests/sample-data/sales_by_region.csv".to_string();
hit.size = Some(25);
hit.rows = Some(3);
hit.cols = Some(2);
assert!(text(&hit).contains("region.csv"), "{:?}", text(&hit));
}
#[test]
fn the_row_and_the_pane_agree_on_what_a_directory_is() {
let ctx = RenderContext::for_test();
let mut entry = row("s3://bucket/occurrence", EntryKind::Directory);
entry.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 12)],
..Default::default()
};
for curated in [None, Some("dataset"), Some("project")] {
let line = entry_line(
&entry,
false,
EntryNotes {
place_kind: curated,
..EntryNotes::default()
},
&rows(&ctx, 40, false, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
let pane: String = preview_head(&entry, curated, None, 60, &ctx)
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
let word = curated.unwrap_or("12 parquet");
assert!(
pane.contains(word),
"the pane {pane:?} does not name it {word:?}"
);
assert!(
line.contains(word),
"the row {line:?} does not name it {word:?}"
);
}
}
#[test]
fn a_cloud_directory_is_labelled_by_what_it_holds() {
let ctx = RenderContext::for_test();
let drawn = |entry: &Entry, looking: Option<&'static str>| -> String {
entry_line(
entry,
false,
EntryNotes {
looking,
..EntryNotes::default()
},
&rows(&ctx, 40, false, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let g = glyphs::get();
let spinner = g.spinner[0];
let mut directory = row("s3://bucket/exports", EntryKind::Directory);
let text = drawn(&directory, Some(spinner));
assert!(text.contains(spinner), "{text}");
assert!(!text.contains("dir") && !text.contains("prefix"), "{text}");
let text = drawn(&directory, Some(g.ellipsis));
assert!(text.contains(g.ellipsis), "{text}");
let text = drawn(&directory, None);
assert!(text.contains(" dir"), "{text}");
assert!(!text.contains("prefix"), "{text}");
directory.holds = crate::discover::Holds {
formats: vec![("csv".to_string(), 12)],
..Default::default()
};
let text = drawn(&directory, None);
assert!(text.contains("12 csv"), "{text}");
let bucket = row("s3://bucket", EntryKind::Directory);
assert!(drawn(&bucket, Some(g.ellipsis)).contains("bucket"));
let container = row(
"abfss://data@acct.dfs.core.windows.net/",
EntryKind::Directory,
);
assert!(drawn(&container, Some(g.ellipsis)).contains("container"));
let inside = row(
"abfss://data@acct.dfs.core.windows.net/jolpica",
EntryKind::Directory,
);
assert!(!drawn(&inside, None).contains("prefix"));
}
#[test]
fn every_row_reads_name_slash_two_spaces_label() {
let ctx = RenderContext::for_test();
let drawn = |entry: &Entry, place_kind: Option<&'static str>| -> String {
entry_line(
entry,
false,
EntryNotes {
place_kind,
..EntryNotes::default()
},
&rows(&ctx, 60, true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
let mut local = row("/data/project/data", EntryKind::Directory);
local.holds.directories = 3;
let mut hive = row("/data/project/events", EntryKind::Hive);
hive.holds.partitions = 2;
let mut parquet = row("/data/project/processed", EntryKind::MultiFile);
parquet.holds.formats = vec![("parquet".to_string(), 2)];
let mut prefix = row("s3://bucket/exports", EntryKind::Directory);
prefix.holds.formats = vec![("csv".to_string(), 12)];
let bucket = row("s3://noaa-ghcn-pds", EntryKind::Directory);
let mut noaa = row("s3://noaa-ghcn-pds/parquet/", EntryKind::Directory);
noaa.name = "NOAA daily weather".to_string();
let mut penguins = row("https://example.com/csv/penguins.csv", EntryKind::File);
penguins.name = "Palmer penguins".to_string();
for (entry, place_kind, expect) in [
(&local, None, "data/ 3 dirs"),
(&hive, None, "events/ hive"),
(&parquet, None, "processed/ 2 parquet"),
(&prefix, None, "exports/ 12 csv"),
(&bucket, None, "noaa-ghcn-pds/ bucket"),
(&noaa, Some("dataset"), "NOAA daily weather/ dataset"),
(&penguins, None, "Palmer penguins csv"),
] {
let text = drawn(entry, place_kind);
assert!(text.contains(expect), "{expect:?} in {text:?}");
}
}
#[test]
fn the_row_that_opens_a_directory_draws_without_a_label() {
let ctx = RenderContext::for_test();
let mut drawn: Vec<(EntryKind, String)> = Vec::new();
for kind in [EntryKind::MultiFile, EntryKind::Hive, EntryKind::Directory] {
let mut door = row("gs://cloud-samples-data/bigquery/us-states", kind);
door.name = "us-states (all files)".to_string();
door.opens_whole_directory = true;
door.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 9)],
..Default::default()
};
assert_eq!(door.label(), "", "{kind:?}");
for width in 1..=60usize {
let line = entry_line(
&door,
false,
EntryNotes::default(),
&rows(&ctx, width, true, ""),
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(!text.is_empty(), "at {width} cells, {kind:?}");
}
let line = entry_line(
&door,
false,
EntryNotes::default(),
&rows(&ctx, 40, true, ""),
);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("us-states (all files)"), "{kind:?}: {text:?}");
assert!(!text.contains("parquet"), "{kind:?}: {text:?}");
drawn.push((kind, text));
}
let mut door = row("s3://lab@bucket/exports", EntryKind::Directory);
door.name = "exports (all files)".to_string();
door.opens_whole_directory = true;
let known = ["lab".to_string()];
let text: String = entry_line(
&door,
false,
EntryNotes {
known_sources: Some(&known),
place_kind: Some("dataset"),
..EntryNotes::default()
},
&rows(&ctx, 60, true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(text.contains("exports (all files)"), "{text:?}");
assert!(
!text.contains("dataset"),
"curated word on a door: {text:?}"
);
assert!(!text.contains("lab"), "source id on a door: {text:?}");
let mut door = row("s3://bucket/warehouse", EntryKind::Directory);
door.name = "warehouse (all files)".to_string();
door.opens_whole_directory = true;
let pane = preview_text(&door, 60);
assert!(pane.contains("warehouse (all files)"), "{pane}");
assert!(
!pane.lines().any(|l| l.trim_start().starts_with("kind")),
"a kind stood in for the label it does not have: {pane}"
);
let (_, first) = &drawn[0];
for (kind, text) in &drawn[1..] {
assert_eq!(first, text, "{kind:?} drew a different row");
}
}
#[test]
fn the_pane_calls_a_cloud_directory_what_the_row_calls_it() {
use crate::home::CloudLook;
let ctx = RenderContext::for_test();
let pane = |entry: &Entry, looking: Option<CloudLook>| -> Vec<String> {
preview_head(entry, None, looking, 60, &ctx)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
};
let kind_line = |lines: &[String]| {
lines
.iter()
.find(|l| l.trim_start().starts_with("kind"))
.cloned()
};
let mut directory = row("s3://bucket/warehouse", EntryKind::Directory);
assert_eq!(kind_line(&pane(&directory, Some(CloudLook::Waiting))), None);
assert_eq!(kind_line(&pane(&directory, Some(CloudLook::Looking))), None);
let failed = kind_line(&pane(&directory, Some(CloudLook::Failed))).expect("a kind");
assert!(failed.contains("listing failed"), "{failed}");
let looked = kind_line(&pane(&directory, None)).expect("a kind");
assert!(looked.trim_end().ends_with("directory"), "{looked}");
directory.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 12)],
..Default::default()
};
let lines = pane(&directory, None);
let contains = lines
.iter()
.find(|l| l.trim_start().starts_with("contains"))
.expect("a contains line");
assert!(contains.contains("12 parquet"), "{contains}");
}
#[test]
fn a_directory_of_separate_tables_shows_its_width_without_a_question_mark() {
let mut directory = row("/data/consolidated", EntryKind::Directory);
directory.cols = Some(72);
let shape = meta_columns(&directory, false, None, None);
assert!(shape.contains("72 cols"), "{shape}");
assert!(!shape.contains('?'), "{shape}");
assert!(
!shape.contains(glyphs::get().times),
"an operator with nothing on its left: {shape}"
);
let mut big = row("/data/events", EntryKind::Hive);
big.cols = Some(72);
assert!(
meta_columns(&big, false, None, None).contains('?'),
"a hive dataset still says ?"
);
}
#[test]
fn a_wrapping_schema_line_is_paid_for_at_its_real_height() {
use polars::prelude::DataType;
let ctx = RenderContext::for_test();
let long = (0..6).fold(DataType::Int64, |inner, _| DataType::List(Box::new(inner)));
assert!(
format!("{long}").chars().count() > 18,
"a type past the pane's forty cells beside a name: {long}"
);
let schema: Vec<(String, DataType)> = (0..10)
.map(|i| (format!("created_at_{i}"), long.clone()))
.collect();
let (shown, lines) = schema_lines(&schema, 22, 40, 6, &ctx);
let rows: usize = lines.iter().map(|l| wrapped_rows(l, 40)).sum();
assert!(rows <= 6, "{rows} rows drawn into six: {shown} columns");
assert!(shown < 6, "each column takes more than one row: {shown}");
assert!(shown > 0, "and at least one still fits");
}
#[test]
fn a_wrapped_line_is_counted_by_the_rows_it_takes() {
let line = |text: &str| Line::from(vec![Span::raw(text.to_string())]);
assert_eq!(wrapped_rows(&line("short"), 40), 1);
assert_eq!(
wrapped_rows(&line("aaaa aaaa aaaa aaaa aaaa"), 6),
5,
"the room left at the end of a row is room a word cannot use"
);
assert_eq!(wrapped_rows(&line(&"x".repeat(25)), 10), 3);
assert_eq!(
wrapped_rows(&line("æ¼¢å—æ¼¢"), 5),
2,
"three characters, six cells"
);
}
#[test]
fn a_label_gives_way_to_the_name_on_a_narrow_screen() {
let ctx = RenderContext::for_test();
let mut entry = row("/data/exports", crate::discover::EntryKind::MultiFile);
entry.size = Some(4096);
entry.rows = Some(12);
entry.cols = Some(3);
entry.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 5000)],
truncated: true,
..Default::default()
};
assert_eq!(entry.label(), "5000+ parquet");
let offset_of_meta = |line: Line<'_>, entry: &Entry| -> usize {
let meta = meta_columns(entry, false, None, None);
let at = line
.spans
.iter()
.position(|s| s.content == meta)
.expect("the meta columns are drawn");
line.spans[..at]
.iter()
.map(|s| s.content.chars().count())
.sum()
};
let meta_starts_at = |entry: &Entry, width: usize| -> usize {
let line = entry_line(
entry,
false,
EntryNotes::default(),
&rows(&ctx, width, true, ""),
);
offset_of_meta(line, entry)
};
let mut short = row("/data/exports", crate::discover::EntryKind::MultiFile);
short.size = Some(4096);
short.rows = Some(12);
short.cols = Some(3);
short.holds = crate::discover::Holds {
formats: vec![("csv".to_string(), 2)],
..Default::default()
};
assert_eq!(short.label(), "2 csv");
for width in [22usize, 24, 30, 48, 100] {
assert_eq!(
meta_starts_at(&entry, width),
meta_starts_at(&short, width),
"the meta columns must start in the same place at {width}"
);
}
let mut named = row("s3://bucket/occurrence", EntryKind::Directory);
named.size = Some(4096);
named.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 5000)],
truncated: true,
..Default::default()
};
let curated = |width: usize| -> String {
entry_line(
&named,
false,
EntryNotes {
place_kind: Some("dataset"),
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
};
for width in [10usize, 12, 14, 22, 30] {
assert!(
curated(width).contains("dataset"),
"at {width}: {}",
curated(width)
);
}
let mut curated_multi = row("s3://bucket/occurrence", EntryKind::MultiFile);
curated_multi.size = Some(4096);
curated_multi.rows = Some(12);
curated_multi.cols = Some(3);
curated_multi.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 5000)],
truncated: true,
..Default::default()
};
let with_curated_path = |width: usize| -> usize {
let line = entry_line(
&curated_multi,
false,
EntryNotes {
place_kind: Some("dataset"),
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
);
offset_of_meta(line, &curated_multi)
};
for width in [20usize, 22, 24, 30] {
assert_eq!(
with_curated_path(width),
meta_starts_at(&short, width),
"a count under a curated path still gives way at {width}"
);
}
let mut gone = row("s3://averylongsourcename@bucket/exports", EntryKind::File);
gone.size = Some(4096);
gone.rows = Some(12);
gone.cols = Some(3);
let known: [String; 1] = ["other".to_string()];
let missing = |width: usize| -> usize {
let line = entry_line(
&gone,
false,
EntryNotes {
known_sources: Some(&known),
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
);
offset_of_meta(line, &gone)
};
for width in [22usize, 24, 30, 48] {
assert_eq!(
missing(width),
meta_starts_at(&short, width),
"a warning too long for the row is cut, not left to push the columns \
out, at {width}"
);
}
let marks = |width: usize| -> Vec<String> {
entry_line(
&short,
false,
EntryNotes {
matched_column: Some("amount"),
..EntryNotes::default()
},
&rows(&ctx, width, true, "amount"),
)
.spans
.iter()
.filter(|s| s.style.add_modifier.contains(Modifier::UNDERLINED))
.map(|s| s.content.to_string())
.collect()
};
assert_eq!(
marks(200).join(""),
"amount",
"every letter of the match is marked when the note is whole"
);
let mut named = row("s3://prod@bucket/exports", EntryKind::MultiFile);
named.size = Some(4096);
named.rows = Some(12);
named.cols = Some(3);
named.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 12000)],
..Default::default()
};
let sources = ["prod".to_string()];
let offset = |width: usize| -> usize {
let line = entry_line(
&named,
false,
EntryNotes {
known_sources: Some(&sources),
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
);
offset_of_meta(line, &named)
};
for width in [22usize, 24, 30] {
assert_eq!(
offset(width),
meta_starts_at(&short, width),
"a label under a named source gives way like any other, at {width}"
);
}
let noted = |entry: &Entry, width: usize| -> usize {
let line = entry_line(
entry,
false,
EntryNotes {
matched_column: Some("transaction_amount"),
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
);
offset_of_meta(line, entry)
};
for width in [20usize, 24, 30, 48] {
assert_eq!(
noted(&entry, width),
noted(&short, width),
"a matched row is drawn by its note, whatever its label would say, \
at {width}"
);
assert_eq!(
noted(&short, width),
meta_starts_at(&short, width),
"a column note too long for the row is cut, at {width}"
);
}
}
#[test]
fn an_empty_current_directory_says_so_and_points_at_the_path_prompt() {
let ctx = RenderContext::for_test();
let section = Section {
door: None,
title: "/home/me/empty".to_string(),
subtitle: None,
origin: Some(crate::home::RootOrigin::Cwd.note()),
rows: Vec::new(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
place_labels: Default::default(),
root: None,
};
let text = |matches: usize, section: &Section| -> String {
section_header(section, matches, false, false, &header_row(&ctx, 80))
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let empty = text(0, §ion);
assert!(empty.contains("nothing to open here"), "{empty:?}");
assert!(empty.contains("~ types a path"), "{empty:?}");
assert!(!text(3, §ion).contains("nothing to open"));
let configured = Section {
origin: Some("configured"),
..section
};
assert!(!text(0, &configured).contains("nothing to open"));
}
#[test]
fn the_origin_chip_sits_by_the_count_and_the_state_by_the_rule() {
let ctx = RenderContext::for_test();
let section = Section {
door: None,
title: "/mnt/data".to_string(),
subtitle: Some("nfs4".to_string()),
origin: Some("configured"),
rows: Vec::new(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
place_labels: Default::default(),
root: None,
};
let text = |width: usize| -> String {
section_header(§ion, 12, false, false, &header_row(&ctx, width))
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let wide = text(80);
let count = wide.find(" 12 ").expect("count chip");
let origin = wide.find(" configured ").expect("origin chip");
let state = wide.find("nfs4").expect("state note");
assert!(count < origin && origin < state, "{wide:?}");
assert_eq!(wide.chars().count(), 80);
let narrow = text(40);
assert_eq!(narrow.chars().count(), 40, "{narrow:?}");
assert!(
narrow.contains(" configured ") && narrow.contains("nfs4"),
"{narrow:?}"
);
assert!(narrow.contains("/mnt/data"), "{narrow:?}");
for width in [30usize, 24, 16] {
let tight = text(width);
assert_eq!(tight.chars().count(), width, "{tight:?}");
assert!(tight.contains("ta"), "{width}: {tight:?}");
}
let bare = text(20);
assert!(bare.contains("/mnt/data"), "{bare:?}");
assert!(
!bare.contains("configured") && !bare.contains("nfs4"),
"{bare:?}"
);
}
#[test]
fn a_long_path_leaves_the_focused_heading_its_rule() {
let ctx = RenderContext::for_test();
let g = glyphs::get();
let section = Section {
door: None,
title: format!("/var/folders/{}", "x".repeat(120)),
subtitle: None,
origin: Some("configured"),
rows: Vec::new(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
place_labels: Default::default(),
root: None,
};
for width in [40usize, 80, 120] {
let text: String = section_header(§ion, 60, false, true, &header_row(&ctx, width))
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text.chars().count(), width, "{text:?}");
assert!(text.contains(g.rule_h_focused), "{width}: {text:?}");
}
}
#[test]
fn an_unmeasured_recent_from_a_store_shows_an_ellipsis_for_its_shape() {
let ctx = RenderContext::for_test();
let g = glyphs::get();
let mut entry = row("s3://bucket/sales/", EntryKind::MultiFile);
entry.cost.source = Some("s3".to_string());
let text = |entry: &Entry, indent: usize| -> String {
entry_line(
entry,
false,
EntryNotes {
indent,
..EntryNotes::default()
},
&rows(&ctx, 60, true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let meta = meta_columns(&entry, true, None, None);
assert!(text(&entry, NEST_INDENT).contains(&meta));
assert!(meta.contains(g.ellipsis), "{meta:?}");
assert!(!text(&entry, 0).contains(g.ellipsis));
let mut local = row("/data/sales.csv", EntryKind::File);
local.cost.source = Some("ext4".to_string());
local.size = Some(10);
assert!(!text(&local, NEST_INDENT).contains(g.ellipsis));
entry.rows = Some(20);
entry.cols = Some(3);
assert!(!text(&entry, NEST_INDENT).contains(g.ellipsis));
}
#[test]
fn a_place_row_carries_the_label_the_index_remembers_and_drops_it_before_the_path() {
let ctx = RenderContext::for_test();
let text = |label: Option<&str>, name_width: usize| -> String {
place_line(
std::path::Path::new("/mnt/data/sets/bitcoin"),
label,
Some("nfs4"),
false,
name_width,
false,
&ctx,
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let wide = text(Some("2 parquet"), 60);
assert!(wide.contains("bitcoin/ 2 parquet"), "{wide:?}");
assert!(wide.trim_end().ends_with("nfs4"), "{wide:?}");
assert_eq!(wide.chars().count(), row_width(60, false));
let tight = text(Some("2 parquet"), 22);
assert!(!tight.contains("parquet"), "{tight:?}");
assert!(tight.contains("bitcoin/"), "{tight:?}");
assert_eq!(tight.chars().count(), row_width(22, false));
}
#[test]
fn a_nested_row_keeps_the_meta_columns_where_the_place_row_ends() {
let ctx = RenderContext::for_test();
let mut entry = row("/data/exports", EntryKind::MultiFile);
entry.size = Some(4096);
entry.rows = Some(12);
entry.cols = Some(3);
entry.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 5000)],
truncated: true,
..Default::default()
};
let meta = meta_columns(&entry, false, None, None);
let meta_at = |indent: usize, width: usize| -> usize {
let line = entry_line(
&entry,
false,
EntryNotes {
indent,
..EntryNotes::default()
},
&rows(&ctx, width, true, ""),
);
let at = line
.spans
.iter()
.position(|s| s.content == meta)
.expect("the meta columns are drawn");
line.spans[..at]
.iter()
.map(|s| s.content.chars().count())
.sum()
};
for width in [24usize, 30, 48, 100] {
assert_eq!(
meta_at(NEST_INDENT, width),
meta_at(0, width),
"at {width} the indented row's meta columns start where a plain row's do"
);
}
assert_eq!(meta.chars().count(), META_COLUMNS_WIDTH);
let drawn = |line: Line<'_>| -> usize {
line.spans.iter().map(|s| s.content.chars().count()).sum()
};
for name_width in [60usize, 24] {
let entry_width = drawn(entry_line(
&entry,
false,
EntryNotes {
indent: NEST_INDENT,
..EntryNotes::default()
},
&rows(&ctx, name_width, true, ""),
));
assert_eq!(entry_width, row_width(name_width, true));
let place = place_line(
std::path::Path::new("/data"),
None,
Some("nfs4"),
false,
name_width,
true,
&ctx,
);
assert_eq!(drawn(place), entry_width, "{name_width}");
let more = more_line(3, 2, false, name_width, true, &ctx);
assert_eq!(drawn(more), entry_width, "{name_width}");
}
}
#[test]
fn a_place_row_names_only_a_filesystem_worth_naming() {
let ctx = RenderContext::for_test();
let text = |source: Option<&str>| -> String {
place_line(
std::path::Path::new("/data/x"),
None,
source,
false,
40,
false,
&ctx,
)
.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
};
assert!(
text(Some("ext4")).trim_end().ends_with("/data/x/"),
"{:?}",
text(Some("ext4"))
);
assert!(
text(Some("nfs4")).trim_end().ends_with("nfs4"),
"{:?}",
text(Some("nfs4"))
);
assert!(
text(Some("s3")).trim_end().ends_with("s3"),
"{:?}",
text(Some("s3"))
);
assert!(
text(None).trim_end().ends_with("/data/x/"),
"{:?}",
text(None)
);
let long = place_line(
std::path::Path::new("/very/deeply/nested/place/on/a/share/somewhere/far"),
None,
Some("nfs4"),
false,
27,
false,
&ctx,
);
let drawn: String = long.spans.iter().map(|s| s.content.to_string()).collect();
assert_eq!(drawn.chars().count(), row_width(27, false), "{drawn:?}");
assert!(drawn.contains("far/"), "{drawn:?}");
}
#[test]
fn the_more_row_counts_rows_and_places() {
let ctx = RenderContext::for_test();
let text = |hidden: usize, places: usize| -> String {
more_line(hidden, places, false, 40, false, &ctx)
.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
.trim_end()
.to_string()
};
let g = glyphs::get();
assert_eq!(
text(13, 5),
format!("{}{} 13 more in 5 places", g.selector_blank, g.ellipsis)
);
assert_eq!(
text(1, 1),
format!("{}{} 1 more in 1 place", g.selector_blank, g.ellipsis)
);
}
#[test]
fn a_local_directory_keeps_its_slash_however_its_name_is_spelled() {
for name in ["project.old", "v1.2", "site.com", "datui.git", "plain"] {
let entry = row(&format!("/home/derek/{name}"), EntryKind::Unknown);
assert!(
shows_as_a_place(&entry),
"{name} is a directory the listing saw"
);
}
}
#[test]
fn a_remote_row_is_read_as_a_prefix_only_when_its_name_is_not_a_file() {
for prefix in ["s3://bucket", "https://host"] {
for name in ["export.txt", "part-00000.parquet", "notes.md"] {
assert!(
!shows_as_a_place(&row(&format!("{prefix}/{name}"), EntryKind::Unknown)),
"{prefix}/{name} is named like a file"
);
}
}
for prefix in ["s3://bucket", "https://host"] {
for name in ["exports", "2024", "raw"] {
assert!(
shows_as_a_place(&row(&format!("{prefix}/{name}"), EntryKind::Unknown)),
"{prefix}/{name} is a prefix"
);
}
}
}
fn header_width(section: &Section, width: usize) -> usize {
let ctx = RenderContext::for_test();
let line = section_header(section, 3, false, false, &header_row(&ctx, width));
line.spans.iter().map(|s| s.content.chars().count()).sum()
}
#[test]
fn a_long_note_never_pushes_the_header_past_the_screen() {
let section = Section {
door: None,
title: "Found".to_string(),
subtitle: Some(
"/very/deeply/nested/path/that/goes/on/and/on/for/quite/a/while · 99999 searched"
.to_string(),
),
origin: None,
rows: Vec::new(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
place_labels: Default::default(),
root: None,
};
for width in [20usize, 40, 80, 120] {
let rendered = header_width(§ion, width);
assert!(
rendered <= width,
"a {width}-wide screen produced a {rendered}-character header"
);
}
}
#[test]
fn a_recent_from_a_source_names_it_or_says_it_is_gone() {
let ctx = RenderContext::for_test();
let path = std::path::Path::new("s3://lab@data/sales.parquet");
let mut entry = Entry::for_test(path, "sales.parquet");
entry.kind = EntryKind::Unknown;
let text = |known: Option<&[String]>| -> String {
entry_line(
&entry,
false,
EntryNotes {
known_sources: known,
..EntryNotes::default()
},
&rows(&ctx, 80, false, ""),
)
.spans
.iter()
.map(|s| s.content.to_string())
.collect()
};
let lab = ["lab".to_string()];
assert!(text(Some(&lab)).contains("sales.parquet lab"));
assert!(text(Some(&[])).contains("source not found: lab"));
assert!(!text(None).contains("lab"));
}
fn meta_offset(line: &Line) -> usize {
let spans = &line.spans[..line.spans.len().saturating_sub(2)];
spans
.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum()
}
#[test]
fn the_pane_does_not_say_what_a_directory_holds_twice() {
let mut entry = Entry::for_test(std::path::Path::new("/data/consolidated"), "consolidated");
entry.kind = EntryKind::Directory;
entry.holds = crate::discover::Holds {
formats: vec![("parquet".to_string(), 12)],
..Default::default()
};
let text = preview_text(&entry, 60);
assert!(text.contains("12 parquet"), "{text}");
assert_eq!(
text.matches("12 parquet").count(),
1,
"the label and the line are the same words: {text}"
);
entry.holds.directories = 3;
let text = preview_text(&entry, 60);
assert!(text.contains("contains"), "{text}");
assert!(text.contains("3 directories"), "{text}");
entry.holds = crate::discover::Holds {
not_read: 10,
..Default::default()
};
let text = preview_text(&entry, 60);
assert!(!text.contains("10"), "{text}");
assert!(text.contains("no data files"), "{text}");
}
#[test]
fn no_mark_ever_lands_on_a_cut_notes_ellipsis() {
let ctx = RenderContext::for_test();
let entry = Entry::for_test(std::path::Path::new("/tmp/x"), "x");
let g = glyphs::get();
for width in 1..=40usize {
let line = entry_line(
&entry,
false,
EntryNotes {
matched_column: Some("transaction_amount_usd"),
known_sources: Some(&[]),
..EntryNotes::default()
},
&rows(&ctx, width, false, "usd"),
);
let marked: String = line
.spans
.iter()
.filter(|s| s.style.add_modifier.contains(Modifier::UNDERLINED))
.map(|s| s.content.to_string())
.collect();
assert!(
!marked.contains(g.ellipsis),
"at {width} cells a mark landed on the cut note's ellipsis: {marked:?}"
);
let sep = format!(" {}", g.middot);
let note: String = line
.spans
.iter()
.skip_while(|s| s.content != sep)
.skip(1)
.map(|s| s.content.to_string())
.collect();
assert_ne!(
note.trim_end(),
g.ellipsis,
"at {width} cells the note was cut down to an ellipsis and said nothing"
);
let note = note.trim_end();
if note.chars().count() >= "transaction_amount_usd".chars().count() {
assert_eq!(
note, "transaction_amount_usd",
"at {width} cells a note that fitted was cut anyway"
);
}
}
}
#[test]
fn a_column_note_on_a_row_from_a_source_leaves_the_meta_columns_alone() {
let ctx = RenderContext::for_test();
let known = ["prod".to_string()];
let mut plain = Entry::for_test(
std::path::Path::new("s3://bucket/sales.parquet"),
"sales.parquet",
);
plain.kind = EntryKind::Unknown;
let mut sourced = Entry::for_test(
std::path::Path::new("s3://prod@bucket/sales.parquet"),
"sales.parquet",
);
sourced.kind = EntryKind::Unknown;
for width in 6..=30usize {
let with_note = entry_line(
&sourced,
false,
EntryNotes {
matched_column: Some("customer_identifier"),
known_sources: Some(&known),
..EntryNotes::default()
},
&rows(&ctx, width, true, "cust"),
);
let without = entry_line(
&plain,
false,
EntryNotes {
matched_column: Some("customer_identifier"),
known_sources: Some(&[]),
..EntryNotes::default()
},
&rows(&ctx, width, true, "cust"),
);
assert_eq!(
meta_offset(&with_note),
meta_offset(&without),
"at {width} cells the source id moved the meta columns"
);
}
}
fn row_spans(name: &str, filter: &str, column: Option<&str>) -> Vec<(String, bool)> {
let ctx = RenderContext::for_test();
let entry = Entry::for_test(std::path::Path::new("/tmp/x"), name);
let line = entry_line(
&entry,
false,
EntryNotes {
matched_column: column,
known_sources: Some(&[]),
..EntryNotes::default()
},
&rows(&ctx, 60, false, filter),
);
line.spans
.iter()
.skip(1) .map(|s| {
(
s.content.to_string(),
s.style.add_modifier.contains(Modifier::UNDERLINED),
)
})
.collect()
}
fn highlighted_text(spans: &[(String, bool)]) -> String {
spans
.iter()
.filter(|(_, hit)| *hit)
.map(|(t, _)| t.as_str())
.collect()
}
fn preview_text(entry: &Entry, width: usize) -> String {
let ctx = RenderContext::for_test();
preview_head(entry, None, None, width, &ctx)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn costed(name: &str, cost: crate::discover::Cost, size: Option<u64>) -> Entry {
let mut e = Entry::for_test(std::path::Path::new("/tmp/x"), name);
e.size = size;
e.cost = cost;
e
}
#[test]
fn only_a_file_of_real_tables_contains_tables() {
let db = costed(
"shop.db",
crate::discover::Cost {
tables: Some(3),
..Default::default()
},
None,
);
let shown = preview_text(&db, 60);
assert!(
shown
.lines()
.any(|l| l.starts_with("contains") && l.ends_with(" 3 tables")),
"{shown}"
);
let mut spec_file = costed(
"day.ord",
crate::discover::Cost {
tables: Some(5),
..Default::default()
},
None,
);
spec_file.format_spec = Some("acme.orders".into());
let shown = preview_text(&spec_file, 60);
assert!(!shown.contains("tables"), "{shown}");
assert!(shown.contains("acme.orders file"), "{shown}");
}
#[test]
fn a_network_source_is_named_rather_than_described() {
let e = costed(
"prices.parquet",
crate::discover::Cost {
source: Some("nfs4".into()),
..Default::default()
},
None,
);
let text = preview_text(&e, 44);
assert!(text.contains("nfs4"), "{text}");
}
#[test]
fn local_disk_gets_no_warning() {
let e = costed(
"prices.parquet",
crate::discover::Cost {
source: Some("ext4".into()),
..Default::default()
},
None,
);
let text = preview_text(&e, 44);
assert!(text.contains("ext4"), "{text}");
assert!(
!text.contains("network"),
"an ordinary disk should say nothing alarming: {text}"
);
}
#[test]
fn what_a_file_weighs_open_is_stated_with_its_ratio() {
let e = costed(
"prices.parquet",
crate::discover::Cost {
source: Some("ext4".into()),
uncompressed: Some(2_000_000_000),
codec: Some("zstd".into()),
row_groups: Some(12),
..Default::default()
},
Some(200_000_000),
);
let text = preview_text(&e, 44);
assert!(text.contains("in memory"), "{text}");
assert!(text.contains("zstd"), "{text}");
assert!(text.contains("10.0"), "the ratio should be stated: {text}");
assert!(text.contains("row groups"), "{text}");
}
#[test]
fn a_ratio_too_small_to_matter_is_left_out() {
let e = costed(
"prices.parquet",
crate::discover::Cost {
source: Some("ext4".into()),
uncompressed: Some(1_050_000),
codec: Some("uncompressed".into()),
..Default::default()
},
Some(1_000_000),
);
let text = preview_text(&e, 44);
assert!(!text.contains("1.0×") && !text.contains("1.1×"), "{text}");
}
#[test]
fn a_partition_layout_names_its_keys_and_its_range() {
let e = costed(
"events",
crate::discover::Cost {
source: Some("nfs4".into()),
partitions: Some(crate::discover::Partitions {
keys: vec!["year".into(), "region".into()],
first_key_values: vec!["2023".into(), "2024".into(), "2025".into()],
count: 3,
more: false,
}),
..Default::default()
},
None,
);
let text = preview_text(&e, 44);
assert!(text.contains("3 by year, region"), "{text}");
assert!(text.contains("year 2023 to 2025"), "{text}");
}
#[test]
fn a_bounded_partition_count_says_it_is_a_floor() {
let e = costed(
"daily",
crate::discover::Cost {
partitions: Some(crate::discover::Partitions {
keys: vec!["day".into()],
first_key_values: vec!["0001".into()],
count: 512,
more: true,
}),
..Default::default()
},
None,
);
assert!(preview_text(&e, 44).contains("512+"));
}
#[test]
fn the_preview_never_draws_past_its_pane() {
let e = costed(
"a_dataset_with_a_very_long_name_indeed.parquet",
crate::discover::Cost {
source: Some("fuse.sshfs".into()),
uncompressed: Some(9_000_000_000),
codec: Some("zstd".into()),
row_groups: Some(1024),
partitions: Some(crate::discover::Partitions {
keys: vec!["year".into(), "month".into(), "day".into()],
first_key_values: vec!["2001".into(), "2025".into()],
count: 9999,
more: true,
}),
tables: None,
opens_one: false,
ipc_stream: false,
},
Some(400_000_000),
);
for width in [24usize, 40, 80] {
let ctx = RenderContext::for_test();
for line in preview_head(&e, None, None, width, &ctx) {
let text: String = l_text(&line);
if text.trim_start().starts_with("OPENING") {
assert!(
text.chars().count() <= width,
"a {width}-wide pane drew a {}-character heading",
text.chars().count()
);
}
}
}
}
fn l_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn the_matched_characters_are_the_highlighted_ones() {
let spans = row_spans("sales_2024.parquet", "sales", None);
assert_eq!(highlighted_text(&spans), "sales");
let text: String = spans.iter().map(|(t, _)| t.as_str()).collect();
assert!(text.contains("sales_2024.parquet"), "got {text:?}");
}
#[test]
fn a_scattered_match_highlights_each_run_separately() {
let spans = row_spans("sales_by_region.parquet", "sreg", None);
assert_eq!(highlighted_text(&spans), "sreg");
let runs = spans.iter().filter(|(_, hit)| *hit).count();
assert!(
runs >= 2,
"a match spread across the name should be several runs, got {runs}"
);
}
#[test]
fn consecutive_matches_become_one_span_not_a_stutter() {
let spans = row_spans("sales.parquet", "sales", None);
let runs = spans.iter().filter(|(_, hit)| *hit).count();
assert_eq!(runs, 1, "five adjacent characters are one run, got {runs}");
}
#[test]
fn nothing_is_highlighted_without_a_filter() {
let spans = row_spans("sales.parquet", "", None);
assert_eq!(highlighted_text(&spans), "");
}
#[test]
fn a_row_matched_by_a_column_highlights_the_column_not_the_name() {
let spans = row_spans("orders.parquet", "cust", Some("customer_id"));
assert_eq!(highlighted_text(&spans), "cust");
let text: String = spans.iter().map(|(t, _)| t.as_str()).collect();
let note = format!("{}customer_id", glyphs::get().middot);
assert!(
text.contains(¬e),
"the column note should still read whole, got {text:?}"
);
}
#[test]
fn the_title_survives_a_note_that_wants_the_whole_line() {
let section = Section {
door: None,
title: "Found".to_string(),
subtitle: Some("x".repeat(200)),
origin: None,
rows: Vec::new(),
unavailable: false,
unavailable_note: None,
folded_by_default: false,
remote_root: None,
waiting: false,
grouped_by_place: false,
place_labels: Default::default(),
root: None,
};
let ctx = RenderContext::for_test();
let line = section_header(§ion, 3, false, false, &header_row(&ctx, 40));
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("FOUND"),
"the title should still be readable, got {text:?}"
);
}
#[test]
fn the_door_keeps_what_it_opens_when_its_name_is_cut() {
let ctx = RenderContext::for_test();
let mut door = row("/data/same_schema/", EntryKind::MultiFile);
door.name = "same_schema (3 Parquet files, one schema)".to_string();
door.opens_whole_directory = true;
door.rows = Some(6);
door.cols = Some(2);
let drawn = |width: usize| -> String {
entry_line(
&door,
true,
EntryNotes::default(),
&rows(&ctx, width.saturating_sub(META_WIDTH as usize), true, ""),
)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
let narrow = drawn(78);
assert!(
narrow.contains("(3 Parquet files, one schema)"),
"{narrow:?}"
);
assert!(!narrow.contains("same_schema ("), "{narrow:?}");
assert!(
narrow.contains("6 × 2") || narrow.contains("6 x 2"),
"{narrow:?}"
);
assert_eq!(
narrow.chars().count(),
row_width(78 - META_WIDTH as usize, true),
"the columns stay where they are: {narrow:?}"
);
let narrowest = drawn(58);
assert!(narrowest.contains("(3 Parquet files"), "{narrowest:?}");
assert!(!narrowest.contains("same_"), "{narrowest:?}");
assert_eq!(
narrowest.chars().count(),
row_width(58 - META_WIDTH as usize, true),
"{narrowest:?}"
);
let wide = drawn(200);
assert!(
wide.contains("same_schema (3 Parquet files, one schema)"),
"{wide:?}"
);
}
fn texts(lines: &[Line]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn a_long_path_is_cut_in_the_middle_not_wrapped() {
let e = glyphs::get().ellipsis;
let path = "~/.config/datui/formats/demo-mktdata.toml";
assert_eq!(elide_path(path, 60), path);
assert_eq!(
elide_path(path, 30),
format!("~/{e}/formats/demo-mktdata.toml")
);
assert!(elide_path(path, 30).chars().count() <= 30);
assert_eq!(elide_path(path, 22), format!("~/{e}/demo-mktdata.toml"));
assert_eq!(
elide_path("/a/b/c/long-name.toml", 17),
format!("/{e}/long-name.toml")
);
let cut = elide_path(path, 10);
assert!(cut.ends_with("ta.toml"), "{cut}");
assert!(glyphs::display_width(&cut) <= 10, "{cut}");
}
#[cfg(windows)]
#[test]
fn a_windows_path_is_cut_at_its_backslashes() {
let e = glyphs::get().ellipsis;
let path = r"~\.config\datui\formats\demo-mktdata.toml";
assert_eq!(
elide_path(path, 30),
format!(r"~\{e}\formats\demo-mktdata.toml")
);
}
fn spec_chips() -> Vec<MatchChip> {
let spec = crate::formats::Spec::parse(
r#"name = "acme.chips"
match = { glob = ["*.bin", "*.dat"], magic = "MKTD", where = { "header.version" = 1, "header.kind" = "A" } }
[header]
fields = [
{ name = "magic", type = "str", size = 4 },
{ name = "version", type = "u2" },
{ name = "kind", type = "str", size = 1 },
]
[records]
fields = [{ name = "x", type = "u1" }]
"#,
None,
)
.unwrap();
spec.match_chips()
}
#[test]
fn match_chips_wrap_between_whole_chips() {
let ctx = RenderContext::for_test();
let chips = spec_chips();
for filled in [true, false] {
for width in [24, 30, 40, 80] {
let lines = chip_fact_lines("match", &chips, 5, width, filled, &ctx);
let shown = texts(&lines);
for line in &shown {
assert!(
glyphs::display_width(line) <= width,
"{line:?} wider than {width}"
);
}
let kind = if filled { "kind A" } else { "kind \"A\"" };
let whole: &[&str] = if width < 30 {
&[]
} else {
&["magic MKTD", "version 1", kind, "glob *.bin *.dat"]
};
for chip in whole {
assert!(
shown.iter().any(|l| l.contains(chip)),
"{chip:?} whole on one line at {width}: {shown:#?}"
);
}
assert!(shown[0].starts_with("match "), "{shown:?}");
assert!(
shown[1..].iter().all(|l| l.starts_with(" ")),
"wrapped under the value: {shown:#?}"
);
}
}
let wide = texts(&chip_fact_lines("match", &chips, 5, 80, false, &ctx));
assert_eq!(
wide,
["match [magic MKTD] [kind \"A\"] [version 1] [glob *.bin *.dat]"]
);
let wide = chip_fact_lines("match", &chips, 5, 80, true, &ctx);
let magic = &wide[0].spans[1..6];
assert!(
magic
.iter()
.all(|s| s.style.bg == Some(ctx.table_header_bg)),
"{magic:?}"
);
assert_eq!(magic[1].style.fg, Some(ctx.dimmed));
assert_eq!(magic[3].style.fg, Some(ctx.str_col));
let one = wide[0]
.spans
.iter()
.find(|s| s.content == "1")
.expect("the version");
assert_eq!(one.style.fg, Some(ctx.int_col), "{wide:?}");
}
#[test]
fn a_spec_without_a_match_says_so() {
let ctx = RenderContext::for_test();
let lines = chip_fact_lines("match", &[], 5, 40, true, &ctx);
assert_eq!(texts(&lines), ["match no match"]);
}
#[test]
fn a_long_field_name_is_cut_before_its_value() {
use crate::formats::ChipKind;
let ctx = RenderContext::for_test();
let e = glyphs::get().ellipsis;
let chips = [
MatchChip {
name: "magic".to_string(),
value: "MKTD".to_string(),
kind: ChipKind::Magic,
offset: None,
},
MatchChip {
name: "exchange_feed_sequence_version".to_string(),
value: "3".to_string(),
kind: ChipKind::Int,
offset: None,
},
];
for filled in [true, false] {
let shown = texts(&chip_fact_lines("match", &chips, 8, 40, filled, &ctx));
assert_eq!(shown.len(), 2, "{shown:#?}");
for line in &shown {
assert!(glyphs::display_width(line) <= 40, "{line:?}");
}
let long = &shown[1];
assert!(long.starts_with(" "), "{shown:#?}");
let chip = long.trim();
let (open, close) = if filled { ("", "") } else { ("[", "]") };
assert!(
chip.starts_with(&format!("{open}exchange_feed")),
"{chip:?}"
);
assert!(chip.ends_with(&format!("{e} 3{close}")), "{chip:?}");
}
let glob = MatchChip {
name: "glob".to_string(),
value: "*.alpha *.bravo *.charlie *.delta *.echo".to_string(),
kind: ChipKind::Glob,
offset: None,
};
let shown = texts(&chip_fact_lines("match", &[glob], 5, 30, false, &ctx));
assert_eq!(shown.len(), 1, "{shown:#?}");
assert!(shown[0].starts_with("match [glob *.alpha"), "{shown:#?}");
assert!(shown[0].ends_with(&format!("{e}]")), "{shown:#?}");
assert_eq!(glyphs::display_width(&shown[0]), 30, "{shown:#?}");
}
#[test]
fn variants_pack_densely_and_break_between_items() {
let ctx = RenderContext::for_test();
let table = |name: &str, n: usize| crate::members::Table {
name: name.to_string(),
kind: "record type".to_string(),
internal: false,
columns: (0..n).map(|i| (format!("c{i}"), String::new())).collect(),
};
let variants = [
table("Status", 6),
table("OrderAdd", 9),
table("OrderCancel", 6),
table("Trade", 9),
table("Fill", 9),
];
let m = glyphs::get().middot;
let wide = texts(&variant_lines(&variants, 8, 80, 10, &ctx));
assert_eq!(
wide,
[format!(
" Status 6 {m} OrderAdd 9 {m} OrderCancel 6 {m} Trade 9 {m} Fill 9"
)]
);
let narrow = texts(&variant_lines(&variants, 8, 40, 10, &ctx));
assert!(narrow.len() > 1, "{narrow:#?}");
for line in &narrow {
assert!(glyphs::display_width(line) <= 40, "{line:?}");
assert!(!line.trim_end().ends_with(m), "{line:?}");
}
for item in [
"Status 6",
"OrderAdd 9",
"OrderCancel 6",
"Trade 9",
"Fill 9",
] {
assert!(
narrow.iter().any(|l| l.contains(item)),
"{item}: {narrow:#?}"
);
}
let cut = texts(&variant_lines(&variants, 8, 30, 2, &ctx));
assert_eq!(cut.len(), 2, "{cut:#?}");
assert!(cut[1].trim_start().ends_with("more"), "{cut:#?}");
}
#[test]
fn pane_headings_put_counts_in_a_chip() {
let ctx = RenderContext::for_test();
let text = |line: &Line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
};
let rows = rows_heading(5, 20, 40, &ctx);
assert!(
text(&rows).starts_with("ROWS 5 of 20 columns "),
"{:?}",
text(&rows)
);
let chip = rows
.spans
.iter()
.find(|s| s.content.contains("5 of 20"))
.unwrap();
assert_eq!(chip.style.bg, Some(ctx.controls_bg));
assert!(text(&rows_heading(5, 5, 40, &ctx)).starts_with("ROWS "));
let columns = pane_heading_counted("COLUMNS", Some("1,200"), 40, &ctx);
assert!(
text(&columns).starts_with("COLUMNS 1,200 "),
"{:?}",
text(&columns)
);
assert_eq!(
text(&columns).chars().count(),
39,
"as wide as a heading without one"
);
}
#[test]
fn column_notes_are_listed_under_columns_and_counted_when_cut() {
let ctx = RenderContext::for_test();
let columns: Vec<(String, crate::catalog::ColumnNote)> = (0..6)
.map(|i| {
let note = crate::catalog::ColumnNote {
description: format!("Note {i}"),
unit: if i == 0 { "USD".into() } else { String::new() },
values: Vec::new(),
ty: String::new(),
};
(format!("col{i}"), note)
})
.collect();
let whole = texts(&column_notes_block(&columns, 50, 20, &ctx));
assert!(whole[1].starts_with("COLUMNS "), "{whole:#?}");
assert_eq!(whole.len(), 2 + 6, "{whole:#?}");
assert!(whole[2].contains("col0") && whole[2].contains("Note 0 (USD)"));
assert!(!whole.iter().any(|l| l.contains("^E")), "{whole:#?}");
let cut = texts(&column_notes_block(&columns, 50, 6, &ctx));
assert_eq!(cut.len(), 6, "{cut:#?}");
let more = format!("{} 3 more", glyphs::get().ellipsis);
assert_eq!(cut.last(), Some(&more), "{cut:#?}");
assert!(!cut.iter().any(|l| l.contains("^E")), "{cut:#?}");
}
}