1use std::collections::HashSet;
12use std::sync::Arc;
13
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Paragraph, Widget};
19use unicode_width::UnicodeWidthStr;
20
21use crate::formats::SpecDocs;
22use crate::glyphs;
23use crate::home::catalog::{ColumnNote, Dataset};
24use crate::render::context::RenderContext;
25use crate::widgets::ui::{HintBar, SectionRule, Surface};
26
27const MEASURE: u16 = 110;
30
31#[derive(Debug, Clone, Default)]
34pub struct Documented {
35 pub catalog: Option<(String, Arc<Dataset>)>,
37 pub spec: Option<Arc<SpecDocs>>,
39 pub name: String,
41}
42
43impl Documented {
44 pub fn new(
47 catalog: Option<(String, Arc<Dataset>)>,
48 spec: Option<Arc<SpecDocs>>,
49 name: String,
50 ) -> Option<Self> {
51 (catalog.is_some() || spec.is_some()).then_some(Self {
52 catalog,
53 spec,
54 name,
55 })
56 }
57
58 pub fn title(&self) -> &str {
60 match &self.catalog {
61 Some((_, entry)) => &entry.name,
62 None => &self.name,
63 }
64 }
65
66 pub fn columns(&self) -> Vec<(String, ColumnNote)> {
70 let catalog: &[(String, ColumnNote)] = self
71 .catalog
72 .as_ref()
73 .map_or(&[], |(_, entry)| &entry.columns);
74 let spec: &[(String, ColumnNote)] = self.spec.as_ref().map_or(&[], |s| &s.columns);
75 let noted = |name: &str| catalog.iter().find(|(n, _)| n == name).map(|(_, n)| n);
76 let mut out: Vec<(String, ColumnNote)> = spec
77 .iter()
78 .map(|(name, note)| {
79 let note = match noted(name) {
80 Some(over) => layered(over, note),
81 None => note.clone(),
82 };
83 (name.clone(), note)
84 })
85 .collect();
86 out.extend(
87 catalog
88 .iter()
89 .filter(|(name, _)| !spec.iter().any(|(n, _)| n == name))
90 .cloned(),
91 );
92 out
93 }
94}
95
96fn layered(over: &ColumnNote, under: &ColumnNote) -> ColumnNote {
98 let pick = |a: &String, b: &String| if a.is_empty() { b } else { a }.clone();
99 ColumnNote {
100 description: pick(&over.description, &under.description),
101 unit: pick(&over.unit, &under.unit),
102 values: if over.values.is_empty() {
103 under.values.clone()
104 } else {
105 over.values.clone()
106 },
107 ty: pick(&over.ty, &under.ty),
108 }
109}
110
111fn field_section(out: &mut Vec<DocLine>, title: &'static str, fields: &[(String, ColumnNote)]) {
113 if fields.is_empty() {
114 return;
115 }
116 out.push(DocLine::Blank);
117 out.push(DocLine::Section(title, fields.len()));
118 for (name, note) in fields {
119 out.push(DocLine::Column {
120 name: name.clone(),
121 about: note.about(),
122 values: 0,
123 });
124 }
125}
126
127#[derive(Debug, Clone, PartialEq)]
129pub enum DocLine {
130 About(String),
132 Field(&'static str, String),
134 Link(&'static str, String),
136 Section(&'static str, usize),
138 Column {
140 name: String,
141 about: String,
142 values: usize,
143 },
144 Legend(String, String),
146 RecordType(String, String),
149 Bookmark(String, String),
151 Blank,
152}
153
154impl DocLine {
155 fn focusable(&self) -> bool {
157 !matches!(self, DocLine::Blank | DocLine::Section(..))
158 }
159
160 pub fn copy_text(&self) -> Option<&str> {
162 match self {
163 DocLine::Link(_, url) => Some(url),
164 DocLine::Field(_, value) => Some(value),
165 DocLine::Bookmark(_, path) => Some(path),
166 DocLine::Legend(code, _) => Some(code),
167 DocLine::Column { name, .. } => Some(name),
168 DocLine::RecordType(name, _) => Some(name),
169 DocLine::About(text) => Some(text),
170 DocLine::Section(..) | DocLine::Blank => None,
171 }
172 }
173}
174
175pub fn lines(doc: &Documented, expanded: &HashSet<String>, measured: Option<u64>) -> Vec<DocLine> {
178 let mut out = Vec::new();
179 let entry = doc.catalog.as_ref().map(|(_, entry)| entry.as_ref());
180 let spec = doc.spec.as_deref();
181 let first_said = |of_entry: Option<&str>, of_spec: Option<&str>| {
183 [of_entry, of_spec]
184 .into_iter()
185 .flatten()
186 .find(|text| !text.is_empty())
187 .unwrap_or_default()
188 .to_string()
189 };
190 let description = first_said(
191 entry.map(|e| e.description.as_str()),
192 spec.map(|s| s.description.as_str()),
193 );
194 if !description.is_empty() {
195 out.push(DocLine::About(description));
196 out.push(DocLine::Blank);
197 }
198 if let Some((catalog, entry)) = &doc.catalog {
199 catalog_fields(&mut out, entry, catalog, measured);
200 }
201 if let Some(spec) = spec {
202 out.push(DocLine::Field("format spec", spec.spec.clone()));
203 if let Some(file) = &spec.file {
204 out.push(DocLine::Field("spec file", crate::home::display_path(file)));
205 }
206 }
207 let documentation = first_said(
208 entry.map(|e| e.documentation.as_str()),
209 spec.map(|s| s.documentation.as_str()),
210 );
211 let links: Vec<(&'static str, String)> = [
212 (
213 "homepage",
214 entry.map(|e| e.homepage.clone()).unwrap_or_default(),
215 ),
216 ("documentation", documentation),
217 ]
218 .into_iter()
219 .filter(|(_, url)| !url.is_empty())
220 .collect();
221 if !links.is_empty() {
222 out.push(DocLine::Blank);
223 out.push(DocLine::Section("LINKS", links.len()));
224 for (label, url) in links {
225 out.push(DocLine::Link(label, url));
226 }
227 }
228 if let Some(spec) = spec {
229 field_section(&mut out, "HEADER", &spec.header);
230 }
231 if let Some(spec) = spec.filter(|s| !s.record_types.is_empty()) {
232 let middot = glyphs::get().middot;
233 out.push(DocLine::Blank);
234 out.push(DocLine::Section("RECORD TYPES", spec.record_types.len()));
235 for record in &spec.record_types {
236 let columns = match record.columns {
237 1 => "1 column".to_string(),
238 n => format!("{n} columns"),
239 };
240 let mut about = format!("{} {middot} {columns}", record.picked_by);
241 if !record.description.is_empty() {
242 about = format!("{about} {middot} {}", record.description);
243 }
244 out.push(DocLine::RecordType(record.name.clone(), about));
245 }
246 }
247 let columns = doc.columns();
248 if !columns.is_empty() {
249 out.push(DocLine::Blank);
250 out.push(DocLine::Section("COLUMNS", columns.len()));
251 for (name, note) in &columns {
252 out.push(DocLine::Column {
253 name: name.clone(),
254 about: note.about(),
255 values: note.values.len(),
256 });
257 if expanded.contains(name) {
258 for (code, meaning) in ¬e.values {
259 let code = if code.is_empty() { "blank" } else { code };
260 out.push(DocLine::Legend(code.to_string(), meaning.clone()));
261 }
262 }
263 }
264 }
265 if let Some(spec) = spec {
266 field_section(&mut out, "FOOTER", &spec.footer);
267 }
268 if let Some(entry) = entry.filter(|e| !e.bookmarks.is_empty()) {
269 out.push(DocLine::Blank);
270 out.push(DocLine::Section("BOOKMARKS", entry.bookmarks.len()));
271 for (name, path) in &entry.bookmarks {
272 out.push(DocLine::Bookmark(name.clone(), path.clone()));
273 }
274 }
275 out
276}
277
278fn catalog_fields(out: &mut Vec<DocLine>, entry: &Dataset, catalog: &str, measured: Option<u64>) {
280 out.push(DocLine::Field("catalog", catalog.to_string()));
281 for (label, value) in [("publisher", &entry.publisher), ("license", &entry.license)] {
282 if !value.is_empty() {
283 out.push(DocLine::Field(label, value.clone()));
284 }
285 }
286 out.push(DocLine::Field("format", format_of(entry)));
287 match (&entry.path, &entry.url) {
288 (Some(_), _) => out.push(DocLine::Field(
289 "path",
290 crate::home::display_path(&entry.location()),
291 )),
292 (None, Some(url)) => {
293 out.push(DocLine::Link("url", url.clone()));
294 out.push(DocLine::Field("login", crate::home::login_of(entry)));
295 }
296 (None, None) => {}
297 }
298 match (measured, entry.size) {
299 (Some(size), _) => out.push(DocLine::Field("size", crate::numfmt::bytes(size))),
300 (None, Some(hint)) => out.push(DocLine::Field(
301 "size",
302 format!("~{}", crate::numfmt::bytes(hint)),
303 )),
304 (None, None) => {}
305 }
306}
307
308fn format_of(entry: &Dataset) -> String {
310 let location = entry.location();
311 let text = location.to_string_lossy();
312 if text.ends_with('/') || (entry.path.is_some() && location.is_dir()) {
313 return "directory".to_string();
314 }
315 crate::FileFormat::from_path(&location)
316 .map(|f| f.name().to_string())
317 .unwrap_or_else(|| {
318 if crate::home::catalog::is_object_store_dataset(&text) {
319 "directory".to_string()
320 } else {
321 "file".to_string()
322 }
323 })
324}
325
326#[derive(Debug, Clone, Default)]
328pub struct DocState {
329 pub doc: Option<Documented>,
330 pub measured: Option<u64>,
332 pub cursor: usize,
334 pub scroll: usize,
336 pub expanded: HashSet<String>,
338 pub view_height: usize,
340 pub links_open: bool,
342}
343
344impl DocState {
345 pub fn open(&mut self, doc: Documented, measured: Option<u64>) {
347 *self = Self {
348 doc: Some(doc),
349 measured,
350 ..Self::default()
351 };
352 self.cursor = self.first_focusable();
353 }
354
355 pub fn close(&mut self) {
356 *self = Self::default();
357 }
358
359 pub fn is_open(&self) -> bool {
360 self.doc.is_some()
361 }
362
363 pub fn lines(&self) -> Vec<DocLine> {
364 match &self.doc {
365 Some(doc) => lines(doc, &self.expanded, self.measured),
366 None => Vec::new(),
367 }
368 }
369
370 fn first_focusable(&self) -> usize {
371 self.lines()
372 .iter()
373 .position(DocLine::focusable)
374 .unwrap_or(0)
375 }
376
377 pub fn move_cursor(&mut self, delta: isize) {
379 let lines = self.lines();
380 let focusable: Vec<usize> = (0..lines.len()).filter(|&i| lines[i].focusable()).collect();
381 if focusable.is_empty() {
382 return;
383 }
384 let at = focusable
385 .iter()
386 .position(|&i| i >= self.cursor)
387 .unwrap_or(focusable.len() - 1) as isize;
388 let to = (at + delta).clamp(0, focusable.len() as isize - 1) as usize;
389 self.cursor = focusable[to];
390 }
391
392 pub fn toggle_legend(&mut self) -> bool {
395 let lines = self.lines();
396 let column = lines[..=self.cursor.min(lines.len().saturating_sub(1))]
398 .iter()
399 .rev()
400 .find_map(|line| match line {
401 DocLine::Column { name, values, .. } if *values > 0 => Some(name.clone()),
402 DocLine::Legend(..) => None,
403 _ => Some(String::new()),
404 })
405 .filter(|name| !name.is_empty());
406 let on_legend_or_column = matches!(
407 lines.get(self.cursor),
408 Some(DocLine::Legend(..)) | Some(DocLine::Column { values: 1.., .. })
409 );
410 let Some(column) = column.filter(|_| on_legend_or_column) else {
411 return false;
412 };
413 if !self.expanded.remove(&column) {
414 self.expanded.insert(column.clone());
415 }
416 let lines = self.lines();
418 if let Some(at) = lines
419 .iter()
420 .position(|l| matches!(l, DocLine::Column { name, values: 1.., .. } if *name == column))
421 && !self.expanded.contains(&column)
422 {
423 self.cursor = at;
424 }
425 true
426 }
427
428 pub fn link(&self) -> Option<String> {
431 match self.lines().get(self.cursor) {
432 Some(DocLine::Link(_, url)) => Some(url.clone()),
433 _ => None,
434 }
435 }
436
437 pub fn offers_open(&self) -> bool {
439 self.links_open && self.link().is_some()
440 }
441
442 pub fn copy_text(&self) -> Option<String> {
444 self.lines()
445 .get(self.cursor)
446 .and_then(DocLine::copy_text)
447 .map(str::to_string)
448 }
449}
450
451fn wrap(text: &str, width: usize) -> Vec<String> {
453 let width = width.max(8);
454 let mut rows = Vec::new();
455 let mut row = String::new();
456 for word in text.split_whitespace() {
457 let candidate = if row.is_empty() {
458 word.to_string()
459 } else {
460 format!("{row} {word}")
461 };
462 if candidate.width() <= width || row.is_empty() {
463 row = candidate;
464 while row.width() > width {
465 let head: String = row.chars().take(width).collect();
467 let rest: String = row.chars().skip(width).collect();
468 rows.push(head);
469 row = rest;
470 }
471 } else {
472 rows.push(std::mem::take(&mut row));
473 row = word.to_string();
474 }
475 }
476 if !row.is_empty() || rows.is_empty() {
477 rows.push(row);
478 }
479 rows
480}
481
482fn layout(
484 lines: &[DocLine],
485 width: usize,
486 cursor: usize,
487 ctx: &RenderContext,
488) -> (Vec<Line<'static>>, Vec<usize>) {
489 let g = glyphs::get();
490 let key_w = lines
491 .iter()
492 .filter_map(|l| match l {
493 DocLine::Field(label, _) | DocLine::Link(label, _) => Some(label.len()),
494 _ => None,
495 })
496 .max()
497 .unwrap_or(8)
498 + 2;
499 let col_w = lines
500 .iter()
501 .filter_map(|l| match l {
502 DocLine::Column { name, .. }
503 | DocLine::Bookmark(name, _)
504 | DocLine::RecordType(name, _) => Some(name.width()),
505 _ => None,
506 })
507 .max()
508 .unwrap_or(0)
509 .min(width / 3)
510 + 2;
511 let label = Style::default().fg(ctx.label);
512 let plain = Style::default().fg(ctx.text_primary);
513 let dim = Style::default().fg(ctx.dimmed);
514 let inner = width.saturating_sub(1);
516 let mut rows: Vec<Line<'static>> = Vec::new();
517 let mut starts = Vec::with_capacity(lines.len());
518 for (i, line) in lines.iter().enumerate() {
519 starts.push(rows.len());
520 let focused = i == cursor;
521 let rail = if focused { g.rail } else { " " };
522 let rail = Span::styled(rail, Style::default().fg(ctx.accent));
523 let key_style = if focused {
524 Style::default().fg(ctx.accent)
525 } else {
526 label
527 };
528 let mut push = |spans: Vec<Span<'static>>, first: bool| {
529 let mut all = vec![if first { rail.clone() } else { Span::raw(" ") }];
530 all.extend(spans);
531 let mut line = Line::from(all);
532 if focused {
533 line = line.patch_style(ctx.highlight_style());
534 }
535 rows.push(line);
536 };
537 match line {
538 DocLine::Blank => rows.push(Line::from("")),
539 DocLine::Section(..) => rows.push(Line::from("")),
540 DocLine::About(text) => {
541 for (n, row) in wrap(text, inner).into_iter().enumerate() {
542 push(vec![Span::styled(row, plain)], n == 0);
543 }
544 }
545 DocLine::Field(key, value) => {
546 let room = inner.saturating_sub(key_w);
547 for (n, row) in wrap(value, room).into_iter().enumerate() {
548 let head = if n == 0 {
549 format!("{key:<key_w$}")
550 } else {
551 " ".repeat(key_w)
552 };
553 push(
554 vec![Span::styled(head, key_style), Span::styled(row, plain)],
555 n == 0,
556 );
557 }
558 }
559 DocLine::Link(key, url) => {
560 let room = inner.saturating_sub(key_w);
561 push(
562 vec![
563 Span::styled(format!("{key:<key_w$}"), key_style),
564 Span::styled(
565 glyphs::fit(url, room),
566 plain.add_modifier(Modifier::UNDERLINED),
567 ),
568 ],
569 true,
570 );
571 }
572 DocLine::Column {
573 name,
574 about,
575 values,
576 } => {
577 let marker = if *values == 0 {
578 String::new()
579 } else {
580 let open = if i + 1 < lines.len() && matches!(lines[i + 1], DocLine::Legend(..))
581 {
582 g.expanded
583 } else {
584 g.collapsed
585 };
586 format!(" {}{values} values", open)
587 };
588 let room = inner.saturating_sub(col_w);
589 let text = format!("{about}{marker}");
590 for (n, row) in wrap(&text, room).into_iter().enumerate() {
591 let head = if n == 0 {
592 format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
593 } else {
594 " ".repeat(col_w)
595 };
596 push(
597 vec![Span::styled(head, key_style), Span::styled(row, plain)],
598 n == 0,
599 );
600 }
601 }
602 DocLine::RecordType(name, about) => {
603 let room = inner.saturating_sub(col_w);
604 for (n, row) in wrap(about, room).into_iter().enumerate() {
605 let head = if n == 0 {
606 format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
607 } else {
608 " ".repeat(col_w)
609 };
610 push(
611 vec![Span::styled(head, key_style), Span::styled(row, plain)],
612 n == 0,
613 );
614 }
615 }
616 DocLine::Legend(code, meaning) => {
617 let pad = col_w + 2;
618 let code_w = 6usize.max(code.width() + 2);
619 let room = inner.saturating_sub(pad + code_w);
620 for (n, row) in wrap(meaning, room).into_iter().enumerate() {
621 let head = if n == 0 {
622 format!("{}{code:<code_w$}", " ".repeat(pad))
623 } else {
624 " ".repeat(pad + code_w)
625 };
626 push(
627 vec![Span::styled(head, dim), Span::styled(row, plain)],
628 n == 0,
629 );
630 }
631 }
632 DocLine::Bookmark(name, path) => {
633 let room = inner.saturating_sub(col_w);
634 push(
635 vec![
636 Span::styled(
637 format!("{:<col_w$}", glyphs::fit(name, col_w - 2)),
638 key_style,
639 ),
640 Span::styled(glyphs::fit(path, room), plain),
641 ],
642 true,
643 );
644 }
645 }
646 }
647 (rows, starts)
648}
649
650pub fn render_page(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
653 if area.width < 10 || area.height == 0 {
654 return;
655 }
656 let area = Rect {
657 width: area.width.min(MEASURE),
658 ..area
659 };
660 let lines = state.lines();
661 state.cursor = state.cursor.min(lines.len().saturating_sub(1));
662 let (rows, starts) = layout(&lines, area.width as usize, state.cursor, ctx);
663 let height = area.height as usize;
664 state.view_height = height;
665 let first = starts.get(state.cursor).copied().unwrap_or(0);
667 let last = starts
668 .get(state.cursor + 1)
669 .copied()
670 .unwrap_or(rows.len())
671 .saturating_sub(1);
672 if first < state.scroll {
673 state.scroll = first;
674 }
675 if last >= state.scroll + height {
676 state.scroll = last + 1 - height.min(last + 1);
677 }
678 if state.scroll > 0 && state.cursor == state.first_focusable() {
679 state.scroll = 0;
680 }
681 let shown: Vec<Line> = rows
682 .iter()
683 .skip(state.scroll)
684 .take(height)
685 .cloned()
686 .collect();
687 Paragraph::new(shown).render(area, buf);
688 for (i, line) in lines.iter().enumerate() {
690 let DocLine::Section(title, count) = line else {
691 continue;
692 };
693 let Some(row) = starts[i].checked_sub(state.scroll).filter(|r| *r < height) else {
694 continue;
695 };
696 let chip = count.to_string();
697 SectionRule {
698 title,
699 chip: Some(&chip),
700 }
701 .render(
702 Rect {
703 x: area.x + 1,
704 y: area.y + row as u16,
705 width: area.width.saturating_sub(1),
706 height: 1,
707 },
708 buf,
709 ctx,
710 );
711 }
712}
713
714pub fn render_view(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
717 let Some(title) = state.doc.as_ref().map(|d| d.title().to_string()) else {
718 return;
719 };
720 let mut footer = HintBar::from_ctx(ctx).screen(datui_cli::keys::Context::Documentation);
721 let lines = state.lines();
722 match lines.get(state.cursor) {
723 Some(DocLine::Column { values: 1.., .. }) | Some(DocLine::Legend(..)) => {
724 footer = footer.key("Enter");
725 }
726 _ => {}
727 }
728 if state.offers_open() {
729 footer = footer.key("o");
730 }
731 footer = footer.key("y").key("Esc");
732 let title = format!("Documentation {} {title}", glyphs::get().trail);
733 let inner = Surface::new(&title).footer(&footer).render(area, buf, ctx);
734 render_page(state, inner, buf, ctx);
735}
736
737#[cfg(test)]
738mod tests;