Skip to main content

kimun_notes/components/
attachment_view.rs

1//! `AttachmentView` — the read-only surface the editor area shows when an
2//! **Attachment** is opened (see CONTEXT.md), in place of the text editor.
3//! Renders the attachment's metadata plus, for text files, a scrollable
4//! preview of its content; binary files show metadata only. It never edits:
5//! the attachment's verb is *open externally* (**FollowLink**, default Ctrl+N),
6//! handled by the editor screen.
7
8use ratatui::Frame;
9use ratatui::crossterm::event::{KeyCode, MouseEventKind};
10use ratatui::layout::{Constraint, Direction, Layout, Rect};
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15use kimun_core::nfs::VaultPath;
16use kimun_core::{AttachmentContent, AttachmentDetails};
17
18use crate::components::Component;
19use crate::components::event_state::EventState;
20use crate::components::events::{AppEvent, AppTx, InputEvent};
21use crate::components::hint_row::HintRow;
22use crate::keys::KeyBindings;
23use crate::keys::action_shortcuts::ActionShortcuts;
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26
27/// How many lines a PageUp/PageDown leaves visible from the previous view.
28const PAGE_OVERLAP: u16 = 2;
29
30pub struct AttachmentView {
31    details: AttachmentDetails,
32    icons: Icons,
33    key_bindings: KeyBindings,
34    /// Topmost preview line shown (vertical scroll offset).
35    scroll: u16,
36    /// Preview body height in rows from the last render — clamps scrolling.
37    viewport_height: u16,
38    /// Total preview lines, computed once from the content.
39    total_lines: u16,
40    /// The `[<key>] Open externally` chip under the header.
41    open_chip: HintRow,
42}
43
44impl AttachmentView {
45    pub fn new(details: AttachmentDetails, icons: Icons, key_bindings: KeyBindings) -> Self {
46        let total_lines = match &details.content {
47            AttachmentContent::Text { text, .. } => {
48                // `lines()` drops a trailing newline; count at least 1 so an
49                // empty file still occupies a row. Saturate rather than `as u16`
50                // truncate — a file with >65535 lines would otherwise wrap to a
51                // tiny count and strand the scroll near the top. (ratatui's own
52                // scroll offset is u16, so past 65535 lines the tail isn't
53                // reachable anyway — open externally for the full file.)
54                text.lines().count().clamp(1, u16::MAX as usize) as u16
55            }
56            AttachmentContent::Binary => 0,
57        };
58        let key = key_bindings
59            .first_combo_for(&ActionShortcuts::FollowLink)
60            .unwrap_or_else(|| "open".to_string());
61        // The chip's key only marks it clickable; a click sends the open
62        // request itself (the bound key is a chord the chip cannot carry).
63        let open_chip = HintRow::new(&[(KeyCode::Enter, &key, "Open externally")]).with_indent(0);
64        Self {
65            details,
66            icons,
67            key_bindings,
68            scroll: 0,
69            viewport_height: 0,
70            total_lines,
71            open_chip,
72        }
73    }
74
75    /// The opened attachment's vault path — used by the editor screen to open
76    /// it with the OS default program.
77    pub fn path(&self) -> &VaultPath {
78        &self.details.path
79    }
80
81    /// Largest valid scroll offset given the last viewport height.
82    fn max_scroll(&self) -> u16 {
83        self.total_lines.saturating_sub(self.viewport_height)
84    }
85
86    fn scroll_by(&mut self, delta: i32) {
87        let next = (self.scroll as i32 + delta).clamp(0, self.max_scroll() as i32);
88        self.scroll = next as u16;
89    }
90
91    /// The metadata header lines shown above the preview.
92    fn header_lines(&self, theme: &Theme) -> Vec<Line<'static>> {
93        let label = Style::default().fg(theme.gray.to_ratatui());
94        let value = Style::default().fg(theme.fg.to_ratatui());
95        let filename = self.details.path.get_parent_path().1;
96
97        let kv = |k: &str, v: String| {
98            Line::from(vec![
99                Span::styled(format!("{k:<10}"), label),
100                Span::styled(v, value),
101            ])
102        };
103
104        let type_label = match &self.details.extension {
105            Some(ext) => ext.to_uppercase(),
106            None => "(no extension)".to_string(),
107        };
108
109        vec![
110            Line::from(vec![
111                Span::styled(
112                    format!("{} ", self.icons.attachment),
113                    Style::default().fg(theme.accent.to_ratatui()),
114                ),
115                Span::styled(
116                    filename,
117                    Style::default()
118                        .fg(theme.fg_bright.to_ratatui())
119                        .add_modifier(Modifier::BOLD),
120                ),
121            ]),
122            Line::from(""),
123            kv("Path", self.details.path.to_string()),
124            kv("Size", human_size(self.details.size)),
125            kv("Modified", format_mtime(self.details.modified_secs)),
126            kv("Type", type_label),
127        ]
128    }
129}
130
131impl Component for AttachmentView {
132    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
133        if let InputEvent::Mouse(m) = event
134            && self.open_chip.hit(m).is_some()
135        {
136            tx.send(AppEvent::OpenAttachmentExternally).ok();
137            return EventState::Consumed;
138        }
139        let page = self.viewport_height.saturating_sub(PAGE_OVERLAP).max(1) as i32;
140        match event {
141            InputEvent::Key(key) => match key.code {
142                KeyCode::Up => self.scroll_by(-1),
143                KeyCode::Down => self.scroll_by(1),
144                KeyCode::PageUp => self.scroll_by(-page),
145                KeyCode::PageDown => self.scroll_by(page),
146                KeyCode::Home => self.scroll = 0,
147                KeyCode::End => self.scroll = self.max_scroll(),
148                _ => return EventState::NotConsumed,
149            },
150            InputEvent::Mouse(mouse) => match mouse.kind {
151                MouseEventKind::ScrollUp => self.scroll_by(-1),
152                MouseEventKind::ScrollDown => self.scroll_by(1),
153                _ => return EventState::NotConsumed,
154            },
155            _ => return EventState::NotConsumed,
156        }
157        EventState::Consumed
158    }
159
160    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
161        let header = self.header_lines(theme);
162        let header_height = header.len() as u16;
163
164        // Header on top (fixed), a one-row gap, then the preview body.
165        let chunks = Layout::default()
166            .direction(Direction::Vertical)
167            .constraints([
168                Constraint::Length(header_height),
169                Constraint::Length(1),
170                Constraint::Min(0),
171            ])
172            .split(rect);
173
174        f.render_widget(Paragraph::new(header), chunks[0]);
175        self.open_chip.render(
176            f,
177            chunks[1],
178            Style::default().fg(theme.blue.to_ratatui()),
179            theme,
180        );
181
182        let body = chunks[2];
183        match &self.details.content {
184            AttachmentContent::Text { text, truncated } => {
185                let title = if *truncated {
186                    " preview — truncated, open externally for the full file "
187                } else {
188                    " preview "
189                };
190                let block = Block::default()
191                    .borders(Borders::TOP)
192                    .title(title)
193                    .border_style(Style::default().fg(theme.border_dim.to_ratatui()))
194                    .title_style(Style::default().fg(theme.gray.to_ratatui()));
195                let inner = block.inner(body);
196                f.render_widget(block, body);
197                self.viewport_height = inner.height;
198                // Re-clamp after a resize so a shrunk viewport can't strand the
199                // scroll past the new bottom.
200                self.scroll = self.scroll.min(self.max_scroll());
201                f.render_widget(
202                    // Borrow the preview — ratatui renders from the scroll
203                    // offset, so cloning the whole (≤10 MiB) string every frame
204                    // is pure waste.
205                    Paragraph::new(text.as_str())
206                        .style(Style::default().fg(theme.fg.to_ratatui()))
207                        .scroll((self.scroll, 0)),
208                    inner,
209                );
210            }
211            AttachmentContent::Binary => {
212                self.viewport_height = 0;
213                let key = self
214                    .key_bindings
215                    .first_combo_for(&ActionShortcuts::FollowLink)
216                    .unwrap_or_else(|| "the open key".to_string());
217                let msg = Paragraph::new(vec![
218                    Line::from(""),
219                    Line::from(Span::styled(
220                        "Binary file — no preview.",
221                        Style::default().fg(theme.fg_secondary.to_ratatui()),
222                    )),
223                    Line::from(Span::styled(
224                        format!("Press {key} to open it with the default program."),
225                        Style::default().fg(theme.gray.to_ratatui()),
226                    )),
227                ]);
228                f.render_widget(msg, body);
229            }
230        }
231    }
232
233    fn hint_shortcuts(&self) -> Vec<(String, String)> {
234        crate::components::hints::hints_for(
235            &self.key_bindings,
236            &[(ActionShortcuts::FollowLink, "open externally")],
237        )
238    }
239}
240
241/// Formats a byte count as a human-readable size (`2.3 MB`, `512 B`).
242fn human_size(bytes: u64) -> String {
243    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
244    if bytes < 1024 {
245        return format!("{bytes} B");
246    }
247    let mut size = bytes as f64;
248    let mut unit = 0;
249    while size >= 1024.0 && unit < UNITS.len() - 1 {
250        size /= 1024.0;
251        unit += 1;
252    }
253    format!("{size:.1} {}", UNITS[unit])
254}
255
256/// Formats a Unix-second timestamp as a local-agnostic `YYYY-MM-DD HH:MM` (UTC).
257fn format_mtime(secs: u64) -> String {
258    match chrono::DateTime::from_timestamp(secs as i64, 0) {
259        Some(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
260        None => "unknown".to_string(),
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn human_size_scales_units() {
270        assert_eq!(human_size(0), "0 B");
271        assert_eq!(human_size(512), "512 B");
272        assert_eq!(human_size(1024), "1.0 KB");
273        assert_eq!(human_size(1536), "1.5 KB");
274        assert_eq!(human_size(2_411_724), "2.3 MB");
275    }
276
277    fn text_view(text: &str) -> AttachmentView {
278        let details = AttachmentDetails {
279            path: VaultPath::new("notes.txt"),
280            size: text.len() as u64,
281            modified_secs: 0,
282            extension: Some("txt".to_string()),
283            content: AttachmentContent::Text {
284                text: text.to_string(),
285                truncated: false,
286            },
287        };
288        AttachmentView::new(details, Icons::new(false), KeyBindings::empty())
289    }
290
291    #[test]
292    fn open_externally_chip_requests_the_open() {
293        use ratatui::{Terminal, backend::TestBackend};
294        let mut v = text_view("a\nb");
295        let theme = Theme::default();
296        let mut t = Terminal::new(TestBackend::new(60, 20)).unwrap();
297        t.draw(|f| v.render(f, f.area(), &theme, true)).unwrap();
298        let (x, y) = crate::test_support::find_text(t.backend().buffer(), "Open externally")
299            .expect("chip drawn");
300        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
301        v.handle_input(&crate::test_support::mouse_down_at(x, y), &tx);
302        assert!(matches!(
303            rx.try_recv(),
304            Ok(AppEvent::OpenAttachmentExternally)
305        ));
306    }
307
308    #[test]
309    fn scroll_clamps_to_content() {
310        let mut v = text_view("a\nb\nc\nd\ne");
311        v.viewport_height = 2; // 5 lines, 2 visible -> max scroll 3
312        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
313
314        // Scrolling up from the top is a no-op.
315        v.scroll_by(-1);
316        assert_eq!(v.scroll, 0);
317        // End jumps to the bottom-most valid offset.
318        v.scroll = v.max_scroll();
319        assert_eq!(v.scroll, 3);
320        // Cannot scroll past the bottom.
321        v.scroll_by(10);
322        assert_eq!(v.scroll, 3);
323
324        // A scroll-down mouse event is consumed and advances one line.
325        v.scroll = 0;
326        let ev = InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
327            kind: MouseEventKind::ScrollDown,
328            column: 0,
329            row: 0,
330            modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
331        });
332        assert_eq!(v.handle_input(&ev, &tx), EventState::Consumed);
333        assert_eq!(v.scroll, 1);
334    }
335
336    #[test]
337    fn total_lines_saturates_instead_of_wrapping() {
338        // >65535 lines must clamp to u16::MAX, not wrap via `as u16` to a tiny
339        // value that would strand the scroll near the top.
340        let v = text_view(&"x\n".repeat(70_000));
341        assert_eq!(v.total_lines, u16::MAX);
342    }
343
344    #[test]
345    fn binary_view_has_no_preview_lines() {
346        let details = AttachmentDetails {
347            path: VaultPath::new("blob.bin"),
348            size: 3,
349            modified_secs: 0,
350            extension: Some("bin".to_string()),
351            content: AttachmentContent::Binary,
352        };
353        let v = AttachmentView::new(details, Icons::new(false), KeyBindings::empty());
354        assert_eq!(v.total_lines, 0);
355    }
356}