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::{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
27const PAGE_OVERLAP: u16 = 2;
29
30pub struct AttachmentView {
31 details: AttachmentDetails,
32 icons: Icons,
33 key_bindings: KeyBindings,
34 scroll: u16,
36 viewport_height: u16,
38 total_lines: u16,
40 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 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 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 pub fn path(&self) -> &VaultPath {
78 &self.details.path
79 }
80
81 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 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 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 self.scroll = self.scroll.min(self.max_scroll());
201 f.render_widget(
202 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
241fn 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
256fn 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; let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
313
314 v.scroll_by(-1);
316 assert_eq!(v.scroll, 0);
317 v.scroll = v.max_scroll();
319 assert_eq!(v.scroll, 3);
320 v.scroll_by(10);
322 assert_eq!(v.scroll, 3);
323
324 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 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}