1use std::sync::Arc;
7
8use crossterm::event::KeyEvent;
9use pdfboss_core::elements::{Element, Span};
10use pdfboss_core::{pretty, ObjRef};
11
12use crate::hexview::{HexSource, HexState};
13use crate::input::{action_for, Action, KeyContext};
14use crate::inspector::{InspectorPayload, InspectorState};
15use crate::markdown::MarkdownState;
16use crate::preview::{PreviewFrame, PreviewState, RESIZE_DEBOUNCE_TICKS};
17use crate::search::{SearchHit, SearchState};
18use crate::tree::{LoadState, NodeId, NodeKind, TreeReq, TreeState};
19use crate::ui;
20use crate::yank::{self, YankFormat, YankTarget};
21
22const TOAST_TICKS: u8 = 30;
24const PAGE_JUMP: usize = 10;
26const HISTORY_CAP: usize = 64;
28
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum Pane {
32 Tree,
33 Inspector,
34 Hex,
35}
36
37#[derive(Debug)]
40pub enum Msg {
41 Key(KeyEvent),
43 Resize(u16, u16),
45 Tick,
47 TreeBatch {
49 req: TreeReq,
50 elements: Vec<Element>,
51 errors: usize,
53 done: bool,
54 },
55 TreeFailed { req: TreeReq, error: String },
57 ContentsLoaded { page: usize, refs: Vec<ObjRef> },
59 ContentsFailed { page: usize, error: String },
61 InspectorLoaded {
63 generation: u64,
64 payload: InspectorPayload,
65 },
66 InspectorFailed { generation: u64, error: String },
68 HexLoaded {
70 generation: u64,
71 window_start: u64,
72 total_len: u64,
73 bytes: Vec<u8>,
74 },
75 HexFailed { generation: u64, error: String },
77 SearchResult { generation: u64, hit: SearchHit },
79 SearchDone { generation: u64 },
81 PreviewReady {
83 generation: u64,
84 result: Result<PreviewFrame, String>,
85 },
86 MarkdownReady {
88 generation: u64,
89 result: Result<String, String>,
90 },
91 Yanked { result: Result<String, String> },
93}
94
95#[derive(Debug, Clone)]
98pub enum Cmd {
99 LoadTree(TreeReq),
101 LoadContents { page: usize, r: ObjRef },
103 LoadObject { generation: u64, r: ObjRef },
105 DecodeStream { generation: u64, r: ObjRef },
107 LoadHex {
109 generation: u64,
110 source: HexSource,
111 window_start: u64,
112 },
113 StartSearch { generation: u64, query: String },
115 CancelSearch { generation: u64 },
119 RenderPreview {
121 generation: u64,
122 page: usize,
123 max_w: u32,
124 max_h: u32,
125 file_bytes: Option<Arc<Vec<u8>>>,
127 },
128 ExtractMarkdown { generation: u64, page: usize },
130 Copy { text: String, what: &'static str },
132 YankMarkdown { page: usize },
134 YankSpan {
138 source: HexSource,
139 slice: Option<Span>,
140 base: u64,
141 format: YankFormat,
142 },
143}
144
145pub struct App {
147 pub title: String,
148 pub target: String,
151 pub tree: TreeState,
152 pub inspector: InspectorState,
153 pub hex: HexState,
154 pub preview: PreviewState,
155 pub markdown: MarkdownState,
156 pub search: SearchState,
157 pub focus: Pane,
158 pub history: Vec<NodeId>,
159 pub pending_jump: Option<ObjRef>,
160 pub toast: Option<String>,
161 pub yank_open: bool,
163 toast_ticks: u8,
164 pub size: (u16, u16),
165 pub splits: ui::Splits,
167 pub should_quit: bool,
168 pub inspector_generation: u64,
169 pub hex_generation: u64,
170 last_selected: Option<NodeId>,
171}
172
173impl App {
174 pub fn new(
175 title: String,
176 target: String,
177 version: (u8, u8),
178 page_count: usize,
179 size: (u16, u16),
180 ) -> App {
181 let mut app = App {
182 title,
183 target,
184 tree: TreeState::new(version, page_count),
185 inspector: InspectorState::new(),
186 hex: HexState::new(),
187 preview: PreviewState::new(),
188 markdown: MarkdownState::new(),
189 search: SearchState::new(),
190 focus: Pane::Tree,
191 history: Vec::new(),
192 pending_jump: None,
193 toast: None,
194 yank_open: false,
195 toast_ticks: 0,
196 size,
197 splits: ui::Splits::default(),
198 should_quit: false,
199 inspector_generation: 0,
200 hex_generation: 0,
201 last_selected: None,
202 };
203 let startup = app.on_select(true);
204 assert!(startup.is_empty(), "the Document root needs no fetches");
205 app
206 }
207
208 pub fn toast(&mut self, message: impl Into<String>) {
210 self.toast = Some(message.into());
211 self.toast_ticks = TOAST_TICKS;
212 }
213
214 pub fn status_line(&self) -> String {
216 if self.search.active {
217 return self.search.status_line();
218 }
219 if self.yank_open {
220 return "yank \u{b7} [q]uery [c]ommand [x] hex [b]ytes [e]lement \
221 [m]arkdown [o]bj ref [esc]"
222 .to_string();
223 }
224 if let Some(message) = &self.toast {
225 return message.clone();
226 }
227 let yank = if self.size.0 >= 90 { "[y] yank " } else { "" };
231 let resize = if self.size.0 >= 110 {
232 "[alt+arrows] resize "
233 } else {
234 ""
235 };
236 format!(
237 "{} \u{b7} {} \u{b7} [/] search [p] preview [m] markdown {yank}{resize}[q] quit",
238 self.title,
239 self.tree.breadcrumb()
240 )
241 }
242
243 pub fn update(&mut self, msg: Msg) -> Vec<Cmd> {
245 match msg {
246 Msg::Key(key) => self.on_key(key),
247 Msg::Resize(width, height) => {
248 self.size = (width, height);
249 if self.preview.active {
250 self.preview.debounce = Some(RESIZE_DEBOUNCE_TICKS);
251 }
252 Vec::new()
253 }
254 Msg::Tick => self.on_tick(),
255 Msg::TreeBatch {
256 req,
257 elements,
258 errors,
259 done,
260 } => {
261 self.tree.apply_batch(req, &elements, done);
262 if errors > 0 {
263 self.toast(format!("{errors} element(s) unreadable, skipped"));
264 }
265 let mut cmds = Vec::new();
266 if done {
267 if let Some(r) = self.pending_jump.take() {
268 cmds.extend(self.jump_to(r));
269 }
270 cmds.extend(self.on_select(true));
271 }
272 cmds
273 }
274 Msg::TreeFailed { req, error } => {
275 self.tree.mark_failed(req);
276 self.toast(format!("load failed: {error}"));
277 Vec::new()
278 }
279 Msg::ContentsLoaded { page, refs } => {
280 self.tree.apply_contents(page, &refs);
281 Vec::new()
282 }
283 Msg::ContentsFailed { page, error } => {
284 self.tree.mark_failed(TreeReq::Contents { page });
285 self.toast(format!("contents of page {}: {error}", page + 1));
286 Vec::new()
287 }
288 Msg::InspectorLoaded {
289 generation,
290 payload,
291 } => {
292 if generation == self.inspector_generation {
293 match payload {
294 InspectorPayload::Object { r, object } => {
295 self.inspector.set_object(r, object)
296 }
297 InspectorPayload::Decoded {
298 r,
299 data,
300 passthrough,
301 } => self.inspector.set_decoded(r, data, passthrough),
302 }
303 }
304 Vec::new()
305 }
306 Msg::InspectorFailed { generation, error } => {
307 if generation == self.inspector_generation {
308 let title = self.inspector.title.clone();
309 self.inspector
310 .show_message(&title, vec![format!("error: {error}")]);
311 self.toast(error);
312 }
313 Vec::new()
314 }
315 Msg::HexLoaded {
316 generation,
317 window_start,
318 total_len,
319 bytes,
320 } => {
321 if generation == self.hex_generation {
322 self.hex.apply_loaded(window_start, total_len, bytes);
323 }
324 Vec::new()
325 }
326 Msg::HexFailed { generation, error } => {
327 if generation == self.hex_generation {
328 self.hex.loading = false;
329 self.hex.error = Some(error.clone());
330 self.toast(error);
331 }
332 Vec::new()
333 }
334 Msg::SearchResult { generation, hit } => {
335 self.search.add_hit(generation, hit);
336 Vec::new()
337 }
338 Msg::SearchDone { generation } => {
339 self.search.finish(generation);
340 Vec::new()
341 }
342 Msg::PreviewReady { generation, result } => {
343 if self.preview.apply_ready(generation, result) {
344 if let Some(error) = self.preview.error.clone() {
345 self.toast(format!("preview: {error}"));
346 } else if let Some(notice) = self.preview.notice.clone() {
347 self.toast(format!("preview: {notice}"));
350 }
351 }
352 Vec::new()
353 }
354 Msg::MarkdownReady { generation, result } => {
355 if self.markdown.apply_ready(generation, result) {
356 if let Some(error) = self.markdown.error.clone() {
357 self.toast(format!("markdown: {error}"));
358 }
359 }
360 Vec::new()
361 }
362 Msg::Yanked { result } => {
363 match result {
364 Ok(text) => self.toast(text),
365 Err(error) => self.toast(format!("yank failed: {error}")),
366 }
367 Vec::new()
368 }
369 }
370 }
371
372 fn on_tick(&mut self) -> Vec<Cmd> {
373 if self.toast_ticks > 0 {
374 self.toast_ticks -= 1;
375 if self.toast_ticks == 0 {
376 self.toast = None;
377 }
378 }
379 self.markdown.tick();
380 if self.preview.tick() {
381 return self.request_preview();
382 }
383 Vec::new()
384 }
385
386 fn on_key(&mut self, key: KeyEvent) -> Vec<Cmd> {
387 let context = if self.search.active {
388 KeyContext::Search
389 } else if self.yank_open {
390 KeyContext::Yank
391 } else {
392 KeyContext::Normal
393 };
394 self.on_action(action_for(key, context))
395 }
396
397 fn on_action(&mut self, action: Action) -> Vec<Cmd> {
398 match action {
399 Action::Noop => Vec::new(),
400 Action::Quit => {
401 self.should_quit = true;
402 Vec::new()
403 }
404 Action::OpenSearch => {
405 self.search.open();
406 Vec::new()
407 }
408 Action::SearchChar(c) => {
409 let generation = self.search.push_char(c);
410 self.start_search(generation)
411 }
412 Action::SearchBackspace => match self.search.pop_char() {
413 Some(generation) => self.start_search(generation),
414 None => Vec::new(),
415 },
416 Action::SearchAccept => {
417 self.search.accept();
418 Vec::new()
419 }
420 Action::SearchCancel => {
421 self.search.cancel();
422 vec![Cmd::CancelSearch {
423 generation: self.search.generation,
424 }]
425 }
426 Action::NextHit => match self.search.next_hit() {
427 Some(hit) => self.jump_to(hit.r),
428 None => Vec::new(),
429 },
430 Action::PrevHit => match self.search.prev_hit() {
431 Some(hit) => self.jump_to(hit.r),
432 None => Vec::new(),
433 },
434 Action::OpenYank => {
435 self.yank_open = true;
436 Vec::new()
437 }
438 Action::Yank(target) => {
439 self.yank_open = false;
440 self.on_yank(target)
441 }
442 Action::YankCancel => {
443 self.yank_open = false;
444 Vec::new()
445 }
446 Action::FocusNext => {
447 self.focus = match self.focus {
448 Pane::Tree => Pane::Inspector,
449 Pane::Inspector => Pane::Hex,
450 Pane::Hex => Pane::Tree,
451 };
452 Vec::new()
453 }
454 Action::ResizeLeft => self.resize(|s| s.tree = s.tree.saturating_sub(ui::SPLIT_STEP)),
455 Action::ResizeRight => self.resize(|s| s.tree += ui::SPLIT_STEP),
456 Action::ResizeUp => {
457 self.resize(|s| s.right_top = s.right_top.saturating_sub(ui::SPLIT_STEP))
458 }
459 Action::ResizeDown => self.resize(|s| s.right_top += ui::SPLIT_STEP),
460 Action::TogglePreview => {
461 if self.preview.active {
462 self.preview.active = false;
463 Vec::new()
464 } else {
465 self.markdown.active = false;
467 self.preview.active = true;
468 self.request_preview()
469 }
470 }
471 Action::ToggleMarkdown => {
472 if self.markdown.active {
473 self.markdown.active = false;
474 Vec::new()
475 } else {
476 self.preview.active = false;
477 self.markdown.active = true;
478 self.request_markdown()
479 }
480 }
481 Action::CycleView => {
482 if !self.inspector.is_stream() {
483 if self.inspector.object.is_some() {
484 self.toast("d: not a stream");
485 }
486 return Vec::new();
487 }
488 let needs_decode = self.inspector.cycle_mode();
489 match (needs_decode, self.inspector.object.as_ref()) {
490 (true, Some((r, ..))) => vec![Cmd::DecodeStream {
491 generation: self.inspector_generation,
492 r: *r,
493 }],
494 (true, None) | (false, ..) => Vec::new(),
495 }
496 }
497 Action::Back => match self.history.pop() {
498 Some(id) => {
499 self.tree.reveal(id);
500 self.tree.selected = id;
501 self.on_select(false)
502 }
503 None => {
504 self.toast("history empty");
505 Vec::new()
506 }
507 },
508 Action::Activate => self.on_activate(),
509 Action::MoveUp => self.on_move(-1),
510 Action::MoveDown => self.on_move(1),
511 Action::PageUp => self.on_page(-1),
512 Action::PageDown => self.on_page(1),
513 Action::Collapse => match self.focus {
514 Pane::Tree => {
515 self.tree.collapse_or_parent(self.tree.selected);
516 self.on_select(false)
517 }
518 Pane::Inspector | Pane::Hex => Vec::new(),
519 },
520 Action::Expand => match self.focus {
521 Pane::Tree => self.expand_selected(),
522 Pane::Inspector | Pane::Hex => Vec::new(),
523 },
524 Action::Top => match self.focus {
525 Pane::Tree => {
526 self.tree.select_top();
527 self.on_select(false)
528 }
529 Pane::Inspector if self.markdown.active => {
530 self.markdown.scroll_to(0);
531 Vec::new()
532 }
533 Pane::Inspector => {
534 self.inspector.scroll = 0;
535 self.inspector.ref_cursor = None;
536 Vec::new()
537 }
538 Pane::Hex => {
539 self.hex.scroll_to(0);
540 self.ensure_hex_window()
541 }
542 },
543 Action::Bottom => match self.focus {
544 Pane::Tree => {
545 self.tree.select_bottom();
546 self.on_select(false)
547 }
548 Pane::Inspector if self.markdown.active => {
549 self.markdown.scroll_to(u64::MAX);
550 Vec::new()
551 }
552 Pane::Inspector => {
553 self.inspector.scroll = self.inspector.lines.len().saturating_sub(1) as u16;
554 Vec::new()
555 }
556 Pane::Hex => {
557 let last = self.hex.line_count().saturating_sub(1);
558 self.hex.scroll_to(last);
559 self.ensure_hex_window()
560 }
561 },
562 }
563 }
564
565 fn on_yank(&mut self, target: YankTarget) -> Vec<Cmd> {
566 match target {
567 YankTarget::Query => match self.tree.query(self.tree.selected) {
568 Some(query) => vec![Cmd::Copy {
569 text: query,
570 what: "query",
571 }],
572 None => {
573 self.toast("no query for %%EOF");
574 Vec::new()
575 }
576 },
577 YankTarget::Command => match self.tree.query(self.tree.selected) {
578 Some(query) => vec![Cmd::Copy {
579 text: yank::q_command(&self.target, &query),
580 what: "command",
581 }],
582 None => {
583 self.toast("no query for %%EOF");
584 Vec::new()
585 }
586 },
587 YankTarget::ObjRef => match self.tree.selection_ref(self.tree.selected) {
588 Some(r) => vec![Cmd::Copy {
589 text: format!("{} {} R", r.num, r.gen),
590 what: "obj ref",
591 }],
592 None => {
593 self.toast("selection has no object ref");
594 Vec::new()
595 }
596 },
597 YankTarget::Element => match self.element_text() {
598 Some(text) => vec![Cmd::Copy {
599 text,
600 what: "element",
601 }],
602 None => {
603 self.toast("element still loading");
604 Vec::new()
605 }
606 },
607 YankTarget::Markdown => match self.tree.page_of(self.tree.selected) {
608 Some(page) => vec![Cmd::YankMarkdown { page }],
609 None => {
610 self.toast("selection has no page");
611 Vec::new()
612 }
613 },
614 YankTarget::Hexdump | YankTarget::Bytes => {
615 let format = match target {
616 YankTarget::Hexdump => YankFormat::Hexdump,
617 _ => YankFormat::Bytes,
618 };
619 let Some(source) = self.hex.source.clone() else {
620 self.toast("selection has no bytes");
621 return Vec::new();
622 };
623 match &source {
624 HexSource::File { span }
625 if span.end.saturating_sub(span.start) > yank::CAP_BYTES =>
626 {
627 let selector = format!("range:{}-{}", span.start, span.end);
628 vec![Cmd::Copy {
629 text: yank::hex_command(&self.target, &selector),
630 what: "hex command (selection exceeds the yank cap)",
631 }]
632 }
633 HexSource::File { span } => vec![Cmd::YankSpan {
634 base: span.start,
635 source,
636 slice: None,
637 format,
638 }],
639 HexSource::DecodedObjStm { .. } => vec![Cmd::YankSpan {
642 base: self.hex.highlight.map_or(0, |member| member.start),
643 source,
644 slice: self.hex.highlight,
645 format,
646 }],
647 }
648 }
649 }
650 }
651
652 fn element_text(&self) -> Option<String> {
656 if self.inspector.loading {
657 return None;
658 }
659 if let Some((.., object)) = &self.inspector.object {
660 return Some(pretty::format_object(object));
661 }
662 if self.inspector.lines.is_empty() {
663 return None;
664 }
665 Some(self.inspector.lines.join("\n"))
666 }
667
668 fn on_move(&mut self, delta: i32) -> Vec<Cmd> {
669 match self.focus {
670 Pane::Tree => {
671 if delta < 0 {
672 self.tree.select_prev();
673 } else {
674 self.tree.select_next();
675 }
676 self.on_select(false)
677 }
678 Pane::Inspector if self.markdown.active => {
679 self.markdown.scroll_by(delta);
680 Vec::new()
681 }
682 Pane::Inspector => {
683 self.inspector.move_cursor(delta);
684 Vec::new()
685 }
686 Pane::Hex => {
687 self.hex.scroll_by(i64::from(delta));
688 self.ensure_hex_window()
689 }
690 }
691 }
692
693 fn on_page(&mut self, direction: i32) -> Vec<Cmd> {
694 match self.focus {
695 Pane::Tree => {
696 let mut remaining = PAGE_JUMP;
697 while remaining > 0 {
698 remaining -= 1;
699 if direction < 0 {
700 self.tree.select_prev();
701 } else {
702 self.tree.select_next();
703 }
704 }
705 self.on_select(false)
706 }
707 Pane::Inspector if self.markdown.active => {
708 self.markdown.scroll_by(direction * PAGE_JUMP as i32);
709 Vec::new()
710 }
711 Pane::Inspector => {
712 self.inspector.move_cursor(direction * PAGE_JUMP as i32);
713 Vec::new()
714 }
715 Pane::Hex => {
716 let rows = i64::from(self.hex_visible_rows());
717 self.hex.scroll_by(i64::from(direction) * rows);
718 self.ensure_hex_window()
719 }
720 }
721 }
722
723 fn on_activate(&mut self) -> Vec<Cmd> {
724 match self.focus {
725 Pane::Tree => match self.tree.selection_ref(self.tree.selected) {
726 Some(r) => self.jump_to(r),
727 None => self.expand_selected(),
728 },
729 Pane::Inspector => match self.inspector.current_ref() {
730 Some(r) => self.jump_to(r),
731 None => Vec::new(),
732 },
733 Pane::Hex => Vec::new(),
734 }
735 }
736
737 pub fn jump_to(&mut self, r: ObjRef) -> Vec<Cmd> {
740 if let Some(id) = self.tree.find_object(r) {
741 self.history.push(self.tree.selected);
742 if self.history.len() > HISTORY_CAP {
743 self.history.remove(0);
744 }
745 self.tree.reveal(id);
746 self.tree.selected = id;
747 self.focus = Pane::Tree;
748 return self.on_select(true);
749 }
750 match self.tree.physical {
751 LoadState::NotLoaded => {
752 self.tree.physical = LoadState::Loading;
753 self.pending_jump = Some(r);
754 vec![Cmd::LoadTree(TreeReq::Physical)]
755 }
756 LoadState::Loading => {
757 self.pending_jump = Some(r);
758 Vec::new()
759 }
760 LoadState::Loaded | LoadState::Failed => {
761 self.toast(format!("object {} {} R not found", r.num, r.gen));
762 Vec::new()
763 }
764 }
765 }
766
767 fn start_search(&mut self, generation: u64) -> Vec<Cmd> {
768 if self.search.query.is_empty() {
769 return Vec::new();
770 }
771 vec![Cmd::StartSearch {
772 generation,
773 query: self.search.query.clone(),
774 }]
775 }
776
777 fn expand_selected(&mut self) -> Vec<Cmd> {
778 let id = self.tree.selected;
779 if self.tree.is_branch(id)
781 && self.tree.node(id).expanded
782 && !self.tree.node(id).children.is_empty()
783 {
784 self.tree.selected = self.tree.node(id).children[0];
785 return self.on_select(false);
786 }
787 match self.tree.expand(id) {
788 Some(TreeReq::Contents { page }) => match self.tree.page_ref(page) {
789 Some(r) => vec![Cmd::LoadContents { page, r }],
790 None => {
791 self.toast("page object unknown");
792 Vec::new()
793 }
794 },
795 Some(req) => vec![Cmd::LoadTree(req)],
796 None => Vec::new(),
797 }
798 }
799
800 fn on_select(&mut self, force: bool) -> Vec<Cmd> {
804 if !force && self.last_selected == Some(self.tree.selected) {
805 return Vec::new();
806 }
807 self.last_selected = Some(self.tree.selected);
808 self.inspector_generation += 1;
809 self.hex_generation += 1;
810 let id = self.tree.selected;
811 let kind = self.tree.node(id).kind.clone();
812 let mut cmds = Vec::new();
813 match kind {
814 NodeKind::Document => {
815 self.inspector.show_message(
816 "Document",
817 vec![
818 format!("version: {}.{}", self.tree.version.0, self.tree.version.1),
819 format!("pages: {}", self.tree.page_count),
820 ],
821 );
822 self.hex.clear();
823 }
824 NodeKind::Object { r, span, in_objstm } => {
825 self.inspector
826 .show_loading(&format!("obj {} {}", r.num, r.gen));
827 cmds.push(Cmd::LoadObject {
828 generation: self.inspector_generation,
829 r,
830 });
831 cmds.extend(self.hex_for(span, in_objstm));
832 }
833 NodeKind::Page { r, .. }
834 | NodeKind::Font { r, .. }
835 | NodeKind::Image { r, .. }
836 | NodeKind::Annotation { r, .. }
837 | NodeKind::ContentsStream { r } => {
838 self.inspector
839 .show_loading(&format!("obj {} {}", r.num, r.gen));
840 cmds.push(Cmd::LoadObject {
841 generation: self.inspector_generation,
842 r,
843 });
844 match self.tree.object_span(r.num) {
845 Some((span, in_objstm)) => cmds.extend(self.hex_for(span, in_objstm)),
846 None => {
847 self.hex.clear();
848 if self.tree.physical == LoadState::NotLoaded {
849 self.tree.physical = LoadState::Loading;
850 cmds.push(Cmd::LoadTree(TreeReq::Physical));
851 }
852 }
853 }
854 }
855 NodeKind::XrefSection {
856 kind,
857 span,
858 entries,
859 } => {
860 let name = match kind {
861 pdfboss_core::elements::XrefKind::Table => "xref table",
862 pdfboss_core::elements::XrefKind::Stream => "xref stream",
863 };
864 self.inspector.show_message(
865 name,
866 vec![
867 format!("entries: {entries}"),
868 format!("span: {:#x}..{:#x}", span.start, span.end),
869 ],
870 );
871 cmds.extend(self.hex_file(span));
872 }
873 NodeKind::StartXref { offset, span } => {
874 self.inspector
875 .show_message("startxref", vec![format!("offset: {offset} ({offset:#x})")]);
876 cmds.extend(self.hex_file(span));
877 }
878 NodeKind::Eof { span } => {
879 self.inspector
880 .show_message("%%EOF", vec!["end-of-file marker".to_string()]);
881 cmds.extend(self.hex_file(span));
882 }
883 NodeKind::Trailer => {
884 match self.tree.trailer_dict.clone() {
885 Some(dict) => self.inspector.set_dict("Trailer", &dict),
886 None if matches!(self.tree.physical, LoadState::Loaded | LoadState::Failed) => {
887 self.inspector
892 .show_message("Trailer", vec!["no trailer found".to_string()]);
893 }
894 None => {
895 self.inspector.show_loading("Trailer");
896 if self.tree.physical == LoadState::NotLoaded {
897 self.tree.physical = LoadState::Loading;
898 cmds.push(Cmd::LoadTree(TreeReq::Physical));
899 }
900 }
901 }
902 match self.tree.trailer_span {
903 Some(span) => cmds.extend(self.hex_file(span)),
904 None => self.hex.clear(),
905 }
906 }
907 NodeKind::PagesFolder
908 | NodeKind::FontsFolder { .. }
909 | NodeKind::ImagesFolder { .. }
910 | NodeKind::AnnotationsFolder { .. }
911 | NodeKind::ContentsFolder { .. }
912 | NodeKind::ObjectsFolder
913 | NodeKind::XrefFolder => {
914 let label = self.tree.label(id);
915 self.inspector.show_message(&label, Vec::new());
916 self.hex.clear();
917 }
918 }
919 cmds.extend(self.refresh_page_panes());
920 cmds
921 }
922
923 fn refresh_page_panes(&mut self) -> Vec<Cmd> {
927 let Some(page) = self.tree.page_of(self.tree.selected) else {
928 return Vec::new();
929 };
930 if self.preview.active && self.preview.page != Some(page) {
931 return self.request_preview();
932 }
933 if self.markdown.active && self.markdown.page != Some(page) {
934 return self.request_markdown();
935 }
936 Vec::new()
937 }
938
939 fn hex_for(&mut self, span: Span, in_objstm: Option<(ObjRef, Span)>) -> Vec<Cmd> {
940 match in_objstm {
941 Some((container, member)) => {
942 let source = HexSource::DecodedObjStm { container };
943 self.hex.set_source(source.clone());
944 self.hex.highlight = Some(member);
945 vec![Cmd::LoadHex {
946 generation: self.hex_generation,
947 source,
948 window_start: 0,
949 }]
950 }
951 None => self.hex_file(span),
952 }
953 }
954
955 fn hex_file(&mut self, span: Span) -> Vec<Cmd> {
956 let source = HexSource::File { span };
957 self.hex.set_source(source.clone());
958 vec![Cmd::LoadHex {
959 generation: self.hex_generation,
960 source,
961 window_start: 0,
962 }]
963 }
964
965 fn ensure_hex_window(&mut self) -> Vec<Cmd> {
966 let rows = self.hex_visible_rows();
967 match (
968 self.hex.visible_window_missing(rows),
969 self.hex.source.clone(),
970 ) {
971 (Some(window_start), Some(source)) => {
972 self.hex.loading = true;
973 self.hex_generation += 1;
974 vec![Cmd::LoadHex {
975 generation: self.hex_generation,
976 source,
977 window_start,
978 }]
979 }
980 (Some(..), None) | (None, ..) => Vec::new(),
981 }
982 }
983
984 fn resize(&mut self, adjust: impl FnOnce(&mut ui::Splits)) -> Vec<Cmd> {
987 let mut next = self.splits;
988 adjust(&mut next);
989 next.tree = next
990 .tree
991 .clamp(*ui::TREE_SPLIT.start(), *ui::TREE_SPLIT.end());
992 next.right_top = next
993 .right_top
994 .clamp(*ui::RIGHT_TOP_SPLIT.start(), *ui::RIGHT_TOP_SPLIT.end());
995 if next == self.splits {
996 return Vec::new();
997 }
998 self.splits = next;
999 if self.preview.active {
1000 return self.request_preview();
1001 }
1002 Vec::new()
1003 }
1004
1005 fn request_preview(&mut self) -> Vec<Cmd> {
1006 if self.tree.page_count == 0 {
1007 self.toast("document has no pages to preview");
1008 self.preview.active = false;
1009 return Vec::new();
1010 }
1011 let page = self.tree.page_of(self.tree.selected).unwrap_or(0);
1012 let generation = self.preview.start_render(page);
1013 let (max_w, max_h) = self.preview_budget();
1014 vec![Cmd::RenderPreview {
1015 generation,
1016 page,
1017 max_w,
1018 max_h,
1019 file_bytes: self.preview.file_bytes.clone(),
1020 }]
1021 }
1022
1023 fn request_markdown(&mut self) -> Vec<Cmd> {
1027 if self.tree.page_count == 0 {
1028 self.toast("document has no pages to extract");
1029 self.markdown.active = false;
1030 return Vec::new();
1031 }
1032 let page = self.tree.page_of(self.tree.selected).unwrap_or(0);
1033 let generation = self.markdown.start_extract(page);
1034 vec![Cmd::ExtractMarkdown { generation, page }]
1035 }
1036
1037 fn preview_budget(&self) -> (u32, u32) {
1043 let area = ratatui::layout::Rect::new(0, 0, self.size.0, self.size.1);
1044 let split = ui::panes(area, self.splits);
1045 let width = u32::from(split.right_top.width.saturating_sub(2)).max(1);
1046 let height = (u32::from(split.right_top.height.saturating_sub(2)) * 2).max(1);
1048 (width, height)
1049 }
1050
1051 fn hex_visible_rows(&self) -> u16 {
1052 let area = ratatui::layout::Rect::new(0, 0, self.size.0, self.size.1);
1053 ui::panes(area, self.splits)
1054 .hex
1055 .height
1056 .saturating_sub(2)
1057 .max(1)
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1065 use pdfboss_core::elements::{Element, Span, XrefKind};
1066 use pdfboss_core::{Dict, Name, ObjRef, Object, Stream};
1067
1068 fn key(code: KeyCode) -> Msg {
1069 Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))
1070 }
1071
1072 fn resize_key(code: KeyCode) -> Msg {
1073 Msg::Key(KeyEvent::new(
1074 code,
1075 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
1076 ))
1077 }
1078
1079 #[test]
1080 fn ctrl_shift_arrows_move_the_dividers_and_clamp() {
1081 let mut app = App::new(
1082 "t.pdf".to_string(),
1083 "t.pdf".to_string(),
1084 (1, 7),
1085 1,
1086 (80, 24),
1087 );
1088 assert_eq!(app.splits, ui::Splits::default());
1089
1090 app.update(resize_key(KeyCode::Right));
1091 assert_eq!(app.splits.tree, ui::Splits::default().tree + ui::SPLIT_STEP);
1092 app.update(resize_key(KeyCode::Left));
1093 assert_eq!(app.splits.tree, ui::Splits::default().tree);
1094
1095 app.update(resize_key(KeyCode::Down));
1096 assert_eq!(
1097 app.splits.right_top,
1098 ui::Splits::default().right_top + ui::SPLIT_STEP
1099 );
1100
1101 for _ in 0..40 {
1102 app.update(resize_key(KeyCode::Left));
1103 app.update(resize_key(KeyCode::Up));
1104 }
1105 assert_eq!(app.splits.tree, *ui::TREE_SPLIT.start());
1106 assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.start());
1107 for _ in 0..40 {
1108 app.update(resize_key(KeyCode::Right));
1109 app.update(resize_key(KeyCode::Down));
1110 }
1111 assert_eq!(app.splits.tree, *ui::TREE_SPLIT.end());
1112 assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.end());
1113 }
1114
1115 #[test]
1116 fn resizing_rerenders_an_active_preview() {
1117 let mut app = App::new(
1118 "t.pdf".to_string(),
1119 "t.pdf".to_string(),
1120 (1, 7),
1121 1,
1122 (80, 24),
1123 );
1124 let cmds = app.update(key(KeyCode::Char('p')));
1125 assert!(
1126 matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
1127 "opening the preview renders it"
1128 );
1129 let cmds = app.update(resize_key(KeyCode::Down));
1130 assert!(
1131 matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
1132 "the raster was fit to the old pane size, so a resize re-renders"
1133 );
1134 let at_bound = app.splits;
1135 for _ in 0..40 {
1136 app.update(resize_key(KeyCode::Down));
1137 }
1138 assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.end());
1139 let cmds = app.update(resize_key(KeyCode::Down));
1140 assert!(
1141 cmds.is_empty(),
1142 "a keypress that moves nothing renders nothing (from {at_bound:?})"
1143 );
1144 }
1145
1146 fn obj_ref(num: u32) -> ObjRef {
1147 ObjRef { num, gen: 0 }
1148 }
1149
1150 fn physical_elements() -> Vec<Element> {
1151 let mut trailer = Dict::new();
1152 trailer.insert(Name("Root".to_string()), Object::Ref(obj_ref(1)));
1153 vec![
1154 Element::Header {
1155 version: (1, 7),
1156 span: Span { start: 0, end: 15 },
1157 },
1158 Element::IndirectObject {
1159 r: obj_ref(1),
1160 object: Object::Null,
1161 span: Span { start: 15, end: 64 },
1162 in_objstm: None,
1163 },
1164 Element::IndirectObject {
1165 r: obj_ref(2),
1166 object: Object::Null,
1167 span: Span {
1168 start: 64,
1169 end: 120,
1170 },
1171 in_objstm: Some((obj_ref(9), Span { start: 4, end: 30 })),
1172 },
1173 Element::XrefSection {
1174 kind: XrefKind::Table,
1175 span: Span {
1176 start: 120,
1177 end: 260,
1178 },
1179 entries: 3,
1180 },
1181 Element::Trailer {
1182 dict: trailer,
1183 span: Span {
1184 start: 260,
1185 end: 300,
1186 },
1187 },
1188 ]
1189 }
1190
1191 fn loaded_app() -> App {
1192 let mut app = App::new(
1193 "t.pdf".to_string(),
1194 "t.pdf".to_string(),
1195 (1, 7),
1196 1,
1197 (80, 24),
1198 );
1199 let cmds = app.update(Msg::TreeBatch {
1200 req: crate::tree::TreeReq::Physical,
1201 elements: physical_elements(),
1202 errors: 0,
1203 done: true,
1204 });
1205 assert!(cmds.is_empty(), "root selection refresh needs no data");
1206 app
1207 }
1208
1209 #[test]
1210 fn quit_sets_flag() {
1211 let mut app = loaded_app();
1212 app.update(key(KeyCode::Char('q')));
1213 assert!(app.should_quit);
1214 }
1215
1216 fn two_page_app() -> App {
1219 let mut app = App::new(
1220 "t.pdf".to_string(),
1221 "t.pdf".to_string(),
1222 (1, 7),
1223 2,
1224 (80, 24),
1225 );
1226 app.update(Msg::TreeBatch {
1227 req: crate::tree::TreeReq::Physical,
1228 elements: physical_elements(),
1229 errors: 0,
1230 done: true,
1231 });
1232 app.update(Msg::TreeBatch {
1233 req: crate::tree::TreeReq::Logical,
1234 elements: vec![
1235 Element::Page {
1236 index: 0,
1237 r: obj_ref(1),
1238 },
1239 Element::Page {
1240 index: 1,
1241 r: obj_ref(2),
1242 },
1243 ],
1244 errors: 0,
1245 done: true,
1246 });
1247 app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); app
1251 }
1252
1253 #[test]
1254 fn active_preview_follows_the_selection_to_another_page() {
1255 let mut app = two_page_app();
1256 let cmds = app.update(key(KeyCode::Char('p')));
1257 assert!(
1258 matches!(cmds.as_slice(), [Cmd::RenderPreview { page: 0, .. }]),
1259 "preview opens on the selected page: {cmds:?}"
1260 );
1261 let cmds = app.update(key(KeyCode::Char('j'))); assert!(
1263 cmds.iter()
1264 .any(|cmd| matches!(cmd, Cmd::RenderPreview { page: 1, .. })),
1265 "moving to page 2 must re-render the active preview: {cmds:?}"
1266 );
1267 }
1268
1269 #[test]
1270 fn active_markdown_follows_the_selection_to_another_page() {
1271 let mut app = two_page_app();
1272 let cmds = app.update(key(KeyCode::Char('m')));
1273 assert!(
1274 matches!(cmds.as_slice(), [Cmd::ExtractMarkdown { page: 0, .. }]),
1275 "markdown opens on the selected page: {cmds:?}"
1276 );
1277 let cmds = app.update(key(KeyCode::Char('j'))); assert!(
1279 cmds.iter()
1280 .any(|cmd| matches!(cmd, Cmd::ExtractMarkdown { page: 1, .. })),
1281 "moving to page 2 must re-extract the active markdown: {cmds:?}"
1282 );
1283 }
1284
1285 #[test]
1286 fn selection_without_a_page_ancestor_keeps_the_preview() {
1287 let mut app = two_page_app();
1288 app.update(key(KeyCode::Char('p')));
1289 let cmds = app.update(key(KeyCode::Char('G'))); assert!(
1291 !cmds
1292 .iter()
1293 .any(|cmd| matches!(cmd, Cmd::RenderPreview { .. })),
1294 "browsing outside the page tree must not re-render: {cmds:?}"
1295 );
1296 }
1297
1298 #[test]
1299 fn selection_within_the_same_page_does_not_rerender() {
1300 let mut app = two_page_app();
1301 app.update(key(KeyCode::Char('p')));
1302 app.update(key(KeyCode::Char('l'))); let cmds = app.update(key(KeyCode::Char('j'))); assert!(
1305 !cmds
1306 .iter()
1307 .any(|cmd| matches!(cmd, Cmd::RenderPreview { .. })),
1308 "same page needs no re-render: {cmds:?}"
1309 );
1310 }
1311
1312 #[test]
1313 fn selecting_an_object_loads_inspector_and_hex() {
1314 let mut app = loaded_app();
1315 app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); let cmds = app.update(key(KeyCode::Char('j'))); assert!(cmds.iter().any(|cmd| matches!(
1320 cmd,
1321 Cmd::LoadObject { r, .. } if r.num == 1
1322 )));
1323 assert!(cmds.iter().any(|cmd| matches!(
1324 cmd,
1325 Cmd::LoadHex { source: crate::hexview::HexSource::File { span }, window_start: 0, .. }
1326 if span.start == 15 && span.end == 64
1327 )));
1328 assert_eq!(app.inspector.title, "obj 1 0");
1329 assert!(app.inspector.loading);
1330 }
1331
1332 #[test]
1333 fn search_hit_jump_and_backspace_history() {
1334 let mut app = loaded_app();
1335 let root = app.tree.selected;
1336 app.update(key(KeyCode::Char('/')));
1337 assert!(app.search.active);
1338 let cmds = app.update(key(KeyCode::Char('2')));
1339 let generation = match cmds.as_slice() {
1340 [Cmd::StartSearch { generation, query }] => {
1341 assert_eq!(query, "2");
1342 *generation
1343 }
1344 other => panic!("expected StartSearch, got {:?}", other),
1345 };
1346 app.update(Msg::SearchResult {
1347 generation,
1348 hit: crate::search::SearchHit { r: obj_ref(2) },
1349 });
1350 app.update(Msg::SearchDone { generation });
1351 app.update(key(KeyCode::Enter)); assert!(!app.search.active);
1353 let cmds = app.update(key(KeyCode::Char('n')));
1354 assert!(cmds.iter().any(|cmd| matches!(
1355 cmd,
1356 Cmd::LoadObject { r, .. } if r.num == 2
1357 )));
1358 let jumped = app.tree.selected;
1359 assert_ne!(jumped, root);
1360 assert_eq!(app.history, vec![root]);
1361 let cmds = app.update(key(KeyCode::Backspace));
1363 assert_eq!(app.tree.selected, root);
1364 assert!(app.history.is_empty());
1365 assert!(cmds.is_empty(), "root selection needs no fetches");
1366 }
1367
1368 #[test]
1369 fn search_cancel_advances_the_epoch_past_the_in_flight_generation() {
1370 let mut app = loaded_app();
1371 app.update(key(KeyCode::Char('/')));
1372 let cmds = app.update(key(KeyCode::Char('a')));
1373 let in_flight_generation = match cmds.as_slice() {
1374 [Cmd::StartSearch { generation, .. }] => *generation,
1375 other => panic!("expected StartSearch, got {:?}", other),
1376 };
1377 let cmds = app.update(key(KeyCode::Esc));
1378 assert!(!app.search.active);
1379 match cmds.as_slice() {
1380 [Cmd::CancelSearch { generation }] => {
1381 assert!(
1382 *generation > in_flight_generation,
1383 "cancel's epoch must be newer than the in-flight search's generation"
1384 );
1385 }
1386 other => panic!("expected CancelSearch, got {:?}", other),
1387 }
1388 }
1389
1390 #[test]
1391 fn jump_before_physical_load_defers() {
1392 let mut app = App::new(
1393 "t.pdf".to_string(),
1394 "t.pdf".to_string(),
1395 (1, 7),
1396 1,
1397 (80, 24),
1398 );
1399 let cmds = app.jump_to(obj_ref(2));
1400 assert!(matches!(
1401 cmds.as_slice(),
1402 [Cmd::LoadTree(crate::tree::TreeReq::Physical)]
1403 ));
1404 assert_eq!(app.pending_jump, Some(obj_ref(2)));
1405 let cmds = app.update(Msg::TreeBatch {
1406 req: crate::tree::TreeReq::Physical,
1407 elements: physical_elements(),
1408 errors: 0,
1409 done: true,
1410 });
1411 assert_eq!(app.pending_jump, None);
1412 assert!(cmds.iter().any(|cmd| matches!(
1413 cmd,
1414 Cmd::LoadObject { r, .. } if r.num == 2
1415 )));
1416 }
1417
1418 #[test]
1419 fn d_cycles_stream_views_and_requests_decode() {
1420 let mut app = loaded_app();
1421 app.update(key(KeyCode::Char('j')));
1422 app.update(key(KeyCode::Char('j')));
1423 app.update(key(KeyCode::Char('l')));
1424 app.update(key(KeyCode::Char('j'))); app.update(Msg::InspectorLoaded {
1426 generation: app.inspector_generation,
1427 payload: crate::inspector::InspectorPayload::Object {
1428 r: obj_ref(1),
1429 object: Object::Stream(Stream {
1430 dict: Dict::new(),
1431 data: b"BT ET".to_vec(),
1432 }),
1433 },
1434 });
1435 let cmds = app.update(key(KeyCode::Char('d'))); assert!(cmds.is_empty());
1437 let cmds = app.update(key(KeyCode::Char('d'))); assert!(matches!(
1439 cmds.as_slice(),
1440 [Cmd::DecodeStream { r, .. }] if r.num == 1
1441 ));
1442 }
1443
1444 #[test]
1445 fn resize_debounces_preview_rerender() {
1446 let mut app = loaded_app();
1447 let cmds = app.update(key(KeyCode::Char('p')));
1448 assert!(app.preview.active);
1449 assert!(matches!(
1450 cmds.as_slice(),
1451 [Cmd::RenderPreview { page: 0, .. }]
1452 ));
1453 let cmds = app.update(Msg::Resize(100, 40));
1454 assert!(cmds.is_empty(), "resize alone renders nothing");
1455 assert!(app.update(Msg::Tick).is_empty(), "first tick still waiting");
1456 let cmds = app.update(Msg::Tick);
1457 assert!(
1458 matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
1459 "debounced re-render after ~200 ms"
1460 );
1461 }
1462
1463 #[test]
1471 fn resize_during_render_supersedes_with_new_generation() {
1472 let mut app = loaded_app();
1473 let cmds = app.update(key(KeyCode::Char('p')));
1474 let first_generation = match cmds.as_slice() {
1475 [Cmd::RenderPreview {
1476 generation,
1477 page: 0,
1478 ..
1479 }] => *generation,
1480 other => panic!("expected RenderPreview, got {:?}", other),
1481 };
1482 assert!(app.preview.rendering, "render is in flight");
1483 app.update(Msg::Resize(100, 40));
1487 assert!(
1488 app.update(Msg::Tick).is_empty(),
1489 "debounce still counting down"
1490 );
1491 let cmds = app.update(Msg::Tick);
1492 let second_generation = match cmds.as_slice() {
1493 [Cmd::RenderPreview {
1494 generation,
1495 page: 0,
1496 ..
1497 }] => *generation,
1498 other => panic!("expected superseding RenderPreview, got {:?}", other),
1499 };
1500 assert!(
1501 second_generation > first_generation,
1502 "the resize-triggered render must bump the generation past the in-flight one"
1503 );
1504 let cmds = app.update(Msg::PreviewReady {
1508 generation: first_generation,
1509 result: Ok(crate::preview::PreviewFrame {
1510 file_bytes: std::sync::Arc::new(Vec::new()),
1511 pixmap: pdfboss_render::Pixmap {
1512 width: 1,
1513 height: 1,
1514 data: vec![0, 0, 0, 255],
1515 },
1516 notice: None,
1517 }),
1518 });
1519 assert!(cmds.is_empty());
1520 assert!(
1521 app.preview.rendering,
1522 "stale reply must not clear the in-flight flag for the superseding generation"
1523 );
1524 assert!(
1525 app.preview.pixmap.is_none(),
1526 "stale reply must not install its frame"
1527 );
1528 }
1529
1530 #[test]
1534 fn preview_that_dropped_content_toasts_the_summary() {
1535 let mut app = loaded_app();
1536 let cmds = app.update(key(KeyCode::Char('p')));
1537 let generation = match cmds.as_slice() {
1538 [Cmd::RenderPreview {
1539 generation,
1540 page: 0,
1541 ..
1542 }] => *generation,
1543 other => panic!("expected RenderPreview, got {:?}", other),
1544 };
1545 app.update(Msg::PreviewReady {
1546 generation,
1547 result: Ok(crate::preview::PreviewFrame {
1548 file_bytes: std::sync::Arc::new(Vec::new()),
1549 pixmap: pdfboss_render::Pixmap {
1550 width: 1,
1551 height: 1,
1552 data: vec![255, 255, 255, 255],
1553 },
1554 notice: Some("1 image skipped".to_string()),
1555 }),
1556 });
1557 assert!(app.preview.pixmap.is_some(), "the frame still installs");
1558 assert!(
1559 app.status_line().contains("1 image skipped"),
1560 "status line does not mention the drop: {}",
1561 app.status_line()
1562 );
1563 }
1564
1565 #[test]
1571 fn preview_budget_doubles_row_height_for_half_block_aspect() {
1572 let mut app = loaded_app();
1573 let cmds = app.update(key(KeyCode::Char('p')));
1574 assert!(matches!(
1575 cmds.as_slice(),
1576 [Cmd::RenderPreview {
1577 max_w: 50,
1578 max_h: 24,
1579 ..
1580 }]
1581 ));
1582 }
1583
1584 #[test]
1589 fn markdown_and_preview_are_mutually_exclusive() {
1590 let mut app = loaded_app();
1591 let cmds = app.update(key(KeyCode::Char('m')));
1592 assert!(app.markdown.active);
1593 assert!(matches!(
1594 cmds.as_slice(),
1595 [Cmd::ExtractMarkdown { page: 0, .. }]
1596 ));
1597 let cmds = app.update(key(KeyCode::Char('p')));
1598 assert!(app.preview.active);
1599 assert!(!app.markdown.active, "preview replaces markdown");
1600 assert!(matches!(
1601 cmds.as_slice(),
1602 [Cmd::RenderPreview { page: 0, .. }]
1603 ));
1604 app.update(key(KeyCode::Char('m')));
1605 assert!(app.markdown.active);
1606 assert!(!app.preview.active, "markdown replaces preview");
1607 app.update(key(KeyCode::Char('m')));
1608 assert!(!app.markdown.active, "m toggles back off");
1609 }
1610
1611 #[test]
1614 fn stale_markdown_extraction_is_dropped() {
1615 let mut app = loaded_app();
1616 let cmds = app.update(key(KeyCode::Char('m')));
1617 let stale = match cmds.as_slice() {
1618 [Cmd::ExtractMarkdown { generation, .. }] => *generation,
1619 other => panic!("expected ExtractMarkdown, got {:?}", other),
1620 };
1621 app.update(key(KeyCode::Char('m'))); let cmds = app.update(key(KeyCode::Char('m'))); let current = match cmds.as_slice() {
1624 [Cmd::ExtractMarkdown { generation, .. }] => *generation,
1625 other => panic!("expected ExtractMarkdown, got {:?}", other),
1626 };
1627 assert!(current > stale);
1628 app.update(Msg::MarkdownReady {
1629 generation: stale,
1630 result: Ok("# stale".to_string()),
1631 });
1632 assert!(app.markdown.source.is_none(), "stale text is not installed");
1633 assert!(
1634 app.markdown.loading,
1635 "the newer extraction is still awaited"
1636 );
1637 app.update(Msg::MarkdownReady {
1638 generation: current,
1639 result: Ok("# fresh".to_string()),
1640 });
1641 assert_eq!(app.markdown.source.as_deref(), Some("# fresh"));
1642 }
1643
1644 #[test]
1648 fn movement_scrolls_the_markdown_pane_while_it_is_active() {
1649 let mut app = loaded_app();
1650 let cmds = app.update(key(KeyCode::Char('m')));
1651 let generation = match cmds.as_slice() {
1652 [Cmd::ExtractMarkdown { generation, .. }] => *generation,
1653 other => panic!("expected ExtractMarkdown, got {:?}", other),
1654 };
1655 app.update(Msg::MarkdownReady {
1656 generation,
1657 result: Ok((0..40)
1658 .map(|n| format!("line {n}"))
1659 .collect::<Vec<String>>()
1660 .join("\n")),
1661 });
1662 app.update(key(KeyCode::Tab));
1663 assert_eq!(app.focus, Pane::Inspector);
1664 app.update(key(KeyCode::Char('j')));
1665 assert_eq!(app.markdown.scroll, 1);
1666 app.update(key(KeyCode::PageDown));
1667 assert_eq!(app.markdown.scroll, 11);
1668 app.update(key(KeyCode::Char('G')));
1669 assert_eq!(app.markdown.scroll, 39, "last of the 40 lines");
1670 app.update(key(KeyCode::Char('g')));
1671 assert_eq!(app.markdown.scroll, 0);
1672 assert!(
1673 app.inspector.ref_cursor.is_none(),
1674 "the hidden inspector must not have moved"
1675 );
1676 }
1677
1678 #[test]
1679 fn y_opens_the_yank_menu_and_esc_closes_it_without_quitting() {
1680 let mut app = loaded_app();
1681 assert!(app.update(key(KeyCode::Char('y'))).is_empty());
1682 assert!(app.yank_open);
1683 assert!(
1684 app.status_line().starts_with("yank"),
1685 "menu hints replace the status line: {}",
1686 app.status_line()
1687 );
1688 for hint in ["[q]", "[c]", "[x]", "[b]", "[e]", "[m]", "[o]", "[esc]"] {
1689 assert!(
1690 app.status_line().contains(hint),
1691 "missing {hint} in {}",
1692 app.status_line()
1693 );
1694 }
1695 app.update(key(KeyCode::Esc));
1696 assert!(!app.yank_open);
1697 assert!(!app.should_quit, "Esc closed the menu, not the app");
1698 }
1699
1700 #[test]
1701 fn unmapped_key_cancels_the_yank_menu_and_does_not_leak_through() {
1702 let mut app = loaded_app();
1703 let selected = app.tree.selected;
1704 app.update(key(KeyCode::Char('y')));
1705 let cmds = app.update(key(KeyCode::Char('j')));
1706 assert!(!app.yank_open);
1707 assert!(cmds.is_empty());
1708 assert_eq!(app.tree.selected, selected, "j cancelled instead of moving");
1709 }
1710
1711 fn select_obj_1(app: &mut App) {
1712 app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); }
1717
1718 fn yank(app: &mut App, target: char) -> Vec<Cmd> {
1719 app.update(key(KeyCode::Char('y')));
1720 app.update(key(KeyCode::Char(target)))
1721 }
1722
1723 #[test]
1724 fn yank_query_copies_the_selection_expression() {
1725 let mut app = loaded_app();
1726 select_obj_1(&mut app);
1727 let cmds = yank(&mut app, 'q');
1728 assert!(
1729 matches!(
1730 cmds.as_slice(),
1731 [Cmd::Copy { text, what: "query" }] if text == ".objects[\"1 0\"]"
1732 ),
1733 "got {cmds:?}"
1734 );
1735 assert!(!app.yank_open, "the menu closes after a yank");
1736 }
1737
1738 #[test]
1739 fn yank_command_quotes_the_target_and_query() {
1740 let mut app = App::new(
1741 "t.pdf".to_string(),
1742 "my docs/o'clock.pdf".to_string(),
1743 (1, 7),
1744 1,
1745 (80, 24),
1746 );
1747 app.update(Msg::TreeBatch {
1748 req: crate::tree::TreeReq::Physical,
1749 elements: physical_elements(),
1750 errors: 0,
1751 done: true,
1752 });
1753 select_obj_1(&mut app);
1754 let cmds = yank(&mut app, 'c');
1755 let expected = "pdfboss q 'my docs/o'\\''clock.pdf' '.objects[\"1 0\"]'";
1756 assert!(
1757 matches!(
1758 cmds.as_slice(),
1759 [Cmd::Copy { text, what: "command" }] if text == expected
1760 ),
1761 "got {cmds:?}"
1762 );
1763 }
1764
1765 #[test]
1766 fn yank_query_on_eof_toasts_instead_of_copying() {
1767 let mut app = App::new(
1768 "t.pdf".to_string(),
1769 "t.pdf".to_string(),
1770 (1, 7),
1771 1,
1772 (80, 24),
1773 );
1774 let mut elements = physical_elements();
1775 elements.push(Element::Eof {
1776 span: Span {
1777 start: 300,
1778 end: 306,
1779 },
1780 });
1781 app.update(Msg::TreeBatch {
1782 req: crate::tree::TreeReq::Physical,
1783 elements,
1784 errors: 0,
1785 done: true,
1786 });
1787 let eof_node = *app
1788 .tree
1789 .node(app.tree.xref_folder)
1790 .children
1791 .last()
1792 .expect("xref children");
1793 app.tree.reveal(eof_node);
1794 app.tree.selected = eof_node;
1795 let cmds = yank(&mut app, 'q');
1796 assert!(cmds.is_empty());
1797 assert!(
1798 app.status_line().contains("no query"),
1799 "toast explains: {}",
1800 app.status_line()
1801 );
1802 }
1803
1804 #[test]
1805 fn yank_obj_ref_copies_n_g_r_and_toasts_without_a_ref() {
1806 let mut app = loaded_app();
1807 select_obj_1(&mut app);
1808 let cmds = yank(&mut app, 'o');
1809 assert!(
1810 matches!(
1811 cmds.as_slice(),
1812 [Cmd::Copy { text, what: "obj ref" }] if text == "1 0 R"
1813 ),
1814 "got {cmds:?}"
1815 );
1816 app.update(key(KeyCode::Char('g'))); let cmds = yank(&mut app, 'o');
1818 assert!(cmds.is_empty());
1819 assert!(
1820 app.status_line().contains("no object ref"),
1821 "toast explains: {}",
1822 app.status_line()
1823 );
1824 }
1825
1826 #[test]
1827 fn yank_element_copies_the_pretty_object() {
1828 let mut app = loaded_app();
1829 select_obj_1(&mut app);
1830 let mut dict = Dict::new();
1831 dict.insert(
1832 Name("Type".to_string()),
1833 Object::Name(Name("Catalog".to_string())),
1834 );
1835 app.update(Msg::InspectorLoaded {
1836 generation: app.inspector_generation,
1837 payload: crate::inspector::InspectorPayload::Object {
1838 r: obj_ref(1),
1839 object: Object::Dict(dict),
1840 },
1841 });
1842 let cmds = yank(&mut app, 'e');
1843 assert!(
1844 matches!(
1845 cmds.as_slice(),
1846 [Cmd::Copy { text, what: "element" }] if text.contains("/Type /Catalog")
1847 ),
1848 "got {cmds:?}"
1849 );
1850 }
1851
1852 #[test]
1853 fn yank_element_while_loading_toasts() {
1854 let mut app = loaded_app();
1855 select_obj_1(&mut app); let cmds = yank(&mut app, 'e');
1857 assert!(cmds.is_empty());
1858 assert!(
1859 app.status_line().contains("loading"),
1860 "toast explains: {}",
1861 app.status_line()
1862 );
1863 }
1864
1865 #[test]
1866 fn yank_markdown_extracts_the_selected_page() {
1867 let mut app = loaded_app();
1868 app.update(Msg::TreeBatch {
1869 req: crate::tree::TreeReq::Logical,
1870 elements: vec![Element::Page {
1871 index: 0,
1872 r: obj_ref(1),
1873 }],
1874 errors: 0,
1875 done: true,
1876 });
1877 app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); let cmds = yank(&mut app, 'm');
1881 assert!(
1882 matches!(cmds.as_slice(), [Cmd::YankMarkdown { page: 0 }]),
1883 "got {cmds:?}"
1884 );
1885 }
1886
1887 #[test]
1888 fn yank_markdown_without_a_page_ancestor_toasts() {
1889 let mut app = loaded_app();
1890 app.update(key(KeyCode::Char('G'))); let cmds = yank(&mut app, 'm');
1892 assert!(cmds.is_empty());
1893 assert!(
1894 app.status_line().contains("no page"),
1895 "toast explains: {}",
1896 app.status_line()
1897 );
1898 }
1899
1900 #[test]
1901 fn alt_arrows_move_the_dividers() {
1902 let mut app = loaded_app();
1903 app.update(Msg::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)));
1904 assert_eq!(app.splits.tree, ui::Splits::default().tree + ui::SPLIT_STEP);
1905 app.update(Msg::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)));
1906 assert_eq!(app.splits.tree, ui::Splits::default().tree);
1907 }
1908
1909 #[test]
1910 fn yank_hexdump_fetches_the_file_span() {
1911 let mut app = loaded_app();
1912 select_obj_1(&mut app);
1913 let cmds = yank(&mut app, 'x');
1914 assert!(
1915 matches!(
1916 cmds.as_slice(),
1917 [Cmd::YankSpan {
1918 source: HexSource::File { span },
1919 slice: None,
1920 base: 15,
1921 format: YankFormat::Hexdump,
1922 }] if span.start == 15 && span.end == 64
1923 ),
1924 "got {cmds:?}"
1925 );
1926 }
1927
1928 #[test]
1929 fn yank_bytes_of_an_objstm_member_slices_the_decoded_container() {
1930 let mut app = loaded_app();
1931 select_obj_1(&mut app);
1932 app.update(key(KeyCode::Char('j'))); let cmds = yank(&mut app, 'b');
1934 assert!(
1935 matches!(
1936 cmds.as_slice(),
1937 [Cmd::YankSpan {
1938 source: HexSource::DecodedObjStm { container },
1939 slice: Some(member),
1940 base: 4,
1941 format: YankFormat::Bytes,
1942 }] if container.num == 9 && member.start == 4 && member.end == 30
1943 ),
1944 "got {cmds:?}"
1945 );
1946 }
1947
1948 #[test]
1949 fn yank_hexdump_over_the_cap_copies_the_cli_command_instead() {
1950 let mut app = App::new(
1951 "t.pdf".to_string(),
1952 "big file.pdf".to_string(),
1953 (1, 7),
1954 1,
1955 (80, 24),
1956 );
1957 app.update(Msg::TreeBatch {
1958 req: crate::tree::TreeReq::Physical,
1959 elements: vec![Element::IndirectObject {
1960 r: obj_ref(1),
1961 object: Object::Null,
1962 span: Span {
1963 start: 0,
1964 end: 2 * 1024 * 1024,
1965 },
1966 in_objstm: None,
1967 }],
1968 errors: 0,
1969 done: true,
1970 });
1971 select_obj_1(&mut app);
1972 let cmds = yank(&mut app, 'x');
1973 let expected = "pdfboss hex 'big file.pdf' 'range:0-2097152'";
1974 assert!(
1975 matches!(
1976 cmds.as_slice(),
1977 [Cmd::Copy { text, what }] if text == expected && what.contains("hex command")
1978 ),
1979 "got {cmds:?}"
1980 );
1981 }
1982
1983 #[test]
1984 fn yank_hexdump_without_bytes_toasts() {
1985 let mut app = loaded_app(); let cmds = yank(&mut app, 'x');
1987 assert!(cmds.is_empty());
1988 assert!(
1989 app.status_line().contains("no bytes"),
1990 "toast explains: {}",
1991 app.status_line()
1992 );
1993 }
1994
1995 #[test]
1996 fn yanked_message_toasts_success_and_failure() {
1997 let mut app = loaded_app();
1998 app.update(Msg::Yanked {
1999 result: Ok("copied query (18 B)".to_string()),
2000 });
2001 assert_eq!(app.status_line(), "copied query (18 B)");
2002 app.update(Msg::Yanked {
2003 result: Err("no clipboard".to_string()),
2004 });
2005 assert_eq!(app.status_line(), "yank failed: no clipboard");
2006 }
2007
2008 #[test]
2009 fn toast_expires_after_ticks() {
2010 let mut app = loaded_app();
2011 app.toast("hello");
2012 assert_eq!(app.status_line(), "hello");
2013 for count in 0..30 {
2014 let ignored_len = app.update(Msg::Tick).len();
2015 assert_eq!(ignored_len, 0, "tick {count} spawned nothing");
2016 }
2017 assert!(app.status_line().starts_with("t.pdf \u{b7} /Document"));
2018 }
2019
2020 #[test]
2021 fn status_line_shows_search_then_breadcrumb() {
2022 let mut app = loaded_app();
2023 assert_eq!(
2024 app.status_line(),
2025 "t.pdf \u{b7} /Document \u{b7} [/] search [p] preview [m] markdown [q] quit",
2026 "at 80 cols the yank hint would push [q] quit off the bar"
2027 );
2028 app.update(Msg::Resize(100, 24));
2029 assert_eq!(
2030 app.status_line(),
2031 "t.pdf \u{b7} /Document \u{b7} [/] search [p] preview [m] markdown [y] yank [q] quit"
2032 );
2033 app.update(key(KeyCode::Char('/')));
2034 app.update(key(KeyCode::Char('a')));
2035 assert_eq!(app.status_line(), "/a \u{b7} 0 hits \u{2026}");
2036 }
2037
2038 #[test]
2039 fn trailer_node_shows_message_when_physical_pass_found_no_trailer() {
2040 let mut app = App::new(
2041 "t.pdf".to_string(),
2042 "t.pdf".to_string(),
2043 (1, 7),
2044 1,
2045 (80, 24),
2046 );
2047 let elements = vec![Element::IndirectObject {
2051 r: obj_ref(1),
2052 object: Object::Null,
2053 span: Span { start: 15, end: 64 },
2054 in_objstm: None,
2055 }];
2056 let cmds = app.update(Msg::TreeBatch {
2057 req: crate::tree::TreeReq::Physical,
2058 elements,
2059 errors: 0,
2060 done: true,
2061 });
2062 assert!(cmds.is_empty(), "root selection refresh needs no fetches");
2063 assert_eq!(app.tree.physical, crate::tree::LoadState::Loaded);
2064 assert!(app.tree.trailer_dict.is_none());
2065
2066 let cmds = app.update(key(KeyCode::Char('G'))); assert_eq!(app.tree.selected, app.tree.trailer_node);
2068 assert!(
2069 cmds.is_empty(),
2070 "physical already loaded and still no trailer needs no fetch"
2071 );
2072 assert!(
2073 !app.inspector.loading,
2074 "must not dead-end on a perpetual loading state"
2075 );
2076 assert_eq!(app.inspector.title, "Trailer");
2077 assert!(
2078 app.inspector
2079 .lines
2080 .iter()
2081 .any(|line| line.contains("no trailer")),
2082 "expected a no-trailer message, got {:?}",
2083 app.inspector.lines
2084 );
2085 }
2086
2087 #[test]
2088 fn hex_scrolling_requests_missing_windows() {
2089 let mut app = loaded_app();
2090 app.update(key(KeyCode::Char('G')));
2092 assert_eq!(app.tree.selected, app.tree.trailer_node);
2093 app.update(Msg::HexLoaded {
2094 generation: app.hex_generation,
2095 window_start: 0,
2096 total_len: 40,
2097 bytes: vec![0u8; 40],
2098 });
2099 app.update(key(KeyCode::Tab));
2101 app.update(key(KeyCode::Tab));
2102 assert_eq!(app.focus, Pane::Hex);
2103 app.update(key(KeyCode::Char('j')));
2104 assert_eq!(app.hex.scroll_line, 1);
2105 app.update(key(KeyCode::PageDown));
2106 assert_eq!(app.hex.scroll_line, 4, "clamped to the 5-line span");
2107 app.update(key(KeyCode::Char('g')));
2108 assert_eq!(app.hex.scroll_line, 0);
2109 }
2110}