Skip to main content

pdfboss_tui/
app.rs

1//! The application state machine. `update` consumes [`Msg`]s (input,
2//! ticks, background-task completions) and returns [`Cmd`]s (side effects
3//! for the event loop to execute); no I/O happens here, which keeps the
4//! whole TUI testable without a terminal.
5
6use 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
22/// Ticks (100 ms) a toast stays visible.
23const TOAST_TICKS: u8 = 30;
24/// Rows a tree/inspector PageUp/PageDown moves.
25const PAGE_JUMP: usize = 10;
26/// Maximum jump-history depth.
27const HISTORY_CAP: usize = 64;
28
29/// Which pane has focus (Tab cycles).
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum Pane {
32    Tree,
33    Inspector,
34    Hex,
35}
36
37/// Everything that can happen to the app: terminal input, timer ticks and
38/// background-task completions. `App::update` consumes exactly these.
39#[derive(Debug)]
40pub enum Msg {
41    /// A key press from the crossterm event stream.
42    Key(KeyEvent),
43    /// Terminal resized to `(width, height)` cells.
44    Resize(u16, u16),
45    /// 100 ms heartbeat: spinner, toast expiry, resize debounce.
46    Tick,
47    /// A batch of streamed elements for a lazily populated tree section.
48    TreeBatch {
49        req: TreeReq,
50        elements: Vec<Element>,
51        /// Elements the stream yielded as errors (salvage: skipped, counted).
52        errors: usize,
53        done: bool,
54    },
55    /// A tree population task failed outright.
56    TreeFailed { req: TreeReq, error: String },
57    /// A page's `/Contents` refs arrived.
58    ContentsLoaded { page: usize, refs: Vec<ObjRef> },
59    /// Fetching a page's `/Contents` failed.
60    ContentsFailed { page: usize, error: String },
61    /// The selected element's data arrived for the inspector.
62    InspectorLoaded {
63        generation: u64,
64        payload: InspectorPayload,
65    },
66    /// Loading the inspector payload failed.
67    InspectorFailed { generation: u64, error: String },
68    /// A window of bytes for the hex pane.
69    HexLoaded {
70        generation: u64,
71        window_start: u64,
72        total_len: u64,
73        bytes: Vec<u8>,
74    },
75    /// Reading hex bytes failed.
76    HexFailed { generation: u64, error: String },
77    /// One incremental search hit.
78    SearchResult { generation: u64, hit: SearchHit },
79    /// The search task visited every object.
80    SearchDone { generation: u64 },
81    /// A page preview render finished (or failed).
82    PreviewReady {
83        generation: u64,
84        result: Result<PreviewFrame, String>,
85    },
86    /// A page's Markdown extraction finished (or failed).
87    MarkdownReady {
88        generation: u64,
89        result: Result<String, String>,
90    },
91    /// A yank finished: the toast text, or what went wrong.
92    Yanked { result: Result<String, String> },
93}
94
95/// Side effects `update` requests; the event loop executes them by
96/// spawning tasks against a cloned `AsyncDocument`.
97#[derive(Debug, Clone)]
98pub enum Cmd {
99    /// Stream elements to populate a tree section.
100    LoadTree(TreeReq),
101    /// Fetch page `page`'s dict and extract its `/Contents` refs.
102    LoadContents { page: usize, r: ObjRef },
103    /// Fetch an object for the inspector.
104    LoadObject { generation: u64, r: ObjRef },
105    /// Decode the shown stream for the Decoded/Ops inspector views.
106    DecodeStream { generation: u64, r: ObjRef },
107    /// Load one hex window from the source.
108    LoadHex {
109        generation: u64,
110        source: HexSource,
111        window_start: u64,
112    },
113    /// Start (or restart) an incremental search for `query`.
114    StartSearch { generation: u64, query: String },
115    /// Advances the shared search epoch to `generation` so a still-running
116    /// search task's stale-epoch check trips and it self-terminates
117    /// (no task spawn).
118    CancelSearch { generation: u64 },
119    /// Render page `page` at fit-to-`(max_w, max_h)`-pixels scale.
120    RenderPreview {
121        generation: u64,
122        page: usize,
123        max_w: u32,
124        max_h: u32,
125        /// Cached whole-file bytes from an earlier render, if any.
126        file_bytes: Option<Arc<Vec<u8>>>,
127    },
128    /// Extract page `page` as Markdown.
129    ExtractMarkdown { generation: u64, page: usize },
130    /// Put `text` on the clipboard; `what` names it in the result toast.
131    Copy { text: String, what: &'static str },
132    /// Extract page `page` as Markdown and copy it.
133    YankMarkdown { page: usize },
134    /// Fetch the selection's bytes and copy them as `format`. `slice`
135    /// narrows a decoded objstm container to the member's range; `base`
136    /// is the offset the hexdump's first line shows.
137    YankSpan {
138        source: HexSource,
139        slice: Option<Span>,
140        base: u64,
141        format: YankFormat,
142    },
143}
144
145/// The whole TUI state.
146pub struct App {
147    pub title: String,
148    /// The path or URL the document was opened from, verbatim, for the
149    /// yank menu's shell-command target.
150    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    /// Whether the yank menu is capturing the next key.
162    pub yank_open: bool,
163    toast_ticks: u8,
164    pub size: (u16, u16),
165    /// The adjustable pane splits (Ctrl+Shift+arrows).
166    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    /// Shows a transient status-bar message.
209    pub fn toast(&mut self, message: impl Into<String>) {
210        self.toast = Some(message.into());
211        self.toast_ticks = TOAST_TICKS;
212    }
213
214    /// The status-bar text: search input > toast > breadcrumb + hints.
215    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        // Optional hints only where they fit: on a narrow terminal they
228        // would push [q] quit off the end of the bar instead of informing
229        // anyone.
230        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    /// Consumes one message, mutating state and returning side effects.
244    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                        // The page rendered, but not whole: say what was
348                        // lost rather than showing a silently blank preview.
349                        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                    // Both render into the right-top pane.
466                    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                    // A decoded objstm container: the member's range is
640                    // the pane's highlight.
641                    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    /// The selection pretty-printed: the fetched object when there is one,
653    /// otherwise whatever informational lines the inspector shows (the
654    /// trailer dict, folder summaries).
655    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    /// Jumps to object `r` in the Objects folder, recording history; if
738    /// the physical pass has not run yet, defers the jump behind it.
739    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        // On an already-expanded branch, descend to the first child.
780        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    /// Reloads the inspector and hex panes for the current selection.
801    /// `force` refreshes even when the selection did not change (used
802    /// after tree loads complete).
803    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                        // The physical pass already ran (successfully or
888                        // not) and never produced a trailer dict: showing
889                        // "loading" forever would dead-end the node, since
890                        // nothing will ever arrive to clear it.
891                        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    /// Re-requests an active preview or markdown pane when the selection
924    /// moved to a different page. A selection with no page ancestor
925    /// (objects, xref, trailer) keeps the pane on the page it shows.
926    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    /// Moves a divider, clamps the result, and re-renders an active page
985    /// preview: its raster was fit to the old pane size.
986    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    /// Extracts the selected page as Markdown. Per page, not document-wide:
1024    /// the document can be HTTP-range-backed, and the pane shows one page
1025    /// at a time anyway.
1026    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    /// The pixel budget `fit_scale` fits a page into: a `C`-column,
1038    /// `R`-row pane offers `C x (R*2)` pixels, not `C x R` — the preview
1039    /// paints two vertical half-block pixels (`▀`) per terminal cell row,
1040    /// so the row count alone would understate the vertical budget by 2x
1041    /// and under-scale every rendered page.
1042    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        // Two vertical pixels per cell row (`▀` half-blocks).
1047        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    /// Two pages loaded (physical and logical passes applied), selection
1217    /// left on the Pages folder's first page node.
1218    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'))); // Pages folder
1248        app.update(key(KeyCode::Char('l'))); // expand
1249        app.update(key(KeyCode::Char('j'))); // Page 1
1250        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'))); // Page 2
1262        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'))); // Page 2
1278        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'))); // Trailer
1290        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'))); // expand Page 1
1303        let cmds = app.update(key(KeyCode::Char('j'))); // Fonts folder of page 1
1304        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'))); // Pages
1316        app.update(key(KeyCode::Char('j'))); // Objects
1317        app.update(key(KeyCode::Char('l'))); // expand (already loaded)
1318        let cmds = app.update(key(KeyCode::Char('j'))); // obj 1 0
1319        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)); // accept, keep hits
1352        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        // Backspace pops the jump history.
1362        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'))); // obj 1 0
1425        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'))); // raw: no fetch
1436        assert!(cmds.is_empty());
1437        let cmds = app.update(key(KeyCode::Char('d'))); // decoded: fetch
1438        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    /// Controller item: a preview render in flight (its `PreviewReady`
1464    /// has not arrived yet) must be superseded, not raced, by a
1465    /// resize-driven debounce. `start_render` bumps `preview.generation`
1466    /// on every call, so the debounced re-render's `Cmd::RenderPreview`
1467    /// carries a strictly newer generation than the in-flight one, and a
1468    /// late reply tagged with the stale generation must be dropped by
1469    /// `PreviewState::apply_ready` rather than clobbering current state.
1470    #[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        // Resize arrives while that render is still in flight (no
1484        // PreviewReady yet): the debounce must fire a *new* render whose
1485        // generation supersedes the in-flight one.
1486        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        // The stale first render finishing late must be dropped: it must
1505        // not clear the (now second-generation) in-flight flag or install
1506        // its frame.
1507        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    /// Controller item: a render that dropped content is not an error, so
1531    /// it installs its (possibly blank) frame — but the status bar must say
1532    /// what was lost instead of leaving a blank preview unexplained.
1533    #[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    /// Controller item: the pane->pixel-budget conversion must account
1566    /// for the half-block 2:1 cell aspect. At 80x24 `ui::panes` gives
1567    /// `right_top` = 52 cols x 14 rows; the 2-cell chrome border leaves a
1568    /// 50 x 12 cell interior, and the vertical pixel budget doubles that
1569    /// (2 pixel rows per cell row) to 50 x 24 — not 50 x 12.
1570    #[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    /// Controller item: `m` and `p` paint the same pane, so activating one
1585    /// must deactivate the other in both directions — otherwise the
1586    /// three-way draw branch would show markdown while the user believes
1587    /// they turned the raster preview on.
1588    #[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    /// Controller item: a superseded extraction finishing late must not
1612    /// install its text over the newer request's.
1613    #[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'))); // off
1622        let cmds = app.update(key(KeyCode::Char('m'))); // on again
1623        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    /// Controller item: the markdown pane shares `Pane::Inspector` focus,
1645    /// so while it is active the movement keys must scroll *it*, not move
1646    /// the inspector's hidden ref cursor.
1647    #[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'))); // Pages
1713        app.update(key(KeyCode::Char('j'))); // Objects
1714        app.update(key(KeyCode::Char('l'))); // expand (already loaded)
1715        app.update(key(KeyCode::Char('j'))); // obj 1 0
1716    }
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'))); // Document root
1817        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); // inspector fetch still in flight
1856        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'))); // Pages folder
1878        app.update(key(KeyCode::Char('l'))); // expand
1879        app.update(key(KeyCode::Char('j'))); // Page 1
1880        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'))); // Trailer
1891        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'))); // obj 2 0, member of objstm 9 0
1933        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(); // Document root: the hex pane is empty
1986        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        // Physical pass completes without ever emitting `Element::Trailer`
2048        // (e.g. a damaged trailer region): `trailer_dict` stays `None`
2049        // even though the pass is done.
2050        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'))); // select bottom: Trailer
2067        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        // Select the trailer (span 260..300) whose hex loads on selection.
2091        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        // Focus the hex pane (Tree → Inspector → Hex) and scroll.
2100        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}