1use 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
26const PAGE_OVERLAP: u16 = 2;
28
29pub struct AttachmentView {
30 details: AttachmentDetails,
31 icons: Icons,
32 key_bindings: KeyBindings,
33 scroll: u16,
35 viewport_height: u16,
37 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 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 pub fn path(&self) -> &VaultPath {
68 &self.details.path
69 }
70
71 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 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 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 self.scroll = self.scroll.min(self.max_scroll());
179 f.render_widget(
180 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
219fn 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
234fn 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; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
274
275 v.scroll_by(-1);
277 assert_eq!(v.scroll, 0);
278 v.scroll = v.max_scroll();
280 assert_eq!(v.scroll, 3);
281 v.scroll_by(10);
283 assert_eq!(v.scroll, 3);
284
285 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 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}