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