Skip to main content

pdfboss_tui/
tree.rs

1//! Tree pane: lazy hierarchy over a document's elements.
2//!
3//! Document → Pages (per page: Fonts, Images, Annotations, Contents) →
4//! Objects (flat, by number) → Xref sections → Trailer. Sections populate
5//! from element batches streamed by background tasks; `expand` reports
6//! which load pass a first expansion needs.
7
8use std::collections::HashMap;
9
10use pdfboss_core::elements::{Element, Span, XrefKind};
11use pdfboss_core::{Dict, Name, ObjRef};
12
13/// Index into [`TreeState::nodes`].
14pub type NodeId = usize;
15
16/// Which lazily loaded data a tree section needs.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum TreeReq {
19    /// Physical pass: objects, xref sections, trailer, header, startxref, eof.
20    Physical,
21    /// Logical pass: pages with their fonts, images and annotations.
22    Logical,
23    /// One page's `/Contents` refs.
24    Contents { page: usize },
25}
26
27/// Load progress of a lazily populated section.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum LoadState {
30    NotLoaded,
31    Loading,
32    Loaded,
33    Failed,
34}
35
36/// What a tree node represents.
37#[derive(Clone, PartialEq, Debug)]
38pub enum NodeKind {
39    Document,
40    PagesFolder,
41    Page {
42        index: usize,
43        r: ObjRef,
44    },
45    FontsFolder {
46        page: usize,
47    },
48    Font {
49        r: ObjRef,
50        subtype: Name,
51        base_font: Option<Name>,
52    },
53    ImagesFolder {
54        page: usize,
55    },
56    Image {
57        r: ObjRef,
58        width: u32,
59        height: u32,
60    },
61    AnnotationsFolder {
62        page: usize,
63    },
64    Annotation {
65        r: ObjRef,
66        subtype: Name,
67    },
68    ContentsFolder {
69        page: usize,
70    },
71    ContentsStream {
72        r: ObjRef,
73    },
74    ObjectsFolder,
75    Object {
76        r: ObjRef,
77        span: Span,
78        in_objstm: Option<(ObjRef, Span)>,
79    },
80    XrefFolder,
81    XrefSection {
82        kind: XrefKind,
83        span: Span,
84        entries: usize,
85    },
86    StartXref {
87        offset: u64,
88        span: Span,
89    },
90    Eof {
91        span: Span,
92    },
93    Trailer,
94}
95
96/// One node of the arena tree.
97#[derive(Clone, Debug)]
98pub struct Node {
99    pub parent: Option<NodeId>,
100    pub kind: NodeKind,
101    pub children: Vec<NodeId>,
102    pub expanded: bool,
103    pub load: LoadState,
104}
105
106/// A visible row: node plus indentation depth.
107#[derive(Clone, Copy, PartialEq, Eq, Debug)]
108pub struct TreeRow {
109    pub id: NodeId,
110    pub depth: usize,
111}
112
113/// Per-page subfolder ids, filled while applying the logical batch.
114#[derive(Clone, Copy, Debug)]
115struct PageFolders {
116    page_node: NodeId,
117    fonts: NodeId,
118    images: NodeId,
119    annotations: NodeId,
120    contents: NodeId,
121}
122
123/// The whole tree pane model.
124pub struct TreeState {
125    pub nodes: Vec<Node>,
126    pub selected: NodeId,
127    pub scroll: usize,
128    pub page_count: usize,
129    pub version: (u8, u8),
130    pub physical: LoadState,
131    pub logical: LoadState,
132    pub header_span: Option<Span>,
133    pub trailer_dict: Option<Dict>,
134    pub trailer_span: Option<Span>,
135    pub root: NodeId,
136    pub pages_folder: NodeId,
137    pub objects_folder: NodeId,
138    pub xref_folder: NodeId,
139    pub trailer_node: NodeId,
140    object_spans: HashMap<u32, (Span, Option<(ObjRef, Span)>)>,
141    page_folders: HashMap<usize, PageFolders>,
142}
143
144impl TreeState {
145    pub fn new(version: (u8, u8), page_count: usize) -> TreeState {
146        let mut tree = TreeState {
147            nodes: Vec::new(),
148            selected: 0,
149            scroll: 0,
150            page_count,
151            version,
152            physical: LoadState::NotLoaded,
153            logical: LoadState::NotLoaded,
154            header_span: None,
155            trailer_dict: None,
156            trailer_span: None,
157            root: 0,
158            pages_folder: 0,
159            objects_folder: 0,
160            xref_folder: 0,
161            trailer_node: 0,
162            object_spans: HashMap::new(),
163            page_folders: HashMap::new(),
164        };
165        tree.root = tree.add(None, NodeKind::Document);
166        tree.nodes[tree.root].expanded = true;
167        tree.pages_folder = tree.add(Some(tree.root), NodeKind::PagesFolder);
168        tree.objects_folder = tree.add(Some(tree.root), NodeKind::ObjectsFolder);
169        tree.xref_folder = tree.add(Some(tree.root), NodeKind::XrefFolder);
170        tree.trailer_node = tree.add(Some(tree.root), NodeKind::Trailer);
171        tree.selected = tree.root;
172        tree
173    }
174
175    pub fn node(&self, id: NodeId) -> &Node {
176        &self.nodes[id]
177    }
178
179    fn add(&mut self, parent: Option<NodeId>, kind: NodeKind) -> NodeId {
180        let id = self.nodes.len();
181        self.nodes.push(Node {
182            parent,
183            kind,
184            children: Vec::new(),
185            expanded: false,
186            load: LoadState::NotLoaded,
187        });
188        if let Some(parent_id) = parent {
189            self.nodes[parent_id].children.push(id);
190        }
191        id
192    }
193
194    /// Whether a node can have children (shows an expansion glyph).
195    pub fn is_branch(&self, id: NodeId) -> bool {
196        matches!(
197            self.nodes[id].kind,
198            NodeKind::Document
199                | NodeKind::PagesFolder
200                | NodeKind::Page { .. }
201                | NodeKind::FontsFolder { .. }
202                | NodeKind::ImagesFolder { .. }
203                | NodeKind::AnnotationsFolder { .. }
204                | NodeKind::ContentsFolder { .. }
205                | NodeKind::ObjectsFolder
206                | NodeKind::XrefFolder
207        )
208    }
209
210    /// Expands a branch node. Returns the load request the expansion needs
211    /// when its data has not been requested yet, or when a prior attempt
212    /// failed (and marks it Loading either way, so a re-expand after a
213    /// failure retries the load instead of bricking the section).
214    pub fn expand(&mut self, id: NodeId) -> Option<TreeReq> {
215        if !self.is_branch(id) {
216            return None;
217        }
218        self.nodes[id].expanded = true;
219        let kind = self.nodes[id].kind.clone();
220        match kind {
221            NodeKind::PagesFolder
222                if matches!(self.logical, LoadState::NotLoaded | LoadState::Failed) =>
223            {
224                self.logical = LoadState::Loading;
225                Some(TreeReq::Logical)
226            }
227            NodeKind::ObjectsFolder | NodeKind::XrefFolder
228                if matches!(self.physical, LoadState::NotLoaded | LoadState::Failed) =>
229            {
230                self.physical = LoadState::Loading;
231                Some(TreeReq::Physical)
232            }
233            NodeKind::ContentsFolder { page }
234                if matches!(
235                    self.nodes[id].load,
236                    LoadState::NotLoaded | LoadState::Failed
237                ) =>
238            {
239                self.nodes[id].load = LoadState::Loading;
240                Some(TreeReq::Contents { page })
241            }
242            NodeKind::Document
243            | NodeKind::PagesFolder
244            | NodeKind::Page { .. }
245            | NodeKind::FontsFolder { .. }
246            | NodeKind::ImagesFolder { .. }
247            | NodeKind::AnnotationsFolder { .. }
248            | NodeKind::ContentsFolder { .. }
249            | NodeKind::ObjectsFolder
250            | NodeKind::XrefFolder => None,
251            NodeKind::Font { .. }
252            | NodeKind::Image { .. }
253            | NodeKind::Annotation { .. }
254            | NodeKind::ContentsStream { .. }
255            | NodeKind::Object { .. }
256            | NodeKind::XrefSection { .. }
257            | NodeKind::StartXref { .. }
258            | NodeKind::Eof { .. }
259            | NodeKind::Trailer => None,
260        }
261    }
262
263    /// Collapses an expanded branch; on a leaf or collapsed node, moves the
264    /// selection to the parent instead. Returns true when anything changed.
265    ///
266    /// Note: this unconditionally retargets `self.selected` to `id`'s parent
267    /// on the leaf/collapsed path, regardless of what was selected before the
268    /// call. Callers driving this from a "collapse the current selection" key
269    /// binding should pass the *currently selected* node; passing an
270    /// unrelated ancestor teleports selection there instead. Separately, if a
271    /// caller collapses a branch that is an ancestor of `self.selected`
272    /// without moving the selection first, the selected node becomes hidden
273    /// and `selected_position`'s `unwrap_or(0)` fallback silently treats it
274    /// as row 0 on the next `select_next`/`select_prev` — callers must
275    /// re-clamp `selected` to a currently visible row after any collapse.
276    pub fn collapse_or_parent(&mut self, id: NodeId) -> bool {
277        if self.is_branch(id) && self.nodes[id].expanded {
278            self.nodes[id].expanded = false;
279            return true;
280        }
281        match self.nodes[id].parent {
282            Some(parent_id) => {
283                self.selected = parent_id;
284                true
285            }
286            None => false,
287        }
288    }
289
290    /// Applies a streamed element batch to the section `req` covers.
291    ///
292    /// Idempotent per section: a batch delivered again after its section
293    /// already finished loading (e.g. a duplicate delivery from the
294    /// background runner) is a no-op rather than re-adding every node.
295    pub fn apply_batch(&mut self, req: TreeReq, elements: &[Element], done: bool) {
296        match req {
297            TreeReq::Physical if self.physical == LoadState::Loaded => return,
298            TreeReq::Logical if self.logical == LoadState::Loaded => return,
299            TreeReq::Physical | TreeReq::Logical | TreeReq::Contents { .. } => {}
300        }
301        for element in elements {
302            match element {
303                Element::Header { version, span } => {
304                    self.version = *version;
305                    self.header_span = Some(*span);
306                }
307                // The parsed object value is not retained (`..`): the
308                // inspector re-fetches on selection, keeping the tree small.
309                Element::IndirectObject {
310                    r, span, in_objstm, ..
311                } => {
312                    self.object_spans.insert(r.num, (*span, *in_objstm));
313                    self.add(
314                        Some(self.objects_folder),
315                        NodeKind::Object {
316                            r: *r,
317                            span: *span,
318                            in_objstm: *in_objstm,
319                        },
320                    );
321                }
322                // Sections stream in xref-chain order (newest → oldest)
323                // and are displayed as received.
324                Element::XrefSection {
325                    kind,
326                    span,
327                    entries,
328                } => {
329                    self.add(
330                        Some(self.xref_folder),
331                        NodeKind::XrefSection {
332                            kind: *kind,
333                            span: *span,
334                            entries: *entries,
335                        },
336                    );
337                }
338                // Exactly one Trailer element per document (the merged
339                // dict), so plain assignment is correct here.
340                Element::Trailer { dict, span } => {
341                    self.trailer_dict = Some(dict.clone());
342                    self.trailer_span = Some(*span);
343                }
344                Element::StartXref { offset, span } => {
345                    self.add(
346                        Some(self.xref_folder),
347                        NodeKind::StartXref {
348                            offset: *offset,
349                            span: *span,
350                        },
351                    );
352                }
353                Element::Eof { span } => {
354                    self.add(Some(self.xref_folder), NodeKind::Eof { span: *span });
355                }
356                Element::Page { index, r } => {
357                    let page_node = self.add(
358                        Some(self.pages_folder),
359                        NodeKind::Page {
360                            index: *index,
361                            r: *r,
362                        },
363                    );
364                    let fonts = self.add(Some(page_node), NodeKind::FontsFolder { page: *index });
365                    let images = self.add(Some(page_node), NodeKind::ImagesFolder { page: *index });
366                    let annotations = self.add(
367                        Some(page_node),
368                        NodeKind::AnnotationsFolder { page: *index },
369                    );
370                    let contents =
371                        self.add(Some(page_node), NodeKind::ContentsFolder { page: *index });
372                    self.page_folders.insert(
373                        *index,
374                        PageFolders {
375                            page_node,
376                            fonts,
377                            images,
378                            annotations,
379                            contents,
380                        },
381                    );
382                }
383                // Font/Image/Annotation all rely on the producer guarantee
384                // that a page's `Element::Page` arrives before its
385                // fonts/images/annotations (core's `page_elements` emits the
386                // page first, in document order). An element whose page
387                // hasn't been seen yet finds no entry in `page_folders` and
388                // is silently dropped rather than queued.
389                Element::Font {
390                    page: Some(page),
391                    r,
392                    subtype,
393                    base_font,
394                } => {
395                    if let Some(folders) = self.page_folders.get(page).copied() {
396                        self.add(
397                            Some(folders.fonts),
398                            NodeKind::Font {
399                                r: *r,
400                                subtype: subtype.clone(),
401                                base_font: base_font.clone(),
402                            },
403                        );
404                    }
405                }
406                Element::Image {
407                    page: Some(page),
408                    r,
409                    width,
410                    height,
411                } => {
412                    if let Some(folders) = self.page_folders.get(page).copied() {
413                        self.add(
414                            Some(folders.images),
415                            NodeKind::Image {
416                                r: *r,
417                                width: *width,
418                                height: *height,
419                            },
420                        );
421                    }
422                }
423                Element::Annotation { page, r, subtype } => {
424                    if let Some(folders) = self.page_folders.get(page).copied() {
425                        self.add(
426                            Some(folders.annotations),
427                            NodeKind::Annotation {
428                                r: *r,
429                                subtype: subtype.clone(),
430                            },
431                        );
432                    }
433                }
434                // Document-level fonts/images (page: None) stay reachable
435                // through Objects; content ops are never streamed here.
436                Element::Font { page: None, .. }
437                | Element::Image { page: None, .. }
438                | Element::ContentOp { .. } => {}
439            }
440        }
441        if done {
442            match req {
443                TreeReq::Physical => self.physical = LoadState::Loaded,
444                TreeReq::Logical => {
445                    self.logical = LoadState::Loaded;
446                    let loaded: Vec<NodeId> = self
447                        .page_folders
448                        .values()
449                        .flat_map(|folders| [folders.fonts, folders.images, folders.annotations])
450                        .collect();
451                    for id in loaded {
452                        self.nodes[id].load = LoadState::Loaded;
453                    }
454                }
455                TreeReq::Contents { .. } => {}
456            }
457        }
458    }
459
460    /// Fills a page's Contents folder with its stream refs.
461    ///
462    /// Idempotent: a page whose Contents folder is already `Loaded` is left
463    /// untouched, so a duplicate delivery does not duplicate stream nodes.
464    pub fn apply_contents(&mut self, page: usize, refs: &[ObjRef]) {
465        let Some(folders) = self.page_folders.get(&page).copied() else {
466            return;
467        };
468        if self.nodes[folders.contents].load == LoadState::Loaded {
469            return;
470        }
471        for r in refs {
472            self.add(Some(folders.contents), NodeKind::ContentsStream { r: *r });
473        }
474        self.nodes[folders.contents].load = LoadState::Loaded;
475    }
476
477    /// Records a failed load pass.
478    pub fn mark_failed(&mut self, req: TreeReq) {
479        match req {
480            TreeReq::Physical => self.physical = LoadState::Failed,
481            TreeReq::Logical => self.logical = LoadState::Failed,
482            TreeReq::Contents { page } => {
483                if let Some(folders) = self.page_folders.get(&page).copied() {
484                    self.nodes[folders.contents].load = LoadState::Failed;
485                }
486            }
487        }
488    }
489
490    /// Depth-first walk of expanded nodes.
491    pub fn visible_rows(&self) -> Vec<TreeRow> {
492        let mut rows = Vec::new();
493        self.walk(self.root, 0, &mut rows);
494        rows
495    }
496
497    fn walk(&self, id: NodeId, depth: usize, rows: &mut Vec<TreeRow>) {
498        rows.push(TreeRow { id, depth });
499        if self.nodes[id].expanded {
500            for child in self.nodes[id].children.clone() {
501                self.walk(child, depth + 1, rows);
502            }
503        }
504    }
505
506    /// Display label for a node (deterministic; used by snapshots).
507    pub fn label(&self, id: NodeId) -> String {
508        match &self.nodes[id].kind {
509            NodeKind::Document => {
510                format!("Document · PDF {}.{}", self.version.0, self.version.1)
511            }
512            NodeKind::PagesFolder => format!("Pages ({})", self.page_count),
513            NodeKind::Page { index, .. } => format!("Page {}", index + 1),
514            NodeKind::FontsFolder { .. } => self.folder_label("Fonts", id),
515            NodeKind::Font {
516                r,
517                subtype,
518                base_font,
519            } => {
520                let face = base_font.as_ref().unwrap_or(subtype);
521                format!("{} · {} {}", face.0, r.num, r.gen)
522            }
523            NodeKind::ImagesFolder { .. } => self.folder_label("Images", id),
524            NodeKind::Image { r, width, height } => {
525                format!("{}x{} · {} {}", width, height, r.num, r.gen)
526            }
527            NodeKind::AnnotationsFolder { .. } => self.folder_label("Annotations", id),
528            NodeKind::Annotation { r, subtype } => {
529                format!("{} · {} {}", subtype.0, r.num, r.gen)
530            }
531            NodeKind::ContentsFolder { .. } => self.folder_label("Contents", id),
532            NodeKind::ContentsStream { r } => format!("stream {} {}", r.num, r.gen),
533            NodeKind::ObjectsFolder => match self.physical {
534                LoadState::Loaded => {
535                    format!("Objects ({})", self.nodes[id].children.len())
536                }
537                LoadState::NotLoaded | LoadState::Loading | LoadState::Failed => {
538                    "Objects".to_string()
539                }
540            },
541            NodeKind::Object { r, .. } => format!("obj {} {}", r.num, r.gen),
542            NodeKind::XrefFolder => match self.physical {
543                LoadState::Loaded => {
544                    let secs = self.nodes[id]
545                        .children
546                        .iter()
547                        .filter(|child| {
548                            matches!(self.nodes[**child].kind, NodeKind::XrefSection { .. })
549                        })
550                        .count();
551                    format!("Xref ({} secs)", secs)
552                }
553                LoadState::NotLoaded | LoadState::Loading | LoadState::Failed => "Xref".to_string(),
554            },
555            NodeKind::XrefSection { kind, entries, .. } => match kind {
556                XrefKind::Table => format!("xref table · {} entries", entries),
557                XrefKind::Stream => format!("xref stream · {} entries", entries),
558            },
559            NodeKind::StartXref { offset, .. } => format!("startxref → {}", offset),
560            NodeKind::Eof { .. } => "%%EOF".to_string(),
561            NodeKind::Trailer => "Trailer".to_string(),
562        }
563    }
564
565    fn folder_label(&self, name: &str, id: NodeId) -> String {
566        match self.nodes[id].load {
567            LoadState::Loaded => format!("{} ({})", name, self.nodes[id].children.len()),
568            LoadState::NotLoaded | LoadState::Loading | LoadState::Failed => name.to_string(),
569        }
570    }
571
572    /// Short label for breadcrumbs (no counts, no versions).
573    fn short_label(&self, id: NodeId) -> String {
574        match &self.nodes[id].kind {
575            NodeKind::Document => "Document".to_string(),
576            NodeKind::PagesFolder => "Pages".to_string(),
577            NodeKind::Page { index, .. } => format!("Page {}", index + 1),
578            NodeKind::FontsFolder { .. } => "Fonts".to_string(),
579            NodeKind::ImagesFolder { .. } => "Images".to_string(),
580            NodeKind::AnnotationsFolder { .. } => "Annotations".to_string(),
581            NodeKind::ContentsFolder { .. } => "Contents".to_string(),
582            NodeKind::ObjectsFolder => "Objects".to_string(),
583            NodeKind::XrefFolder => "Xref".to_string(),
584            NodeKind::Trailer => "Trailer".to_string(),
585            NodeKind::Font { .. }
586            | NodeKind::Image { .. }
587            | NodeKind::Annotation { .. }
588            | NodeKind::ContentsStream { .. }
589            | NodeKind::Object { .. }
590            | NodeKind::XrefSection { .. }
591            | NodeKind::StartXref { .. }
592            | NodeKind::Eof { .. } => self.label(id),
593        }
594    }
595
596    /// `/Document/Objects/obj 12 0`-style path of the current selection.
597    pub fn breadcrumb(&self) -> String {
598        let mut parts = Vec::new();
599        let mut cursor = Some(self.selected);
600        while let Some(id) = cursor {
601            parts.push(self.short_label(id));
602            cursor = self.nodes[id].parent;
603        }
604        parts.reverse();
605        format!("/{}", parts.join("/"))
606    }
607
608    /// The object node for `r` (generation lenient), if loaded.
609    pub fn find_object(&self, r: ObjRef) -> Option<NodeId> {
610        self.nodes[self.objects_folder]
611            .children
612            .iter()
613            .copied()
614            .find(|id| match self.nodes[*id].kind {
615                NodeKind::Object { r: node_ref, .. } => node_ref.num == r.num,
616                NodeKind::Document
617                | NodeKind::PagesFolder
618                | NodeKind::Page { .. }
619                | NodeKind::FontsFolder { .. }
620                | NodeKind::Font { .. }
621                | NodeKind::ImagesFolder { .. }
622                | NodeKind::Image { .. }
623                | NodeKind::AnnotationsFolder { .. }
624                | NodeKind::Annotation { .. }
625                | NodeKind::ContentsFolder { .. }
626                | NodeKind::ContentsStream { .. }
627                | NodeKind::ObjectsFolder
628                | NodeKind::XrefFolder
629                | NodeKind::XrefSection { .. }
630                | NodeKind::StartXref { .. }
631                | NodeKind::Eof { .. }
632                | NodeKind::Trailer => false,
633            })
634    }
635
636    /// Expands every ancestor so `id` becomes visible.
637    pub fn reveal(&mut self, id: NodeId) {
638        let mut cursor = self.nodes[id].parent;
639        while let Some(parent_id) = cursor {
640            self.nodes[parent_id].expanded = true;
641            cursor = self.nodes[parent_id].parent;
642        }
643    }
644
645    /// The object reference a node points at, if any.
646    pub fn selection_ref(&self, id: NodeId) -> Option<ObjRef> {
647        match self.nodes[id].kind {
648            NodeKind::Page { r, .. }
649            | NodeKind::Font { r, .. }
650            | NodeKind::Image { r, .. }
651            | NodeKind::Annotation { r, .. }
652            | NodeKind::ContentsStream { r }
653            | NodeKind::Object { r, .. } => Some(r),
654            NodeKind::Document
655            | NodeKind::PagesFolder
656            | NodeKind::FontsFolder { .. }
657            | NodeKind::ImagesFolder { .. }
658            | NodeKind::AnnotationsFolder { .. }
659            | NodeKind::ContentsFolder { .. }
660            | NodeKind::ObjectsFolder
661            | NodeKind::XrefFolder
662            | NodeKind::XrefSection { .. }
663            | NodeKind::StartXref { .. }
664            | NodeKind::Eof { .. }
665            | NodeKind::Trailer => None,
666        }
667    }
668
669    /// The `pdfboss q` expression addressing node `id` in the wire tree.
670    /// Nodes carrying an object ref address `.objects["N G"]` (the wire
671    /// entries under `.pages[]` are summaries, not the objects); `%%EOF`
672    /// has no wire form at all.
673    pub fn query(&self, id: NodeId) -> Option<String> {
674        match self.nodes[id].kind {
675            NodeKind::Document => Some(".".to_string()),
676            NodeKind::PagesFolder => Some(".pages".to_string()),
677            NodeKind::Page { index, .. } => Some(format!(".pages[{index}]")),
678            NodeKind::FontsFolder { page } => Some(format!(".pages[{page}].fonts")),
679            NodeKind::ImagesFolder { page } => Some(format!(".pages[{page}].images")),
680            NodeKind::AnnotationsFolder { page } => Some(format!(".pages[{page}].annotations")),
681            NodeKind::ContentsFolder { page } => {
682                let r = self.page_ref(page)?;
683                Some(format!(".objects[\"{} {}\"].value.Contents", r.num, r.gen))
684            }
685            NodeKind::ObjectsFolder => Some(".objects".to_string()),
686            NodeKind::Object { r, .. }
687            | NodeKind::Font { r, .. }
688            | NodeKind::Image { r, .. }
689            | NodeKind::Annotation { r, .. }
690            | NodeKind::ContentsStream { r } => Some(format!(".objects[\"{} {}\"]", r.num, r.gen)),
691            NodeKind::XrefFolder => Some(".xref".to_string()),
692            NodeKind::XrefSection { .. } => {
693                let index = self.nodes[self.xref_folder]
694                    .children
695                    .iter()
696                    .filter(|child| {
697                        matches!(self.nodes[**child].kind, NodeKind::XrefSection { .. })
698                    })
699                    .position(|child| *child == id)?;
700                Some(format!(".xref[{index}]"))
701            }
702            NodeKind::StartXref { .. } => Some(".startxref".to_string()),
703            NodeKind::Trailer => Some(".trailer".to_string()),
704            NodeKind::Eof { .. } => None,
705        }
706    }
707
708    /// The physical span (and objstm placement) recorded for object `num`.
709    pub fn object_span(&self, num: u32) -> Option<(Span, Option<(ObjRef, Span)>)> {
710        self.object_spans.get(&num).copied()
711    }
712
713    /// The page dictionary ref of page `page`, once the logical pass ran.
714    pub fn page_ref(&self, page: usize) -> Option<ObjRef> {
715        let folders = self.page_folders.get(&page)?;
716        if let NodeKind::Page { r, .. } = self.nodes[folders.page_node].kind {
717            Some(r)
718        } else {
719            None
720        }
721    }
722
723    /// Nearest ancestor page index of a node (the node itself counts).
724    pub fn page_of(&self, id: NodeId) -> Option<usize> {
725        let mut cursor = Some(id);
726        while let Some(node_id) = cursor {
727            let page = match self.nodes[node_id].kind {
728                NodeKind::Page { index, .. } => Some(index),
729                NodeKind::FontsFolder { page }
730                | NodeKind::ImagesFolder { page }
731                | NodeKind::AnnotationsFolder { page }
732                | NodeKind::ContentsFolder { page } => Some(page),
733                NodeKind::Document
734                | NodeKind::PagesFolder
735                | NodeKind::Font { .. }
736                | NodeKind::Image { .. }
737                | NodeKind::Annotation { .. }
738                | NodeKind::ContentsStream { .. }
739                | NodeKind::ObjectsFolder
740                | NodeKind::Object { .. }
741                | NodeKind::XrefFolder
742                | NodeKind::XrefSection { .. }
743                | NodeKind::StartXref { .. }
744                | NodeKind::Eof { .. }
745                | NodeKind::Trailer => None,
746            };
747            if let Some(index) = page {
748                return Some(index);
749            }
750            cursor = self.nodes[node_id].parent;
751        }
752        None
753    }
754
755    fn selected_position(&self, rows: &[TreeRow]) -> usize {
756        rows.iter()
757            .position(|row| row.id == self.selected)
758            .unwrap_or(0)
759    }
760
761    pub fn select_next(&mut self) {
762        let rows = self.visible_rows();
763        let position = self.selected_position(&rows);
764        if position + 1 < rows.len() {
765            self.selected = rows[position + 1].id;
766        }
767    }
768
769    pub fn select_prev(&mut self) {
770        let rows = self.visible_rows();
771        let position = self.selected_position(&rows);
772        if position > 0 {
773            self.selected = rows[position - 1].id;
774        }
775    }
776
777    pub fn select_top(&mut self) {
778        self.selected = self.root;
779    }
780
781    pub fn select_bottom(&mut self) {
782        if let Some(row) = self.visible_rows().last() {
783            self.selected = row.id;
784        }
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791    use pdfboss_core::elements::{Element, Span, XrefKind};
792    use pdfboss_core::{Dict, Name, ObjRef, Object};
793
794    fn obj_ref(num: u32) -> ObjRef {
795        ObjRef { num, gen: 0 }
796    }
797
798    fn physical_batch() -> Vec<Element> {
799        vec![
800            Element::Header {
801                version: (1, 7),
802                span: Span { start: 0, end: 15 },
803            },
804            Element::IndirectObject {
805                r: obj_ref(1),
806                object: Object::Null,
807                span: Span { start: 15, end: 64 },
808                in_objstm: None,
809            },
810            Element::IndirectObject {
811                r: obj_ref(2),
812                object: Object::Null,
813                span: Span {
814                    start: 64,
815                    end: 120,
816                },
817                in_objstm: Some((obj_ref(9), Span { start: 4, end: 30 })),
818            },
819            Element::XrefSection {
820                kind: XrefKind::Table,
821                span: Span {
822                    start: 120,
823                    end: 260,
824                },
825                entries: 3,
826            },
827            Element::Trailer {
828                dict: Dict::new(),
829                span: Span {
830                    start: 260,
831                    end: 300,
832                },
833            },
834            Element::StartXref {
835                offset: 120,
836                span: Span {
837                    start: 300,
838                    end: 314,
839                },
840            },
841            Element::Eof {
842                span: Span {
843                    start: 314,
844                    end: 320,
845                },
846            },
847        ]
848    }
849
850    fn logical_batch() -> Vec<Element> {
851        vec![
852            Element::Page {
853                index: 0,
854                r: obj_ref(3),
855            },
856            Element::Font {
857                page: Some(0),
858                r: obj_ref(5),
859                subtype: Name("Type1".to_string()),
860                base_font: Some(Name("Helvetica".to_string())),
861            },
862            Element::Image {
863                page: Some(0),
864                r: obj_ref(7),
865                width: 32,
866                height: 16,
867            },
868            Element::Annotation {
869                page: 0,
870                r: obj_ref(8),
871                subtype: Name("Link".to_string()),
872            },
873        ]
874    }
875
876    #[test]
877    fn new_tree_has_root_and_four_sections() {
878        let tree = TreeState::new((1, 7), 14);
879        let rows = tree.visible_rows();
880        let labels: Vec<String> = rows.iter().map(|row| tree.label(row.id)).collect();
881        assert_eq!(
882            labels,
883            vec![
884                "Document · PDF 1.7",
885                "Pages (14)",
886                "Objects",
887                "Xref",
888                "Trailer",
889            ]
890        );
891        assert_eq!(rows[0].depth, 0);
892        assert_eq!(rows[1].depth, 1);
893        assert!(tree.node(tree.root).expanded);
894        assert!(!tree.node(tree.pages_folder).expanded);
895    }
896
897    #[test]
898    fn physical_batch_populates_objects_xref_and_trailer() {
899        let mut tree = TreeState::new((1, 7), 1);
900        assert_eq!(tree.expand(tree.objects_folder), Some(TreeReq::Physical));
901        assert_eq!(tree.physical, LoadState::Loading);
902        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
903        assert_eq!(tree.physical, LoadState::Loaded);
904        assert_eq!(tree.label(tree.objects_folder), "Objects (2)");
905        assert_eq!(tree.label(tree.xref_folder), "Xref (1 secs)");
906        assert_eq!(tree.header_span, Some(Span { start: 0, end: 15 }));
907        assert_eq!(
908            tree.trailer_span,
909            Some(Span {
910                start: 260,
911                end: 300
912            })
913        );
914        assert!(tree.trailer_dict.is_some());
915        let object_ids = tree.node(tree.objects_folder).children.clone();
916        assert_eq!(tree.label(object_ids[0]), "obj 1 0");
917        assert_eq!(tree.label(object_ids[1]), "obj 2 0");
918        assert_eq!(
919            tree.object_span(1),
920            Some((Span { start: 15, end: 64 }, None))
921        );
922        assert_eq!(
923            tree.object_span(2),
924            Some((
925                Span {
926                    start: 64,
927                    end: 120
928                },
929                Some((obj_ref(9), Span { start: 4, end: 30 }))
930            ))
931        );
932        tree.expand(tree.xref_folder);
933        let xref_ids = tree.node(tree.xref_folder).children.clone();
934        let labels: Vec<String> = xref_ids.iter().map(|id| tree.label(*id)).collect();
935        assert_eq!(
936            labels,
937            vec!["xref table · 3 entries", "startxref → 120", "%%EOF"]
938        );
939    }
940
941    #[test]
942    fn query_addresses_every_node_kind_in_the_wire_tree() {
943        let mut tree = TreeState::new((1, 7), 1);
944        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
945        tree.apply_batch(TreeReq::Logical, &logical_batch(), true);
946        tree.apply_contents(0, &[obj_ref(4)]);
947
948        assert_eq!(tree.query(tree.root).as_deref(), Some("."));
949        assert_eq!(tree.query(tree.pages_folder).as_deref(), Some(".pages"));
950        assert_eq!(tree.query(tree.objects_folder).as_deref(), Some(".objects"));
951        assert_eq!(tree.query(tree.xref_folder).as_deref(), Some(".xref"));
952        assert_eq!(tree.query(tree.trailer_node).as_deref(), Some(".trailer"));
953
954        let objects = tree.node(tree.objects_folder).children.clone();
955        assert_eq!(tree.query(objects[0]).as_deref(), Some(".objects[\"1 0\"]"));
956        assert_eq!(tree.query(objects[1]).as_deref(), Some(".objects[\"2 0\"]"));
957
958        let xref_children = tree.node(tree.xref_folder).children.clone();
959        assert_eq!(tree.query(xref_children[0]).as_deref(), Some(".xref[0]"));
960        assert_eq!(tree.query(xref_children[1]).as_deref(), Some(".startxref"));
961        assert_eq!(tree.query(xref_children[2]), None, "%%EOF has no wire form");
962
963        let page = tree.node(tree.pages_folder).children[0];
964        assert_eq!(tree.query(page).as_deref(), Some(".pages[0]"));
965        for folder in tree.node(page).children.clone() {
966            let expected = match tree.node(folder).kind {
967                NodeKind::FontsFolder { .. } => ".pages[0].fonts",
968                NodeKind::ImagesFolder { .. } => ".pages[0].images",
969                NodeKind::AnnotationsFolder { .. } => ".pages[0].annotations",
970                // The wire tree has no contents array; the page object's
971                // /Contents entry is the addressable form.
972                NodeKind::ContentsFolder { .. } => ".objects[\"3 0\"].value.Contents",
973                ref other => panic!("unexpected page child {other:?}"),
974            };
975            assert_eq!(tree.query(folder).as_deref(), Some(expected));
976            for leaf in tree.node(folder).children.clone() {
977                let expected = match tree.node(leaf).kind {
978                    NodeKind::Font { .. } => ".objects[\"5 0\"]",
979                    NodeKind::Image { .. } => ".objects[\"7 0\"]",
980                    NodeKind::Annotation { .. } => ".objects[\"8 0\"]",
981                    NodeKind::ContentsStream { .. } => ".objects[\"4 0\"]",
982                    ref other => panic!("unexpected leaf {other:?}"),
983                };
984                assert_eq!(tree.query(leaf).as_deref(), Some(expected));
985            }
986        }
987    }
988
989    /// `.xref[i]` must index xref *sections* in element-stream order, the
990    /// exact order `build_tree` pushes them into the wire array, skipping
991    /// the startxref/%%EOF siblings interleaved in the tree.
992    #[test]
993    fn xref_query_index_counts_sections_only() {
994        let elements = vec![
995            Element::XrefSection {
996                kind: XrefKind::Table,
997                span: Span { start: 0, end: 10 },
998                entries: 1,
999            },
1000            Element::StartXref {
1001                offset: 0,
1002                span: Span { start: 10, end: 20 },
1003            },
1004            Element::Eof {
1005                span: Span { start: 20, end: 26 },
1006            },
1007            Element::XrefSection {
1008                kind: XrefKind::Stream,
1009                span: Span { start: 26, end: 40 },
1010                entries: 2,
1011            },
1012        ];
1013        let mut tree = TreeState::new((1, 7), 0);
1014        tree.apply_batch(TreeReq::Physical, &elements, true);
1015        let children = tree.node(tree.xref_folder).children.clone();
1016        assert_eq!(tree.query(children[0]).as_deref(), Some(".xref[0]"));
1017        assert_eq!(tree.query(children[3]).as_deref(), Some(".xref[1]"));
1018    }
1019
1020    #[test]
1021    fn expanding_objects_twice_requests_once() {
1022        let mut tree = TreeState::new((1, 7), 1);
1023        assert_eq!(tree.expand(tree.objects_folder), Some(TreeReq::Physical));
1024        assert_eq!(tree.expand(tree.objects_folder), None);
1025        assert_eq!(tree.expand(tree.xref_folder), None, "same physical pass");
1026    }
1027
1028    #[test]
1029    fn logical_batch_builds_page_subtree() {
1030        let mut tree = TreeState::new((1, 7), 1);
1031        assert_eq!(tree.expand(tree.pages_folder), Some(TreeReq::Logical));
1032        tree.apply_batch(TreeReq::Logical, &logical_batch(), true);
1033        assert_eq!(tree.logical, LoadState::Loaded);
1034        let page_id = tree.node(tree.pages_folder).children[0];
1035        assert_eq!(tree.label(page_id), "Page 1");
1036        assert_eq!(tree.page_ref(0), Some(obj_ref(3)));
1037        tree.expand(page_id);
1038        let folder_ids = tree.node(page_id).children.clone();
1039        let labels: Vec<String> = folder_ids.iter().map(|id| tree.label(*id)).collect();
1040        assert_eq!(
1041            labels,
1042            vec!["Fonts (1)", "Images (1)", "Annotations (1)", "Contents"]
1043        );
1044        let font_id = tree.node(folder_ids[0]).children[0];
1045        assert_eq!(tree.label(font_id), "Helvetica · 5 0");
1046        let image_id = tree.node(folder_ids[1]).children[0];
1047        assert_eq!(tree.label(image_id), "32x16 · 7 0");
1048        let annot_id = tree.node(folder_ids[2]).children[0];
1049        assert_eq!(tree.label(annot_id), "Link · 8 0");
1050        assert_eq!(tree.page_of(font_id), Some(0));
1051    }
1052
1053    #[test]
1054    fn contents_folder_requests_and_fills() {
1055        let mut tree = TreeState::new((1, 7), 1);
1056        tree.expand(tree.pages_folder);
1057        tree.apply_batch(TreeReq::Logical, &logical_batch(), true);
1058        let page_id = tree.node(tree.pages_folder).children[0];
1059        tree.expand(page_id);
1060        let contents_id = tree.node(page_id).children[3];
1061        assert_eq!(
1062            tree.expand(contents_id),
1063            Some(TreeReq::Contents { page: 0 })
1064        );
1065        assert_eq!(tree.expand(contents_id), None, "already loading");
1066        tree.apply_contents(0, &[obj_ref(4)]);
1067        assert_eq!(tree.label(contents_id), "Contents (1)");
1068        let stream_id = tree.node(contents_id).children[0];
1069        assert_eq!(tree.label(stream_id), "stream 4 0");
1070        assert_eq!(tree.selection_ref(stream_id), Some(obj_ref(4)));
1071    }
1072
1073    #[test]
1074    fn selection_moves_over_visible_rows_only() {
1075        let mut tree = TreeState::new((1, 7), 1);
1076        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1077        assert_eq!(tree.selected, tree.root);
1078        tree.select_next();
1079        assert_eq!(tree.selected, tree.pages_folder);
1080        tree.select_next();
1081        assert_eq!(tree.selected, tree.objects_folder);
1082        // Objects is collapsed: its children are not visited.
1083        tree.select_next();
1084        assert_eq!(tree.selected, tree.xref_folder);
1085        tree.select_next();
1086        assert_eq!(tree.selected, tree.trailer_node);
1087        tree.select_next();
1088        assert_eq!(tree.selected, tree.trailer_node, "clamped at bottom");
1089        tree.select_top();
1090        assert_eq!(tree.selected, tree.root);
1091        tree.select_bottom();
1092        assert_eq!(tree.selected, tree.trailer_node);
1093        tree.select_prev();
1094        assert_eq!(tree.selected, tree.xref_folder);
1095    }
1096
1097    #[test]
1098    fn collapse_or_parent_folds_then_climbs() {
1099        let mut tree = TreeState::new((1, 7), 1);
1100        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1101        tree.expand(tree.objects_folder);
1102        let first_object = tree.node(tree.objects_folder).children[0];
1103        tree.selected = first_object;
1104        assert!(
1105            tree.collapse_or_parent(first_object),
1106            "leaf climbs to parent"
1107        );
1108        assert_eq!(tree.selected, tree.objects_folder);
1109        assert!(
1110            tree.collapse_or_parent(tree.objects_folder),
1111            "folds open branch"
1112        );
1113        assert!(!tree.node(tree.objects_folder).expanded);
1114    }
1115
1116    #[test]
1117    fn find_object_and_reveal_expand_ancestors() {
1118        let mut tree = TreeState::new((1, 7), 1);
1119        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1120        let id = tree.find_object(obj_ref(2)).expect("object 2 present");
1121        assert!(!tree.node(tree.objects_folder).expanded);
1122        tree.reveal(id);
1123        assert!(tree.node(tree.objects_folder).expanded);
1124        assert!(tree.visible_rows().iter().any(|row| row.id == id));
1125        assert_eq!(tree.find_object(obj_ref(42)), None);
1126    }
1127
1128    #[test]
1129    fn breadcrumb_walks_short_labels() {
1130        let mut tree = TreeState::new((1, 7), 1);
1131        assert_eq!(tree.breadcrumb(), "/Document");
1132        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1133        let id = tree.find_object(obj_ref(1)).expect("object 1");
1134        tree.selected = id;
1135        assert_eq!(tree.breadcrumb(), "/Document/Objects/obj 1 0");
1136        tree.selected = tree.trailer_node;
1137        assert_eq!(tree.breadcrumb(), "/Document/Trailer");
1138    }
1139
1140    #[test]
1141    fn mark_failed_records_failure() {
1142        let mut tree = TreeState::new((1, 7), 1);
1143        tree.expand(tree.objects_folder);
1144        tree.mark_failed(TreeReq::Physical);
1145        assert_eq!(tree.physical, LoadState::Failed);
1146    }
1147
1148    #[test]
1149    fn reapplying_a_batch_is_a_no_op() {
1150        let mut tree = TreeState::new((1, 7), 1);
1151        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1152        tree.apply_batch(TreeReq::Logical, &logical_batch(), true);
1153        tree.expand(tree.objects_folder);
1154        tree.expand(tree.xref_folder);
1155        tree.expand(tree.pages_folder);
1156        let page_id = tree.node(tree.pages_folder).children[0];
1157        tree.expand(page_id);
1158        let before = tree.visible_rows();
1159
1160        tree.apply_batch(TreeReq::Physical, &physical_batch(), true);
1161        tree.apply_batch(TreeReq::Logical, &logical_batch(), true);
1162        let after = tree.visible_rows();
1163
1164        assert_eq!(
1165            before, after,
1166            "reapplying an already-loaded batch must not duplicate nodes"
1167        );
1168    }
1169
1170    #[test]
1171    fn failed_section_can_be_retried_by_expanding() {
1172        let mut tree = TreeState::new((1, 7), 1);
1173        assert_eq!(tree.expand(tree.objects_folder), Some(TreeReq::Physical));
1174        tree.mark_failed(TreeReq::Physical);
1175        assert_eq!(tree.physical, LoadState::Failed);
1176        assert_eq!(
1177            tree.expand(tree.objects_folder),
1178            Some(TreeReq::Physical),
1179            "re-expanding a failed section must re-request its load"
1180        );
1181        assert_eq!(tree.physical, LoadState::Loading);
1182    }
1183}