use std::sync::Arc;
use crossterm::event::KeyEvent;
use pdfboss_core::elements::{Element, Span};
use pdfboss_core::{pretty, ObjRef};
use crate::hexview::{HexSource, HexState};
use crate::input::{action_for, Action, KeyContext};
use crate::inspector::{InspectorPayload, InspectorState};
use crate::markdown::MarkdownState;
use crate::preview::{PreviewFrame, PreviewState, RESIZE_DEBOUNCE_TICKS};
use crate::search::{SearchHit, SearchState};
use crate::tree::{LoadState, NodeId, NodeKind, TreeReq, TreeState};
use crate::ui;
use crate::yank::{self, YankFormat, YankTarget};
const TOAST_TICKS: u8 = 30;
const PAGE_JUMP: usize = 10;
const HISTORY_CAP: usize = 64;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pane {
Tree,
Inspector,
Hex,
}
#[derive(Debug)]
pub enum Msg {
Key(KeyEvent),
Resize(u16, u16),
Tick,
TreeBatch {
req: TreeReq,
elements: Vec<Element>,
errors: usize,
done: bool,
},
TreeFailed { req: TreeReq, error: String },
ContentsLoaded { page: usize, refs: Vec<ObjRef> },
ContentsFailed { page: usize, error: String },
InspectorLoaded {
generation: u64,
payload: InspectorPayload,
},
InspectorFailed { generation: u64, error: String },
HexLoaded {
generation: u64,
window_start: u64,
total_len: u64,
bytes: Vec<u8>,
},
HexFailed { generation: u64, error: String },
SearchResult { generation: u64, hit: SearchHit },
SearchDone { generation: u64 },
PreviewReady {
generation: u64,
result: Result<PreviewFrame, String>,
},
MarkdownReady {
generation: u64,
result: Result<String, String>,
},
Yanked { result: Result<String, String> },
}
#[derive(Debug, Clone)]
pub enum Cmd {
LoadTree(TreeReq),
LoadContents { page: usize, r: ObjRef },
LoadObject { generation: u64, r: ObjRef },
DecodeStream { generation: u64, r: ObjRef },
LoadHex {
generation: u64,
source: HexSource,
window_start: u64,
},
StartSearch { generation: u64, query: String },
CancelSearch { generation: u64 },
RenderPreview {
generation: u64,
page: usize,
max_w: u32,
max_h: u32,
file_bytes: Option<Arc<Vec<u8>>>,
},
ExtractMarkdown { generation: u64, page: usize },
Copy { text: String, what: &'static str },
YankMarkdown { page: usize },
YankSpan {
source: HexSource,
slice: Option<Span>,
base: u64,
format: YankFormat,
},
}
pub struct App {
pub title: String,
pub target: String,
pub tree: TreeState,
pub inspector: InspectorState,
pub hex: HexState,
pub preview: PreviewState,
pub markdown: MarkdownState,
pub search: SearchState,
pub focus: Pane,
pub history: Vec<NodeId>,
pub pending_jump: Option<ObjRef>,
pub toast: Option<String>,
pub yank_open: bool,
toast_ticks: u8,
pub size: (u16, u16),
pub splits: ui::Splits,
pub should_quit: bool,
pub inspector_generation: u64,
pub hex_generation: u64,
last_selected: Option<NodeId>,
}
impl App {
pub fn new(
title: String,
target: String,
version: (u8, u8),
page_count: usize,
size: (u16, u16),
) -> App {
let mut app = App {
title,
target,
tree: TreeState::new(version, page_count),
inspector: InspectorState::new(),
hex: HexState::new(),
preview: PreviewState::new(),
markdown: MarkdownState::new(),
search: SearchState::new(),
focus: Pane::Tree,
history: Vec::new(),
pending_jump: None,
toast: None,
yank_open: false,
toast_ticks: 0,
size,
splits: ui::Splits::default(),
should_quit: false,
inspector_generation: 0,
hex_generation: 0,
last_selected: None,
};
let startup = app.on_select(true);
assert!(startup.is_empty(), "the Document root needs no fetches");
app
}
pub fn toast(&mut self, message: impl Into<String>) {
self.toast = Some(message.into());
self.toast_ticks = TOAST_TICKS;
}
pub fn status_line(&self) -> String {
if self.search.active {
return self.search.status_line();
}
if self.yank_open {
return "yank \u{b7} [q]uery [c]ommand [x] hex [b]ytes [e]lement \
[m]arkdown [o]bj ref [esc]"
.to_string();
}
if let Some(message) = &self.toast {
return message.clone();
}
let yank = if self.size.0 >= 90 { "[y] yank " } else { "" };
let resize = if self.size.0 >= 110 {
"[alt+arrows] resize "
} else {
""
};
format!(
"{} \u{b7} {} \u{b7} [/] search [p] preview [m] markdown {yank}{resize}[q] quit",
self.title,
self.tree.breadcrumb()
)
}
pub fn update(&mut self, msg: Msg) -> Vec<Cmd> {
match msg {
Msg::Key(key) => self.on_key(key),
Msg::Resize(width, height) => {
self.size = (width, height);
if self.preview.active {
self.preview.debounce = Some(RESIZE_DEBOUNCE_TICKS);
}
Vec::new()
}
Msg::Tick => self.on_tick(),
Msg::TreeBatch {
req,
elements,
errors,
done,
} => {
self.tree.apply_batch(req, &elements, done);
if errors > 0 {
self.toast(format!("{errors} element(s) unreadable, skipped"));
}
let mut cmds = Vec::new();
if done {
if let Some(r) = self.pending_jump.take() {
cmds.extend(self.jump_to(r));
}
cmds.extend(self.on_select(true));
}
cmds
}
Msg::TreeFailed { req, error } => {
self.tree.mark_failed(req);
self.toast(format!("load failed: {error}"));
Vec::new()
}
Msg::ContentsLoaded { page, refs } => {
self.tree.apply_contents(page, &refs);
Vec::new()
}
Msg::ContentsFailed { page, error } => {
self.tree.mark_failed(TreeReq::Contents { page });
self.toast(format!("contents of page {}: {error}", page + 1));
Vec::new()
}
Msg::InspectorLoaded {
generation,
payload,
} => {
if generation == self.inspector_generation {
match payload {
InspectorPayload::Object { r, object } => {
self.inspector.set_object(r, object)
}
InspectorPayload::Decoded {
r,
data,
passthrough,
} => self.inspector.set_decoded(r, data, passthrough),
}
}
Vec::new()
}
Msg::InspectorFailed { generation, error } => {
if generation == self.inspector_generation {
let title = self.inspector.title.clone();
self.inspector
.show_message(&title, vec![format!("error: {error}")]);
self.toast(error);
}
Vec::new()
}
Msg::HexLoaded {
generation,
window_start,
total_len,
bytes,
} => {
if generation == self.hex_generation {
self.hex.apply_loaded(window_start, total_len, bytes);
}
Vec::new()
}
Msg::HexFailed { generation, error } => {
if generation == self.hex_generation {
self.hex.loading = false;
self.hex.error = Some(error.clone());
self.toast(error);
}
Vec::new()
}
Msg::SearchResult { generation, hit } => {
self.search.add_hit(generation, hit);
Vec::new()
}
Msg::SearchDone { generation } => {
self.search.finish(generation);
Vec::new()
}
Msg::PreviewReady { generation, result } => {
if self.preview.apply_ready(generation, result) {
if let Some(error) = self.preview.error.clone() {
self.toast(format!("preview: {error}"));
} else if let Some(notice) = self.preview.notice.clone() {
self.toast(format!("preview: {notice}"));
}
}
Vec::new()
}
Msg::MarkdownReady { generation, result } => {
if self.markdown.apply_ready(generation, result) {
if let Some(error) = self.markdown.error.clone() {
self.toast(format!("markdown: {error}"));
}
}
Vec::new()
}
Msg::Yanked { result } => {
match result {
Ok(text) => self.toast(text),
Err(error) => self.toast(format!("yank failed: {error}")),
}
Vec::new()
}
}
}
fn on_tick(&mut self) -> Vec<Cmd> {
if self.toast_ticks > 0 {
self.toast_ticks -= 1;
if self.toast_ticks == 0 {
self.toast = None;
}
}
self.markdown.tick();
if self.preview.tick() {
return self.request_preview();
}
Vec::new()
}
fn on_key(&mut self, key: KeyEvent) -> Vec<Cmd> {
let context = if self.search.active {
KeyContext::Search
} else if self.yank_open {
KeyContext::Yank
} else {
KeyContext::Normal
};
self.on_action(action_for(key, context))
}
fn on_action(&mut self, action: Action) -> Vec<Cmd> {
match action {
Action::Noop => Vec::new(),
Action::Quit => {
self.should_quit = true;
Vec::new()
}
Action::OpenSearch => {
self.search.open();
Vec::new()
}
Action::SearchChar(c) => {
let generation = self.search.push_char(c);
self.start_search(generation)
}
Action::SearchBackspace => match self.search.pop_char() {
Some(generation) => self.start_search(generation),
None => Vec::new(),
},
Action::SearchAccept => {
self.search.accept();
Vec::new()
}
Action::SearchCancel => {
self.search.cancel();
vec![Cmd::CancelSearch {
generation: self.search.generation,
}]
}
Action::NextHit => match self.search.next_hit() {
Some(hit) => self.jump_to(hit.r),
None => Vec::new(),
},
Action::PrevHit => match self.search.prev_hit() {
Some(hit) => self.jump_to(hit.r),
None => Vec::new(),
},
Action::OpenYank => {
self.yank_open = true;
Vec::new()
}
Action::Yank(target) => {
self.yank_open = false;
self.on_yank(target)
}
Action::YankCancel => {
self.yank_open = false;
Vec::new()
}
Action::FocusNext => {
self.focus = match self.focus {
Pane::Tree => Pane::Inspector,
Pane::Inspector => Pane::Hex,
Pane::Hex => Pane::Tree,
};
Vec::new()
}
Action::ResizeLeft => self.resize(|s| s.tree = s.tree.saturating_sub(ui::SPLIT_STEP)),
Action::ResizeRight => self.resize(|s| s.tree += ui::SPLIT_STEP),
Action::ResizeUp => {
self.resize(|s| s.right_top = s.right_top.saturating_sub(ui::SPLIT_STEP))
}
Action::ResizeDown => self.resize(|s| s.right_top += ui::SPLIT_STEP),
Action::TogglePreview => {
if self.preview.active {
self.preview.active = false;
Vec::new()
} else {
self.markdown.active = false;
self.preview.active = true;
self.request_preview()
}
}
Action::ToggleMarkdown => {
if self.markdown.active {
self.markdown.active = false;
Vec::new()
} else {
self.preview.active = false;
self.markdown.active = true;
self.request_markdown()
}
}
Action::CycleView => {
if !self.inspector.is_stream() {
if self.inspector.object.is_some() {
self.toast("d: not a stream");
}
return Vec::new();
}
let needs_decode = self.inspector.cycle_mode();
match (needs_decode, self.inspector.object.as_ref()) {
(true, Some((r, ..))) => vec![Cmd::DecodeStream {
generation: self.inspector_generation,
r: *r,
}],
(true, None) | (false, ..) => Vec::new(),
}
}
Action::Back => match self.history.pop() {
Some(id) => {
self.tree.reveal(id);
self.tree.selected = id;
self.on_select(false)
}
None => {
self.toast("history empty");
Vec::new()
}
},
Action::Activate => self.on_activate(),
Action::MoveUp => self.on_move(-1),
Action::MoveDown => self.on_move(1),
Action::PageUp => self.on_page(-1),
Action::PageDown => self.on_page(1),
Action::Collapse => match self.focus {
Pane::Tree => {
self.tree.collapse_or_parent(self.tree.selected);
self.on_select(false)
}
Pane::Inspector | Pane::Hex => Vec::new(),
},
Action::Expand => match self.focus {
Pane::Tree => self.expand_selected(),
Pane::Inspector | Pane::Hex => Vec::new(),
},
Action::Top => match self.focus {
Pane::Tree => {
self.tree.select_top();
self.on_select(false)
}
Pane::Inspector if self.markdown.active => {
self.markdown.scroll_to(0);
Vec::new()
}
Pane::Inspector => {
self.inspector.scroll = 0;
self.inspector.ref_cursor = None;
Vec::new()
}
Pane::Hex => {
self.hex.scroll_to(0);
self.ensure_hex_window()
}
},
Action::Bottom => match self.focus {
Pane::Tree => {
self.tree.select_bottom();
self.on_select(false)
}
Pane::Inspector if self.markdown.active => {
self.markdown.scroll_to(u64::MAX);
Vec::new()
}
Pane::Inspector => {
self.inspector.scroll = self.inspector.lines.len().saturating_sub(1) as u16;
Vec::new()
}
Pane::Hex => {
let last = self.hex.line_count().saturating_sub(1);
self.hex.scroll_to(last);
self.ensure_hex_window()
}
},
}
}
fn on_yank(&mut self, target: YankTarget) -> Vec<Cmd> {
match target {
YankTarget::Query => match self.tree.query(self.tree.selected) {
Some(query) => vec![Cmd::Copy {
text: query,
what: "query",
}],
None => {
self.toast("no query for %%EOF");
Vec::new()
}
},
YankTarget::Command => match self.tree.query(self.tree.selected) {
Some(query) => vec![Cmd::Copy {
text: yank::q_command(&self.target, &query),
what: "command",
}],
None => {
self.toast("no query for %%EOF");
Vec::new()
}
},
YankTarget::ObjRef => match self.tree.selection_ref(self.tree.selected) {
Some(r) => vec![Cmd::Copy {
text: format!("{} {} R", r.num, r.gen),
what: "obj ref",
}],
None => {
self.toast("selection has no object ref");
Vec::new()
}
},
YankTarget::Element => match self.element_text() {
Some(text) => vec![Cmd::Copy {
text,
what: "element",
}],
None => {
self.toast("element still loading");
Vec::new()
}
},
YankTarget::Markdown => match self.tree.page_of(self.tree.selected) {
Some(page) => vec![Cmd::YankMarkdown { page }],
None => {
self.toast("selection has no page");
Vec::new()
}
},
YankTarget::Hexdump | YankTarget::Bytes => {
let format = match target {
YankTarget::Hexdump => YankFormat::Hexdump,
_ => YankFormat::Bytes,
};
let Some(source) = self.hex.source.clone() else {
self.toast("selection has no bytes");
return Vec::new();
};
match &source {
HexSource::File { span }
if span.end.saturating_sub(span.start) > yank::CAP_BYTES =>
{
let selector = format!("range:{}-{}", span.start, span.end);
vec![Cmd::Copy {
text: yank::hex_command(&self.target, &selector),
what: "hex command (selection exceeds the yank cap)",
}]
}
HexSource::File { span } => vec![Cmd::YankSpan {
base: span.start,
source,
slice: None,
format,
}],
HexSource::DecodedObjStm { .. } => vec![Cmd::YankSpan {
base: self.hex.highlight.map_or(0, |member| member.start),
source,
slice: self.hex.highlight,
format,
}],
}
}
}
}
fn element_text(&self) -> Option<String> {
if self.inspector.loading {
return None;
}
if let Some((.., object)) = &self.inspector.object {
return Some(pretty::format_object(object));
}
if self.inspector.lines.is_empty() {
return None;
}
Some(self.inspector.lines.join("\n"))
}
fn on_move(&mut self, delta: i32) -> Vec<Cmd> {
match self.focus {
Pane::Tree => {
if delta < 0 {
self.tree.select_prev();
} else {
self.tree.select_next();
}
self.on_select(false)
}
Pane::Inspector if self.markdown.active => {
self.markdown.scroll_by(delta);
Vec::new()
}
Pane::Inspector => {
self.inspector.move_cursor(delta);
Vec::new()
}
Pane::Hex => {
self.hex.scroll_by(i64::from(delta));
self.ensure_hex_window()
}
}
}
fn on_page(&mut self, direction: i32) -> Vec<Cmd> {
match self.focus {
Pane::Tree => {
let mut remaining = PAGE_JUMP;
while remaining > 0 {
remaining -= 1;
if direction < 0 {
self.tree.select_prev();
} else {
self.tree.select_next();
}
}
self.on_select(false)
}
Pane::Inspector if self.markdown.active => {
self.markdown.scroll_by(direction * PAGE_JUMP as i32);
Vec::new()
}
Pane::Inspector => {
self.inspector.move_cursor(direction * PAGE_JUMP as i32);
Vec::new()
}
Pane::Hex => {
let rows = i64::from(self.hex_visible_rows());
self.hex.scroll_by(i64::from(direction) * rows);
self.ensure_hex_window()
}
}
}
fn on_activate(&mut self) -> Vec<Cmd> {
match self.focus {
Pane::Tree => match self.tree.selection_ref(self.tree.selected) {
Some(r) => self.jump_to(r),
None => self.expand_selected(),
},
Pane::Inspector => match self.inspector.current_ref() {
Some(r) => self.jump_to(r),
None => Vec::new(),
},
Pane::Hex => Vec::new(),
}
}
pub fn jump_to(&mut self, r: ObjRef) -> Vec<Cmd> {
if let Some(id) = self.tree.find_object(r) {
self.history.push(self.tree.selected);
if self.history.len() > HISTORY_CAP {
self.history.remove(0);
}
self.tree.reveal(id);
self.tree.selected = id;
self.focus = Pane::Tree;
return self.on_select(true);
}
match self.tree.physical {
LoadState::NotLoaded => {
self.tree.physical = LoadState::Loading;
self.pending_jump = Some(r);
vec![Cmd::LoadTree(TreeReq::Physical)]
}
LoadState::Loading => {
self.pending_jump = Some(r);
Vec::new()
}
LoadState::Loaded | LoadState::Failed => {
self.toast(format!("object {} {} R not found", r.num, r.gen));
Vec::new()
}
}
}
fn start_search(&mut self, generation: u64) -> Vec<Cmd> {
if self.search.query.is_empty() {
return Vec::new();
}
vec![Cmd::StartSearch {
generation,
query: self.search.query.clone(),
}]
}
fn expand_selected(&mut self) -> Vec<Cmd> {
let id = self.tree.selected;
if self.tree.is_branch(id)
&& self.tree.node(id).expanded
&& !self.tree.node(id).children.is_empty()
{
self.tree.selected = self.tree.node(id).children[0];
return self.on_select(false);
}
match self.tree.expand(id) {
Some(TreeReq::Contents { page }) => match self.tree.page_ref(page) {
Some(r) => vec![Cmd::LoadContents { page, r }],
None => {
self.toast("page object unknown");
Vec::new()
}
},
Some(req) => vec![Cmd::LoadTree(req)],
None => Vec::new(),
}
}
fn on_select(&mut self, force: bool) -> Vec<Cmd> {
if !force && self.last_selected == Some(self.tree.selected) {
return Vec::new();
}
self.last_selected = Some(self.tree.selected);
self.inspector_generation += 1;
self.hex_generation += 1;
let id = self.tree.selected;
let kind = self.tree.node(id).kind.clone();
let mut cmds = Vec::new();
match kind {
NodeKind::Document => {
self.inspector.show_message(
"Document",
vec![
format!("version: {}.{}", self.tree.version.0, self.tree.version.1),
format!("pages: {}", self.tree.page_count),
],
);
self.hex.clear();
}
NodeKind::Object { r, span, in_objstm } => {
self.inspector
.show_loading(&format!("obj {} {}", r.num, r.gen));
cmds.push(Cmd::LoadObject {
generation: self.inspector_generation,
r,
});
cmds.extend(self.hex_for(span, in_objstm));
}
NodeKind::Page { r, .. }
| NodeKind::Font { r, .. }
| NodeKind::Image { r, .. }
| NodeKind::Annotation { r, .. }
| NodeKind::ContentsStream { r } => {
self.inspector
.show_loading(&format!("obj {} {}", r.num, r.gen));
cmds.push(Cmd::LoadObject {
generation: self.inspector_generation,
r,
});
match self.tree.object_span(r.num) {
Some((span, in_objstm)) => cmds.extend(self.hex_for(span, in_objstm)),
None => {
self.hex.clear();
if self.tree.physical == LoadState::NotLoaded {
self.tree.physical = LoadState::Loading;
cmds.push(Cmd::LoadTree(TreeReq::Physical));
}
}
}
}
NodeKind::XrefSection {
kind,
span,
entries,
} => {
let name = match kind {
pdfboss_core::elements::XrefKind::Table => "xref table",
pdfboss_core::elements::XrefKind::Stream => "xref stream",
};
self.inspector.show_message(
name,
vec![
format!("entries: {entries}"),
format!("span: {:#x}..{:#x}", span.start, span.end),
],
);
cmds.extend(self.hex_file(span));
}
NodeKind::StartXref { offset, span } => {
self.inspector
.show_message("startxref", vec![format!("offset: {offset} ({offset:#x})")]);
cmds.extend(self.hex_file(span));
}
NodeKind::Eof { span } => {
self.inspector
.show_message("%%EOF", vec!["end-of-file marker".to_string()]);
cmds.extend(self.hex_file(span));
}
NodeKind::Trailer => {
match self.tree.trailer_dict.clone() {
Some(dict) => self.inspector.set_dict("Trailer", &dict),
None if matches!(self.tree.physical, LoadState::Loaded | LoadState::Failed) => {
self.inspector
.show_message("Trailer", vec!["no trailer found".to_string()]);
}
None => {
self.inspector.show_loading("Trailer");
if self.tree.physical == LoadState::NotLoaded {
self.tree.physical = LoadState::Loading;
cmds.push(Cmd::LoadTree(TreeReq::Physical));
}
}
}
match self.tree.trailer_span {
Some(span) => cmds.extend(self.hex_file(span)),
None => self.hex.clear(),
}
}
NodeKind::PagesFolder
| NodeKind::FontsFolder { .. }
| NodeKind::ImagesFolder { .. }
| NodeKind::AnnotationsFolder { .. }
| NodeKind::ContentsFolder { .. }
| NodeKind::ObjectsFolder
| NodeKind::XrefFolder => {
let label = self.tree.label(id);
self.inspector.show_message(&label, Vec::new());
self.hex.clear();
}
}
cmds.extend(self.refresh_page_panes());
cmds
}
fn refresh_page_panes(&mut self) -> Vec<Cmd> {
let Some(page) = self.tree.page_of(self.tree.selected) else {
return Vec::new();
};
if self.preview.active && self.preview.page != Some(page) {
return self.request_preview();
}
if self.markdown.active && self.markdown.page != Some(page) {
return self.request_markdown();
}
Vec::new()
}
fn hex_for(&mut self, span: Span, in_objstm: Option<(ObjRef, Span)>) -> Vec<Cmd> {
match in_objstm {
Some((container, member)) => {
let source = HexSource::DecodedObjStm { container };
self.hex.set_source(source.clone());
self.hex.highlight = Some(member);
vec![Cmd::LoadHex {
generation: self.hex_generation,
source,
window_start: 0,
}]
}
None => self.hex_file(span),
}
}
fn hex_file(&mut self, span: Span) -> Vec<Cmd> {
let source = HexSource::File { span };
self.hex.set_source(source.clone());
vec![Cmd::LoadHex {
generation: self.hex_generation,
source,
window_start: 0,
}]
}
fn ensure_hex_window(&mut self) -> Vec<Cmd> {
let rows = self.hex_visible_rows();
match (
self.hex.visible_window_missing(rows),
self.hex.source.clone(),
) {
(Some(window_start), Some(source)) => {
self.hex.loading = true;
self.hex_generation += 1;
vec![Cmd::LoadHex {
generation: self.hex_generation,
source,
window_start,
}]
}
(Some(..), None) | (None, ..) => Vec::new(),
}
}
fn resize(&mut self, adjust: impl FnOnce(&mut ui::Splits)) -> Vec<Cmd> {
let mut next = self.splits;
adjust(&mut next);
next.tree = next
.tree
.clamp(*ui::TREE_SPLIT.start(), *ui::TREE_SPLIT.end());
next.right_top = next
.right_top
.clamp(*ui::RIGHT_TOP_SPLIT.start(), *ui::RIGHT_TOP_SPLIT.end());
if next == self.splits {
return Vec::new();
}
self.splits = next;
if self.preview.active {
return self.request_preview();
}
Vec::new()
}
fn request_preview(&mut self) -> Vec<Cmd> {
if self.tree.page_count == 0 {
self.toast("document has no pages to preview");
self.preview.active = false;
return Vec::new();
}
let page = self.tree.page_of(self.tree.selected).unwrap_or(0);
let generation = self.preview.start_render(page);
let (max_w, max_h) = self.preview_budget();
vec![Cmd::RenderPreview {
generation,
page,
max_w,
max_h,
file_bytes: self.preview.file_bytes.clone(),
}]
}
fn request_markdown(&mut self) -> Vec<Cmd> {
if self.tree.page_count == 0 {
self.toast("document has no pages to extract");
self.markdown.active = false;
return Vec::new();
}
let page = self.tree.page_of(self.tree.selected).unwrap_or(0);
let generation = self.markdown.start_extract(page);
vec![Cmd::ExtractMarkdown { generation, page }]
}
fn preview_budget(&self) -> (u32, u32) {
let area = ratatui::layout::Rect::new(0, 0, self.size.0, self.size.1);
let split = ui::panes(area, self.splits);
let width = u32::from(split.right_top.width.saturating_sub(2)).max(1);
let height = (u32::from(split.right_top.height.saturating_sub(2)) * 2).max(1);
(width, height)
}
fn hex_visible_rows(&self) -> u16 {
let area = ratatui::layout::Rect::new(0, 0, self.size.0, self.size.1);
ui::panes(area, self.splits)
.hex
.height
.saturating_sub(2)
.max(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pdfboss_core::elements::{Element, Span, XrefKind};
use pdfboss_core::{Dict, Name, ObjRef, Object, Stream};
fn key(code: KeyCode) -> Msg {
Msg::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn resize_key(code: KeyCode) -> Msg {
Msg::Key(KeyEvent::new(
code,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
))
}
#[test]
fn ctrl_shift_arrows_move_the_dividers_and_clamp() {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
assert_eq!(app.splits, ui::Splits::default());
app.update(resize_key(KeyCode::Right));
assert_eq!(app.splits.tree, ui::Splits::default().tree + ui::SPLIT_STEP);
app.update(resize_key(KeyCode::Left));
assert_eq!(app.splits.tree, ui::Splits::default().tree);
app.update(resize_key(KeyCode::Down));
assert_eq!(
app.splits.right_top,
ui::Splits::default().right_top + ui::SPLIT_STEP
);
for _ in 0..40 {
app.update(resize_key(KeyCode::Left));
app.update(resize_key(KeyCode::Up));
}
assert_eq!(app.splits.tree, *ui::TREE_SPLIT.start());
assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.start());
for _ in 0..40 {
app.update(resize_key(KeyCode::Right));
app.update(resize_key(KeyCode::Down));
}
assert_eq!(app.splits.tree, *ui::TREE_SPLIT.end());
assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.end());
}
#[test]
fn resizing_rerenders_an_active_preview() {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
let cmds = app.update(key(KeyCode::Char('p')));
assert!(
matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
"opening the preview renders it"
);
let cmds = app.update(resize_key(KeyCode::Down));
assert!(
matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
"the raster was fit to the old pane size, so a resize re-renders"
);
let at_bound = app.splits;
for _ in 0..40 {
app.update(resize_key(KeyCode::Down));
}
assert_eq!(app.splits.right_top, *ui::RIGHT_TOP_SPLIT.end());
let cmds = app.update(resize_key(KeyCode::Down));
assert!(
cmds.is_empty(),
"a keypress that moves nothing renders nothing (from {at_bound:?})"
);
}
fn obj_ref(num: u32) -> ObjRef {
ObjRef { num, gen: 0 }
}
fn physical_elements() -> Vec<Element> {
let mut trailer = Dict::new();
trailer.insert(Name("Root".to_string()), Object::Ref(obj_ref(1)));
vec![
Element::Header {
version: (1, 7),
span: Span { start: 0, end: 15 },
},
Element::IndirectObject {
r: obj_ref(1),
object: Object::Null,
span: Span { start: 15, end: 64 },
in_objstm: None,
},
Element::IndirectObject {
r: obj_ref(2),
object: Object::Null,
span: Span {
start: 64,
end: 120,
},
in_objstm: Some((obj_ref(9), Span { start: 4, end: 30 })),
},
Element::XrefSection {
kind: XrefKind::Table,
span: Span {
start: 120,
end: 260,
},
entries: 3,
},
Element::Trailer {
dict: trailer,
span: Span {
start: 260,
end: 300,
},
},
]
}
fn loaded_app() -> App {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
let cmds = app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements: physical_elements(),
errors: 0,
done: true,
});
assert!(cmds.is_empty(), "root selection refresh needs no data");
app
}
#[test]
fn quit_sets_flag() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('q')));
assert!(app.should_quit);
}
fn two_page_app() -> App {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
2,
(80, 24),
);
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements: physical_elements(),
errors: 0,
done: true,
});
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Logical,
elements: vec![
Element::Page {
index: 0,
r: obj_ref(1),
},
Element::Page {
index: 1,
r: obj_ref(2),
},
],
errors: 0,
done: true,
});
app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); app
}
#[test]
fn active_preview_follows_the_selection_to_another_page() {
let mut app = two_page_app();
let cmds = app.update(key(KeyCode::Char('p')));
assert!(
matches!(cmds.as_slice(), [Cmd::RenderPreview { page: 0, .. }]),
"preview opens on the selected page: {cmds:?}"
);
let cmds = app.update(key(KeyCode::Char('j'))); assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::RenderPreview { page: 1, .. })),
"moving to page 2 must re-render the active preview: {cmds:?}"
);
}
#[test]
fn active_markdown_follows_the_selection_to_another_page() {
let mut app = two_page_app();
let cmds = app.update(key(KeyCode::Char('m')));
assert!(
matches!(cmds.as_slice(), [Cmd::ExtractMarkdown { page: 0, .. }]),
"markdown opens on the selected page: {cmds:?}"
);
let cmds = app.update(key(KeyCode::Char('j'))); assert!(
cmds.iter()
.any(|cmd| matches!(cmd, Cmd::ExtractMarkdown { page: 1, .. })),
"moving to page 2 must re-extract the active markdown: {cmds:?}"
);
}
#[test]
fn selection_without_a_page_ancestor_keeps_the_preview() {
let mut app = two_page_app();
app.update(key(KeyCode::Char('p')));
let cmds = app.update(key(KeyCode::Char('G'))); assert!(
!cmds
.iter()
.any(|cmd| matches!(cmd, Cmd::RenderPreview { .. })),
"browsing outside the page tree must not re-render: {cmds:?}"
);
}
#[test]
fn selection_within_the_same_page_does_not_rerender() {
let mut app = two_page_app();
app.update(key(KeyCode::Char('p')));
app.update(key(KeyCode::Char('l'))); let cmds = app.update(key(KeyCode::Char('j'))); assert!(
!cmds
.iter()
.any(|cmd| matches!(cmd, Cmd::RenderPreview { .. })),
"same page needs no re-render: {cmds:?}"
);
}
#[test]
fn selecting_an_object_loads_inspector_and_hex() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); let cmds = app.update(key(KeyCode::Char('j'))); assert!(cmds.iter().any(|cmd| matches!(
cmd,
Cmd::LoadObject { r, .. } if r.num == 1
)));
assert!(cmds.iter().any(|cmd| matches!(
cmd,
Cmd::LoadHex { source: crate::hexview::HexSource::File { span }, window_start: 0, .. }
if span.start == 15 && span.end == 64
)));
assert_eq!(app.inspector.title, "obj 1 0");
assert!(app.inspector.loading);
}
#[test]
fn search_hit_jump_and_backspace_history() {
let mut app = loaded_app();
let root = app.tree.selected;
app.update(key(KeyCode::Char('/')));
assert!(app.search.active);
let cmds = app.update(key(KeyCode::Char('2')));
let generation = match cmds.as_slice() {
[Cmd::StartSearch { generation, query }] => {
assert_eq!(query, "2");
*generation
}
other => panic!("expected StartSearch, got {:?}", other),
};
app.update(Msg::SearchResult {
generation,
hit: crate::search::SearchHit { r: obj_ref(2) },
});
app.update(Msg::SearchDone { generation });
app.update(key(KeyCode::Enter)); assert!(!app.search.active);
let cmds = app.update(key(KeyCode::Char('n')));
assert!(cmds.iter().any(|cmd| matches!(
cmd,
Cmd::LoadObject { r, .. } if r.num == 2
)));
let jumped = app.tree.selected;
assert_ne!(jumped, root);
assert_eq!(app.history, vec![root]);
let cmds = app.update(key(KeyCode::Backspace));
assert_eq!(app.tree.selected, root);
assert!(app.history.is_empty());
assert!(cmds.is_empty(), "root selection needs no fetches");
}
#[test]
fn search_cancel_advances_the_epoch_past_the_in_flight_generation() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('/')));
let cmds = app.update(key(KeyCode::Char('a')));
let in_flight_generation = match cmds.as_slice() {
[Cmd::StartSearch { generation, .. }] => *generation,
other => panic!("expected StartSearch, got {:?}", other),
};
let cmds = app.update(key(KeyCode::Esc));
assert!(!app.search.active);
match cmds.as_slice() {
[Cmd::CancelSearch { generation }] => {
assert!(
*generation > in_flight_generation,
"cancel's epoch must be newer than the in-flight search's generation"
);
}
other => panic!("expected CancelSearch, got {:?}", other),
}
}
#[test]
fn jump_before_physical_load_defers() {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
let cmds = app.jump_to(obj_ref(2));
assert!(matches!(
cmds.as_slice(),
[Cmd::LoadTree(crate::tree::TreeReq::Physical)]
));
assert_eq!(app.pending_jump, Some(obj_ref(2)));
let cmds = app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements: physical_elements(),
errors: 0,
done: true,
});
assert_eq!(app.pending_jump, None);
assert!(cmds.iter().any(|cmd| matches!(
cmd,
Cmd::LoadObject { r, .. } if r.num == 2
)));
}
#[test]
fn d_cycles_stream_views_and_requests_decode() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('j')));
app.update(key(KeyCode::Char('j')));
app.update(key(KeyCode::Char('l')));
app.update(key(KeyCode::Char('j'))); app.update(Msg::InspectorLoaded {
generation: app.inspector_generation,
payload: crate::inspector::InspectorPayload::Object {
r: obj_ref(1),
object: Object::Stream(Stream {
dict: Dict::new(),
data: b"BT ET".to_vec(),
}),
},
});
let cmds = app.update(key(KeyCode::Char('d'))); assert!(cmds.is_empty());
let cmds = app.update(key(KeyCode::Char('d'))); assert!(matches!(
cmds.as_slice(),
[Cmd::DecodeStream { r, .. }] if r.num == 1
));
}
#[test]
fn resize_debounces_preview_rerender() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('p')));
assert!(app.preview.active);
assert!(matches!(
cmds.as_slice(),
[Cmd::RenderPreview { page: 0, .. }]
));
let cmds = app.update(Msg::Resize(100, 40));
assert!(cmds.is_empty(), "resize alone renders nothing");
assert!(app.update(Msg::Tick).is_empty(), "first tick still waiting");
let cmds = app.update(Msg::Tick);
assert!(
matches!(cmds.as_slice(), [Cmd::RenderPreview { .. }]),
"debounced re-render after ~200 ms"
);
}
#[test]
fn resize_during_render_supersedes_with_new_generation() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('p')));
let first_generation = match cmds.as_slice() {
[Cmd::RenderPreview {
generation,
page: 0,
..
}] => *generation,
other => panic!("expected RenderPreview, got {:?}", other),
};
assert!(app.preview.rendering, "render is in flight");
app.update(Msg::Resize(100, 40));
assert!(
app.update(Msg::Tick).is_empty(),
"debounce still counting down"
);
let cmds = app.update(Msg::Tick);
let second_generation = match cmds.as_slice() {
[Cmd::RenderPreview {
generation,
page: 0,
..
}] => *generation,
other => panic!("expected superseding RenderPreview, got {:?}", other),
};
assert!(
second_generation > first_generation,
"the resize-triggered render must bump the generation past the in-flight one"
);
let cmds = app.update(Msg::PreviewReady {
generation: first_generation,
result: Ok(crate::preview::PreviewFrame {
file_bytes: std::sync::Arc::new(Vec::new()),
pixmap: pdfboss_render::Pixmap {
width: 1,
height: 1,
data: vec![0, 0, 0, 255],
},
notice: None,
}),
});
assert!(cmds.is_empty());
assert!(
app.preview.rendering,
"stale reply must not clear the in-flight flag for the superseding generation"
);
assert!(
app.preview.pixmap.is_none(),
"stale reply must not install its frame"
);
}
#[test]
fn preview_that_dropped_content_toasts_the_summary() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('p')));
let generation = match cmds.as_slice() {
[Cmd::RenderPreview {
generation,
page: 0,
..
}] => *generation,
other => panic!("expected RenderPreview, got {:?}", other),
};
app.update(Msg::PreviewReady {
generation,
result: Ok(crate::preview::PreviewFrame {
file_bytes: std::sync::Arc::new(Vec::new()),
pixmap: pdfboss_render::Pixmap {
width: 1,
height: 1,
data: vec![255, 255, 255, 255],
},
notice: Some("1 image skipped".to_string()),
}),
});
assert!(app.preview.pixmap.is_some(), "the frame still installs");
assert!(
app.status_line().contains("1 image skipped"),
"status line does not mention the drop: {}",
app.status_line()
);
}
#[test]
fn preview_budget_doubles_row_height_for_half_block_aspect() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('p')));
assert!(matches!(
cmds.as_slice(),
[Cmd::RenderPreview {
max_w: 50,
max_h: 24,
..
}]
));
}
#[test]
fn markdown_and_preview_are_mutually_exclusive() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('m')));
assert!(app.markdown.active);
assert!(matches!(
cmds.as_slice(),
[Cmd::ExtractMarkdown { page: 0, .. }]
));
let cmds = app.update(key(KeyCode::Char('p')));
assert!(app.preview.active);
assert!(!app.markdown.active, "preview replaces markdown");
assert!(matches!(
cmds.as_slice(),
[Cmd::RenderPreview { page: 0, .. }]
));
app.update(key(KeyCode::Char('m')));
assert!(app.markdown.active);
assert!(!app.preview.active, "markdown replaces preview");
app.update(key(KeyCode::Char('m')));
assert!(!app.markdown.active, "m toggles back off");
}
#[test]
fn stale_markdown_extraction_is_dropped() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('m')));
let stale = match cmds.as_slice() {
[Cmd::ExtractMarkdown { generation, .. }] => *generation,
other => panic!("expected ExtractMarkdown, got {:?}", other),
};
app.update(key(KeyCode::Char('m'))); let cmds = app.update(key(KeyCode::Char('m'))); let current = match cmds.as_slice() {
[Cmd::ExtractMarkdown { generation, .. }] => *generation,
other => panic!("expected ExtractMarkdown, got {:?}", other),
};
assert!(current > stale);
app.update(Msg::MarkdownReady {
generation: stale,
result: Ok("# stale".to_string()),
});
assert!(app.markdown.source.is_none(), "stale text is not installed");
assert!(
app.markdown.loading,
"the newer extraction is still awaited"
);
app.update(Msg::MarkdownReady {
generation: current,
result: Ok("# fresh".to_string()),
});
assert_eq!(app.markdown.source.as_deref(), Some("# fresh"));
}
#[test]
fn movement_scrolls_the_markdown_pane_while_it_is_active() {
let mut app = loaded_app();
let cmds = app.update(key(KeyCode::Char('m')));
let generation = match cmds.as_slice() {
[Cmd::ExtractMarkdown { generation, .. }] => *generation,
other => panic!("expected ExtractMarkdown, got {:?}", other),
};
app.update(Msg::MarkdownReady {
generation,
result: Ok((0..40)
.map(|n| format!("line {n}"))
.collect::<Vec<String>>()
.join("\n")),
});
app.update(key(KeyCode::Tab));
assert_eq!(app.focus, Pane::Inspector);
app.update(key(KeyCode::Char('j')));
assert_eq!(app.markdown.scroll, 1);
app.update(key(KeyCode::PageDown));
assert_eq!(app.markdown.scroll, 11);
app.update(key(KeyCode::Char('G')));
assert_eq!(app.markdown.scroll, 39, "last of the 40 lines");
app.update(key(KeyCode::Char('g')));
assert_eq!(app.markdown.scroll, 0);
assert!(
app.inspector.ref_cursor.is_none(),
"the hidden inspector must not have moved"
);
}
#[test]
fn y_opens_the_yank_menu_and_esc_closes_it_without_quitting() {
let mut app = loaded_app();
assert!(app.update(key(KeyCode::Char('y'))).is_empty());
assert!(app.yank_open);
assert!(
app.status_line().starts_with("yank"),
"menu hints replace the status line: {}",
app.status_line()
);
for hint in ["[q]", "[c]", "[x]", "[b]", "[e]", "[m]", "[o]", "[esc]"] {
assert!(
app.status_line().contains(hint),
"missing {hint} in {}",
app.status_line()
);
}
app.update(key(KeyCode::Esc));
assert!(!app.yank_open);
assert!(!app.should_quit, "Esc closed the menu, not the app");
}
#[test]
fn unmapped_key_cancels_the_yank_menu_and_does_not_leak_through() {
let mut app = loaded_app();
let selected = app.tree.selected;
app.update(key(KeyCode::Char('y')));
let cmds = app.update(key(KeyCode::Char('j')));
assert!(!app.yank_open);
assert!(cmds.is_empty());
assert_eq!(app.tree.selected, selected, "j cancelled instead of moving");
}
fn select_obj_1(app: &mut App) {
app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); }
fn yank(app: &mut App, target: char) -> Vec<Cmd> {
app.update(key(KeyCode::Char('y')));
app.update(key(KeyCode::Char(target)))
}
#[test]
fn yank_query_copies_the_selection_expression() {
let mut app = loaded_app();
select_obj_1(&mut app);
let cmds = yank(&mut app, 'q');
assert!(
matches!(
cmds.as_slice(),
[Cmd::Copy { text, what: "query" }] if text == ".objects[\"1 0\"]"
),
"got {cmds:?}"
);
assert!(!app.yank_open, "the menu closes after a yank");
}
#[test]
fn yank_command_quotes_the_target_and_query() {
let mut app = App::new(
"t.pdf".to_string(),
"my docs/o'clock.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements: physical_elements(),
errors: 0,
done: true,
});
select_obj_1(&mut app);
let cmds = yank(&mut app, 'c');
let expected = "pdfboss q 'my docs/o'\\''clock.pdf' '.objects[\"1 0\"]'";
assert!(
matches!(
cmds.as_slice(),
[Cmd::Copy { text, what: "command" }] if text == expected
),
"got {cmds:?}"
);
}
#[test]
fn yank_query_on_eof_toasts_instead_of_copying() {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
let mut elements = physical_elements();
elements.push(Element::Eof {
span: Span {
start: 300,
end: 306,
},
});
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements,
errors: 0,
done: true,
});
let eof_node = *app
.tree
.node(app.tree.xref_folder)
.children
.last()
.expect("xref children");
app.tree.reveal(eof_node);
app.tree.selected = eof_node;
let cmds = yank(&mut app, 'q');
assert!(cmds.is_empty());
assert!(
app.status_line().contains("no query"),
"toast explains: {}",
app.status_line()
);
}
#[test]
fn yank_obj_ref_copies_n_g_r_and_toasts_without_a_ref() {
let mut app = loaded_app();
select_obj_1(&mut app);
let cmds = yank(&mut app, 'o');
assert!(
matches!(
cmds.as_slice(),
[Cmd::Copy { text, what: "obj ref" }] if text == "1 0 R"
),
"got {cmds:?}"
);
app.update(key(KeyCode::Char('g'))); let cmds = yank(&mut app, 'o');
assert!(cmds.is_empty());
assert!(
app.status_line().contains("no object ref"),
"toast explains: {}",
app.status_line()
);
}
#[test]
fn yank_element_copies_the_pretty_object() {
let mut app = loaded_app();
select_obj_1(&mut app);
let mut dict = Dict::new();
dict.insert(
Name("Type".to_string()),
Object::Name(Name("Catalog".to_string())),
);
app.update(Msg::InspectorLoaded {
generation: app.inspector_generation,
payload: crate::inspector::InspectorPayload::Object {
r: obj_ref(1),
object: Object::Dict(dict),
},
});
let cmds = yank(&mut app, 'e');
assert!(
matches!(
cmds.as_slice(),
[Cmd::Copy { text, what: "element" }] if text.contains("/Type /Catalog")
),
"got {cmds:?}"
);
}
#[test]
fn yank_element_while_loading_toasts() {
let mut app = loaded_app();
select_obj_1(&mut app); let cmds = yank(&mut app, 'e');
assert!(cmds.is_empty());
assert!(
app.status_line().contains("loading"),
"toast explains: {}",
app.status_line()
);
}
#[test]
fn yank_markdown_extracts_the_selected_page() {
let mut app = loaded_app();
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Logical,
elements: vec![Element::Page {
index: 0,
r: obj_ref(1),
}],
errors: 0,
done: true,
});
app.update(key(KeyCode::Char('j'))); app.update(key(KeyCode::Char('l'))); app.update(key(KeyCode::Char('j'))); let cmds = yank(&mut app, 'm');
assert!(
matches!(cmds.as_slice(), [Cmd::YankMarkdown { page: 0 }]),
"got {cmds:?}"
);
}
#[test]
fn yank_markdown_without_a_page_ancestor_toasts() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('G'))); let cmds = yank(&mut app, 'm');
assert!(cmds.is_empty());
assert!(
app.status_line().contains("no page"),
"toast explains: {}",
app.status_line()
);
}
#[test]
fn alt_arrows_move_the_dividers() {
let mut app = loaded_app();
app.update(Msg::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)));
assert_eq!(app.splits.tree, ui::Splits::default().tree + ui::SPLIT_STEP);
app.update(Msg::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)));
assert_eq!(app.splits.tree, ui::Splits::default().tree);
}
#[test]
fn yank_hexdump_fetches_the_file_span() {
let mut app = loaded_app();
select_obj_1(&mut app);
let cmds = yank(&mut app, 'x');
assert!(
matches!(
cmds.as_slice(),
[Cmd::YankSpan {
source: HexSource::File { span },
slice: None,
base: 15,
format: YankFormat::Hexdump,
}] if span.start == 15 && span.end == 64
),
"got {cmds:?}"
);
}
#[test]
fn yank_bytes_of_an_objstm_member_slices_the_decoded_container() {
let mut app = loaded_app();
select_obj_1(&mut app);
app.update(key(KeyCode::Char('j'))); let cmds = yank(&mut app, 'b');
assert!(
matches!(
cmds.as_slice(),
[Cmd::YankSpan {
source: HexSource::DecodedObjStm { container },
slice: Some(member),
base: 4,
format: YankFormat::Bytes,
}] if container.num == 9 && member.start == 4 && member.end == 30
),
"got {cmds:?}"
);
}
#[test]
fn yank_hexdump_over_the_cap_copies_the_cli_command_instead() {
let mut app = App::new(
"t.pdf".to_string(),
"big file.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements: vec![Element::IndirectObject {
r: obj_ref(1),
object: Object::Null,
span: Span {
start: 0,
end: 2 * 1024 * 1024,
},
in_objstm: None,
}],
errors: 0,
done: true,
});
select_obj_1(&mut app);
let cmds = yank(&mut app, 'x');
let expected = "pdfboss hex 'big file.pdf' 'range:0-2097152'";
assert!(
matches!(
cmds.as_slice(),
[Cmd::Copy { text, what }] if text == expected && what.contains("hex command")
),
"got {cmds:?}"
);
}
#[test]
fn yank_hexdump_without_bytes_toasts() {
let mut app = loaded_app(); let cmds = yank(&mut app, 'x');
assert!(cmds.is_empty());
assert!(
app.status_line().contains("no bytes"),
"toast explains: {}",
app.status_line()
);
}
#[test]
fn yanked_message_toasts_success_and_failure() {
let mut app = loaded_app();
app.update(Msg::Yanked {
result: Ok("copied query (18 B)".to_string()),
});
assert_eq!(app.status_line(), "copied query (18 B)");
app.update(Msg::Yanked {
result: Err("no clipboard".to_string()),
});
assert_eq!(app.status_line(), "yank failed: no clipboard");
}
#[test]
fn toast_expires_after_ticks() {
let mut app = loaded_app();
app.toast("hello");
assert_eq!(app.status_line(), "hello");
for count in 0..30 {
let ignored_len = app.update(Msg::Tick).len();
assert_eq!(ignored_len, 0, "tick {count} spawned nothing");
}
assert!(app.status_line().starts_with("t.pdf \u{b7} /Document"));
}
#[test]
fn status_line_shows_search_then_breadcrumb() {
let mut app = loaded_app();
assert_eq!(
app.status_line(),
"t.pdf \u{b7} /Document \u{b7} [/] search [p] preview [m] markdown [q] quit",
"at 80 cols the yank hint would push [q] quit off the bar"
);
app.update(Msg::Resize(100, 24));
assert_eq!(
app.status_line(),
"t.pdf \u{b7} /Document \u{b7} [/] search [p] preview [m] markdown [y] yank [q] quit"
);
app.update(key(KeyCode::Char('/')));
app.update(key(KeyCode::Char('a')));
assert_eq!(app.status_line(), "/a \u{b7} 0 hits \u{2026}");
}
#[test]
fn trailer_node_shows_message_when_physical_pass_found_no_trailer() {
let mut app = App::new(
"t.pdf".to_string(),
"t.pdf".to_string(),
(1, 7),
1,
(80, 24),
);
let elements = vec![Element::IndirectObject {
r: obj_ref(1),
object: Object::Null,
span: Span { start: 15, end: 64 },
in_objstm: None,
}];
let cmds = app.update(Msg::TreeBatch {
req: crate::tree::TreeReq::Physical,
elements,
errors: 0,
done: true,
});
assert!(cmds.is_empty(), "root selection refresh needs no fetches");
assert_eq!(app.tree.physical, crate::tree::LoadState::Loaded);
assert!(app.tree.trailer_dict.is_none());
let cmds = app.update(key(KeyCode::Char('G'))); assert_eq!(app.tree.selected, app.tree.trailer_node);
assert!(
cmds.is_empty(),
"physical already loaded and still no trailer needs no fetch"
);
assert!(
!app.inspector.loading,
"must not dead-end on a perpetual loading state"
);
assert_eq!(app.inspector.title, "Trailer");
assert!(
app.inspector
.lines
.iter()
.any(|line| line.contains("no trailer")),
"expected a no-trailer message, got {:?}",
app.inspector.lines
);
}
#[test]
fn hex_scrolling_requests_missing_windows() {
let mut app = loaded_app();
app.update(key(KeyCode::Char('G')));
assert_eq!(app.tree.selected, app.tree.trailer_node);
app.update(Msg::HexLoaded {
generation: app.hex_generation,
window_start: 0,
total_len: 40,
bytes: vec![0u8; 40],
});
app.update(key(KeyCode::Tab));
app.update(key(KeyCode::Tab));
assert_eq!(app.focus, Pane::Hex);
app.update(key(KeyCode::Char('j')));
assert_eq!(app.hex.scroll_line, 1);
app.update(key(KeyCode::PageDown));
assert_eq!(app.hex.scroll_line, 4, "clamped to the 5-line span");
app.update(key(KeyCode::Char('g')));
assert_eq!(app.hex.scroll_line, 0);
}
}