use std::collections::HashSet;
use std::sync::Arc;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use unicode_width::UnicodeWidthStr;
use crate::formats::SpecDocs;
use crate::glyphs;
use crate::home::catalog::{ColumnNote, Dataset};
use crate::render::context::RenderContext;
use crate::widgets::ui::{HintBar, SectionRule, Surface};
const MEASURE: u16 = 110;
#[derive(Debug, Clone, Default)]
pub struct Documented {
pub catalog: Option<(String, Arc<Dataset>)>,
pub spec: Option<Arc<SpecDocs>>,
pub name: String,
}
impl Documented {
pub fn new(
catalog: Option<(String, Arc<Dataset>)>,
spec: Option<Arc<SpecDocs>>,
name: String,
) -> Option<Self> {
(catalog.is_some() || spec.is_some()).then_some(Self {
catalog,
spec,
name,
})
}
pub fn title(&self) -> &str {
match &self.catalog {
Some((_, entry)) => &entry.name,
None => &self.name,
}
}
pub fn columns(&self) -> Vec<(String, ColumnNote)> {
let catalog: &[(String, ColumnNote)] = self
.catalog
.as_ref()
.map_or(&[], |(_, entry)| &entry.columns);
let spec: &[(String, ColumnNote)] = self.spec.as_ref().map_or(&[], |s| &s.columns);
let noted = |name: &str| catalog.iter().find(|(n, _)| n == name).map(|(_, n)| n);
let mut out: Vec<(String, ColumnNote)> = spec
.iter()
.map(|(name, note)| {
let note = match noted(name) {
Some(over) => layered(over, note),
None => note.clone(),
};
(name.clone(), note)
})
.collect();
out.extend(
catalog
.iter()
.filter(|(name, _)| !spec.iter().any(|(n, _)| n == name))
.cloned(),
);
out
}
}
fn layered(over: &ColumnNote, under: &ColumnNote) -> ColumnNote {
let pick = |a: &String, b: &String| if a.is_empty() { b } else { a }.clone();
ColumnNote {
description: pick(&over.description, &under.description),
unit: pick(&over.unit, &under.unit),
values: if over.values.is_empty() {
under.values.clone()
} else {
over.values.clone()
},
ty: pick(&over.ty, &under.ty),
}
}
fn field_section(out: &mut Vec<DocLine>, title: &'static str, fields: &[(String, ColumnNote)]) {
if fields.is_empty() {
return;
}
out.push(DocLine::Blank);
out.push(DocLine::Section(title, fields.len()));
for (name, note) in fields {
out.push(DocLine::Column {
name: name.clone(),
about: note.about(),
values: 0,
});
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DocLine {
About(String),
Field(&'static str, String),
Link(&'static str, String),
Section(&'static str, usize),
Column {
name: String,
about: String,
values: usize,
},
Legend(String, String),
RecordType(String, String),
Bookmark(String, String),
Blank,
}
impl DocLine {
fn focusable(&self) -> bool {
!matches!(self, DocLine::Blank | DocLine::Section(..))
}
pub fn copy_text(&self) -> Option<&str> {
match self {
DocLine::Link(_, url) => Some(url),
DocLine::Field(_, value) => Some(value),
DocLine::Bookmark(_, path) => Some(path),
DocLine::Legend(code, _) => Some(code),
DocLine::Column { name, .. } => Some(name),
DocLine::RecordType(name, _) => Some(name),
DocLine::About(text) => Some(text),
DocLine::Section(..) | DocLine::Blank => None,
}
}
}
pub fn lines(doc: &Documented, expanded: &HashSet<String>, measured: Option<u64>) -> Vec<DocLine> {
let mut out = Vec::new();
let entry = doc.catalog.as_ref().map(|(_, entry)| entry.as_ref());
let spec = doc.spec.as_deref();
let first_said = |of_entry: Option<&str>, of_spec: Option<&str>| {
[of_entry, of_spec]
.into_iter()
.flatten()
.find(|text| !text.is_empty())
.unwrap_or_default()
.to_string()
};
let description = first_said(
entry.map(|e| e.description.as_str()),
spec.map(|s| s.description.as_str()),
);
if !description.is_empty() {
out.push(DocLine::About(description));
out.push(DocLine::Blank);
}
if let Some((catalog, entry)) = &doc.catalog {
catalog_fields(&mut out, entry, catalog, measured);
}
if let Some(spec) = spec {
out.push(DocLine::Field("format spec", spec.spec.clone()));
if let Some(file) = &spec.file {
out.push(DocLine::Field("spec file", crate::home::display_path(file)));
}
}
let documentation = first_said(
entry.map(|e| e.documentation.as_str()),
spec.map(|s| s.documentation.as_str()),
);
let links: Vec<(&'static str, String)> = [
(
"homepage",
entry.map(|e| e.homepage.clone()).unwrap_or_default(),
),
("documentation", documentation),
]
.into_iter()
.filter(|(_, url)| !url.is_empty())
.collect();
if !links.is_empty() {
out.push(DocLine::Blank);
out.push(DocLine::Section("LINKS", links.len()));
for (label, url) in links {
out.push(DocLine::Link(label, url));
}
}
if let Some(spec) = spec {
field_section(&mut out, "HEADER", &spec.header);
}
if let Some(spec) = spec.filter(|s| !s.record_types.is_empty()) {
let middot = glyphs::get().middot;
out.push(DocLine::Blank);
out.push(DocLine::Section("RECORD TYPES", spec.record_types.len()));
for record in &spec.record_types {
let columns = match record.columns {
1 => "1 column".to_string(),
n => format!("{n} columns"),
};
let mut about = format!("{} {middot} {columns}", record.picked_by);
if !record.description.is_empty() {
about = format!("{about} {middot} {}", record.description);
}
out.push(DocLine::RecordType(record.name.clone(), about));
}
}
let columns = doc.columns();
if !columns.is_empty() {
out.push(DocLine::Blank);
out.push(DocLine::Section("COLUMNS", columns.len()));
for (name, note) in &columns {
out.push(DocLine::Column {
name: name.clone(),
about: note.about(),
values: note.values.len(),
});
if expanded.contains(name) {
for (code, meaning) in ¬e.values {
let code = if code.is_empty() { "blank" } else { code };
out.push(DocLine::Legend(code.to_string(), meaning.clone()));
}
}
}
}
if let Some(spec) = spec {
field_section(&mut out, "FOOTER", &spec.footer);
}
if let Some(entry) = entry.filter(|e| !e.bookmarks.is_empty()) {
out.push(DocLine::Blank);
out.push(DocLine::Section("BOOKMARKS", entry.bookmarks.len()));
for (name, path) in &entry.bookmarks {
out.push(DocLine::Bookmark(name.clone(), path.clone()));
}
}
out
}
fn catalog_fields(out: &mut Vec<DocLine>, entry: &Dataset, catalog: &str, measured: Option<u64>) {
out.push(DocLine::Field("catalog", catalog.to_string()));
for (label, value) in [("publisher", &entry.publisher), ("license", &entry.license)] {
if !value.is_empty() {
out.push(DocLine::Field(label, value.clone()));
}
}
out.push(DocLine::Field("format", format_of(entry)));
match (&entry.path, &entry.url) {
(Some(_), _) => out.push(DocLine::Field(
"path",
crate::home::display_path(&entry.location()),
)),
(None, Some(url)) => {
out.push(DocLine::Link("url", url.clone()));
out.push(DocLine::Field("login", crate::home::login_of(entry)));
}
(None, None) => {}
}
match (measured, entry.size) {
(Some(size), _) => out.push(DocLine::Field("size", crate::numfmt::bytes(size))),
(None, Some(hint)) => out.push(DocLine::Field(
"size",
format!("~{}", crate::numfmt::bytes(hint)),
)),
(None, None) => {}
}
}
fn format_of(entry: &Dataset) -> String {
let location = entry.location();
let text = location.to_string_lossy();
if text.ends_with('/') || (entry.path.is_some() && location.is_dir()) {
return "directory".to_string();
}
crate::FileFormat::from_path(&location)
.map(|f| f.name().to_string())
.unwrap_or_else(|| {
if crate::home::catalog::is_object_store_dataset(&text) {
"directory".to_string()
} else {
"file".to_string()
}
})
}
#[derive(Debug, Clone, Default)]
pub struct DocState {
pub doc: Option<Documented>,
pub measured: Option<u64>,
pub cursor: usize,
pub scroll: usize,
pub expanded: HashSet<String>,
pub view_height: usize,
pub links_open: bool,
}
impl DocState {
pub fn open(&mut self, doc: Documented, measured: Option<u64>) {
*self = Self {
doc: Some(doc),
measured,
..Self::default()
};
self.cursor = self.first_focusable();
}
pub fn close(&mut self) {
*self = Self::default();
}
pub fn is_open(&self) -> bool {
self.doc.is_some()
}
pub fn lines(&self) -> Vec<DocLine> {
match &self.doc {
Some(doc) => lines(doc, &self.expanded, self.measured),
None => Vec::new(),
}
}
fn first_focusable(&self) -> usize {
self.lines()
.iter()
.position(DocLine::focusable)
.unwrap_or(0)
}
pub fn move_cursor(&mut self, delta: isize) {
let lines = self.lines();
let focusable: Vec<usize> = (0..lines.len()).filter(|&i| lines[i].focusable()).collect();
if focusable.is_empty() {
return;
}
let at = focusable
.iter()
.position(|&i| i >= self.cursor)
.unwrap_or(focusable.len() - 1) as isize;
let to = (at + delta).clamp(0, focusable.len() as isize - 1) as usize;
self.cursor = focusable[to];
}
pub fn toggle_legend(&mut self) -> bool {
let lines = self.lines();
let column = lines[..=self.cursor.min(lines.len().saturating_sub(1))]
.iter()
.rev()
.find_map(|line| match line {
DocLine::Column { name, values, .. } if *values > 0 => Some(name.clone()),
DocLine::Legend(..) => None,
_ => Some(String::new()),
})
.filter(|name| !name.is_empty());
let on_legend_or_column = matches!(
lines.get(self.cursor),
Some(DocLine::Legend(..)) | Some(DocLine::Column { values: 1.., .. })
);
let Some(column) = column.filter(|_| on_legend_or_column) else {
return false;
};
if !self.expanded.remove(&column) {
self.expanded.insert(column.clone());
}
let lines = self.lines();
if let Some(at) = lines
.iter()
.position(|l| matches!(l, DocLine::Column { name, values: 1.., .. } if *name == column))
&& !self.expanded.contains(&column)
{
self.cursor = at;
}
true
}
pub fn link(&self) -> Option<String> {
match self.lines().get(self.cursor) {
Some(DocLine::Link(_, url)) => Some(url.clone()),
_ => None,
}
}
pub fn offers_open(&self) -> bool {
self.links_open && self.link().is_some()
}
pub fn copy_text(&self) -> Option<String> {
self.lines()
.get(self.cursor)
.and_then(DocLine::copy_text)
.map(str::to_string)
}
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(8);
let mut rows = Vec::new();
let mut row = String::new();
for word in text.split_whitespace() {
let candidate = if row.is_empty() {
word.to_string()
} else {
format!("{row} {word}")
};
if candidate.width() <= width || row.is_empty() {
row = candidate;
while row.width() > width {
let head: String = row.chars().take(width).collect();
let rest: String = row.chars().skip(width).collect();
rows.push(head);
row = rest;
}
} else {
rows.push(std::mem::take(&mut row));
row = word.to_string();
}
}
if !row.is_empty() || rows.is_empty() {
rows.push(row);
}
rows
}
fn layout(
lines: &[DocLine],
width: usize,
cursor: usize,
ctx: &RenderContext,
) -> (Vec<Line<'static>>, Vec<usize>) {
let g = glyphs::get();
let key_w = lines
.iter()
.filter_map(|l| match l {
DocLine::Field(label, _) | DocLine::Link(label, _) => Some(label.len()),
_ => None,
})
.max()
.unwrap_or(8)
+ 2;
let col_w = lines
.iter()
.filter_map(|l| match l {
DocLine::Column { name, .. }
| DocLine::Bookmark(name, _)
| DocLine::RecordType(name, _) => Some(name.width()),
_ => None,
})
.max()
.unwrap_or(0)
.min(width / 3)
+ 2;
let label = Style::default().fg(ctx.label);
let plain = Style::default().fg(ctx.text_primary);
let dim = Style::default().fg(ctx.dimmed);
let inner = width.saturating_sub(1);
let mut rows: Vec<Line<'static>> = Vec::new();
let mut starts = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
starts.push(rows.len());
let focused = i == cursor;
let rail = if focused { g.rail } else { " " };
let rail = Span::styled(rail, Style::default().fg(ctx.accent));
let key_style = if focused {
Style::default().fg(ctx.accent)
} else {
label
};
let mut push = |spans: Vec<Span<'static>>, first: bool| {
let mut all = vec![if first { rail.clone() } else { Span::raw(" ") }];
all.extend(spans);
let mut line = Line::from(all);
if focused {
line = line.patch_style(ctx.highlight_style());
}
rows.push(line);
};
match line {
DocLine::Blank => rows.push(Line::from("")),
DocLine::Section(..) => rows.push(Line::from("")),
DocLine::About(text) => {
for (n, row) in wrap(text, inner).into_iter().enumerate() {
push(vec![Span::styled(row, plain)], n == 0);
}
}
DocLine::Field(key, value) => {
let room = inner.saturating_sub(key_w);
for (n, row) in wrap(value, room).into_iter().enumerate() {
let head = if n == 0 {
format!("{key:<key_w$}")
} else {
" ".repeat(key_w)
};
push(
vec![Span::styled(head, key_style), Span::styled(row, plain)],
n == 0,
);
}
}
DocLine::Link(key, url) => {
let room = inner.saturating_sub(key_w);
push(
vec![
Span::styled(format!("{key:<key_w$}"), key_style),
Span::styled(
glyphs::fit(url, room),
plain.add_modifier(Modifier::UNDERLINED),
),
],
true,
);
}
DocLine::Column {
name,
about,
values,
} => {
let marker = if *values == 0 {
String::new()
} else {
let open = if i + 1 < lines.len() && matches!(lines[i + 1], DocLine::Legend(..))
{
g.expanded
} else {
g.collapsed
};
format!(" {}{values} values", open)
};
let room = inner.saturating_sub(col_w);
let text = format!("{about}{marker}");
for (n, row) in wrap(&text, room).into_iter().enumerate() {
let head = if n == 0 {
format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
} else {
" ".repeat(col_w)
};
push(
vec![Span::styled(head, key_style), Span::styled(row, plain)],
n == 0,
);
}
}
DocLine::RecordType(name, about) => {
let room = inner.saturating_sub(col_w);
for (n, row) in wrap(about, room).into_iter().enumerate() {
let head = if n == 0 {
format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
} else {
" ".repeat(col_w)
};
push(
vec![Span::styled(head, key_style), Span::styled(row, plain)],
n == 0,
);
}
}
DocLine::Legend(code, meaning) => {
let pad = col_w + 2;
let code_w = 6usize.max(code.width() + 2);
let room = inner.saturating_sub(pad + code_w);
for (n, row) in wrap(meaning, room).into_iter().enumerate() {
let head = if n == 0 {
format!("{}{code:<code_w$}", " ".repeat(pad))
} else {
" ".repeat(pad + code_w)
};
push(
vec![Span::styled(head, dim), Span::styled(row, plain)],
n == 0,
);
}
}
DocLine::Bookmark(name, path) => {
let room = inner.saturating_sub(col_w);
push(
vec![
Span::styled(
format!("{:<col_w$}", glyphs::fit(name, col_w - 2)),
key_style,
),
Span::styled(glyphs::fit(path, room), plain),
],
true,
);
}
}
}
(rows, starts)
}
pub fn render_page(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
if area.width < 10 || area.height == 0 {
return;
}
let area = Rect {
width: area.width.min(MEASURE),
..area
};
let lines = state.lines();
state.cursor = state.cursor.min(lines.len().saturating_sub(1));
let (rows, starts) = layout(&lines, area.width as usize, state.cursor, ctx);
let height = area.height as usize;
state.view_height = height;
let first = starts.get(state.cursor).copied().unwrap_or(0);
let last = starts
.get(state.cursor + 1)
.copied()
.unwrap_or(rows.len())
.saturating_sub(1);
if first < state.scroll {
state.scroll = first;
}
if last >= state.scroll + height {
state.scroll = last + 1 - height.min(last + 1);
}
if state.scroll > 0 && state.cursor == state.first_focusable() {
state.scroll = 0;
}
let shown: Vec<Line> = rows
.iter()
.skip(state.scroll)
.take(height)
.cloned()
.collect();
Paragraph::new(shown).render(area, buf);
for (i, line) in lines.iter().enumerate() {
let DocLine::Section(title, count) = line else {
continue;
};
let Some(row) = starts[i].checked_sub(state.scroll).filter(|r| *r < height) else {
continue;
};
let chip = count.to_string();
SectionRule {
title,
chip: Some(&chip),
}
.render(
Rect {
x: area.x + 1,
y: area.y + row as u16,
width: area.width.saturating_sub(1),
height: 1,
},
buf,
ctx,
);
}
}
pub fn render_view(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
let Some(title) = state.doc.as_ref().map(|d| d.title().to_string()) else {
return;
};
let mut footer = HintBar::from_ctx(ctx).screen(datui_cli::keys::Context::Documentation);
let lines = state.lines();
match lines.get(state.cursor) {
Some(DocLine::Column { values: 1.., .. }) | Some(DocLine::Legend(..)) => {
footer = footer.key("Enter");
}
_ => {}
}
if state.offers_open() {
footer = footer.key("o");
}
footer = footer.key("y").key("Esc");
let title = format!("Documentation {} {title}", glyphs::get().trail);
let inner = Surface::new(&title).footer(&footer).render(area, buf, ctx);
render_page(state, inner, buf, ctx);
}
#[cfg(test)]
mod tests;