Skip to main content

cui/ui/
view.rs

1//! The right panel: metadata strip on top, content below.
2
3use metatheca::{Uuid, format_iso8601_secs};
4use ratatui::Frame;
5use ratatui::layout::{Constraint, Layout, Rect};
6use ratatui::style::{Color, Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
9
10use crate::snapshot::JotRow;
11use crate::state::{AppState, Focus};
12use crate::ui::border_style;
13
14pub fn draw(f: &mut Frame, area: Rect, state: &mut AppState) {
15    let block = Block::bordered().border_style(border_style(state.focus == Focus::View));
16    let inner = block.inner(area);
17    f.render_widget(block, area);
18    if inner.height < 3 {
19        state.view_height = 0;
20        return;
21    }
22    let [meta, content] =
23        Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).areas(inner);
24    let cblock = Block::new()
25        .borders(Borders::TOP)
26        .border_style(Style::new().add_modifier(Modifier::DIM));
27    let cinner = cblock.inner(content);
28    state.view_height = cinner.height;
29    f.render_widget(cblock, content);
30
31    let Some(row) = state.selected_row() else {
32        f.render_widget(
33            Paragraph::new("no jot selected").style(Style::new().add_modifier(Modifier::DIM)),
34            meta,
35        );
36        return;
37    };
38
39    f.render_widget(
40        Paragraph::new(vec![meta_line_1(row), meta_line_2(row, state)]),
41        meta,
42    );
43
44    let lines = state
45        .selected_row()
46        .and_then(|r| state.content_of(r.entry))
47        .map(|c| c.lines.clone())
48        .unwrap_or_default();
49    f.render_widget(
50        Paragraph::new(lines)
51            .wrap(Wrap { trim: false })
52            .scroll((state.view_scroll, 0)),
53        cinner,
54    );
55}
56
57/// `--work--12 (md)   created 2026-07-01 09:14:02   2 att   1/3 todos`
58fn meta_line_1(row: &JotRow) -> Line<'static> {
59    let mut spans = vec![
60        Span::styled(
61            format!("--{}--{}", row.profile, row.id),
62            Style::new().add_modifier(Modifier::BOLD),
63        ),
64        Span::styled(
65            format!(" ({})", row.ext),
66            Style::new().add_modifier(Modifier::DIM),
67        ),
68    ];
69    if let Some(ns) = row.created_at_ns {
70        spans.push(Span::raw("   created "));
71        spans.push(Span::styled(
72            format_iso8601_secs(ns),
73            Style::new().add_modifier(Modifier::DIM),
74        ));
75    }
76    if !row.attachments.is_empty() {
77        spans.push(Span::raw(format!("   {} att", row.attachments.len())));
78    }
79    let todos = &row.effective.todos;
80    if !todos.is_empty() {
81        let done = todos.iter().filter(|t| t.done).count();
82        spans.push(Span::raw(format!("   {done}/{} todos", todos.len())));
83    }
84    Line::from(spans)
85}
86
87/// `..tag ..tag ::work::alpha   refs: --3 --misc--7`
88fn meta_line_2(row: &JotRow, state: &AppState) -> Line<'static> {
89    let e = &row.effective;
90    let mut spans = Vec::new();
91    for t in &e.tags {
92        spans.push(Span::styled(
93            format!("..{t}"),
94            Style::new().fg(Color::Yellow),
95        ));
96        spans.push(Span::raw(" "));
97    }
98    for c in &e.categories {
99        spans.push(Span::styled(
100            format!("::{c}"),
101            Style::new().fg(Color::Green),
102        ));
103        spans.push(Span::raw(" "));
104    }
105    if !e.edges.is_empty() {
106        spans.push(Span::styled(
107            "  refs: ",
108            Style::new().add_modifier(Modifier::DIM),
109        ));
110        // One span per effective edge (SPEC §5.5): typed edges to
111        // one target render once each, category-suffixed.
112        for (entry_str, infos) in &e.edges {
113            for info in infos {
114                let mut label = edge_ref(state, entry_str, &info.written_as);
115                if let Some(cat) = &info.annot.category {
116                    label.push_str("::");
117                    label.push_str(cat);
118                }
119                spans.push(Span::styled(label, Style::new().fg(Color::Cyan)));
120                spans.push(Span::raw(" "));
121            }
122        }
123    }
124    if !row.attachments.is_empty() {
125        spans.push(Span::styled(
126            "  att: ",
127            Style::new().add_modifier(Modifier::DIM),
128        ));
129        for a in &row.attachments {
130            spans.push(Span::styled(a.clone(), Style::new().fg(Color::Magenta)));
131            spans.push(Span::raw(" "));
132        }
133    }
134    if spans.is_empty() {
135        spans.push(Span::styled(
136            "(no metadata)",
137            Style::new().add_modifier(Modifier::DIM),
138        ));
139    }
140    Line::from(spans)
141}
142
143/// Render an edge by its target's current identity; fall back to
144/// the written form (marked) when the target is not a live jot.
145fn edge_ref(state: &AppState, entry_str: &str, written: &str) -> String {
146    Uuid::parse_str(entry_str)
147        .ok()
148        .and_then(|u| state.snapshot.by_entry.get(&u))
149        .map(|&i| state.snapshot.rows[i].short_ref(state.scope()))
150        .unwrap_or_else(|| format!("{written}?"))
151}