mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M4.6: search bookmark titles without changing the outline (#424)
Refs #257. Show matching entries and their ancestors, keep original edit paths and preserve per-document view state. Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol)
This commit is contained in:
1 parent
3543ca8296
commit
c8c7959c0f
4 files changed
+162
-3
No files matched your search
@@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
|
||||
|
||||
## Navigate long documents
|
||||
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents.
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
@@ -486,6 +486,37 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let search_id = egui::Id::new(("bookmark_search", id.0));
|
||||
let mut bookmark_query = ui.data(|d| d.get_temp::<String>(search_id)).unwrap_or_default();
|
||||
if panel == RightPanel::Bookmarks && !info.outline.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Search"));
|
||||
let previous = bookmark_query.clone();
|
||||
let response =
|
||||
ui.add(egui::TextEdit::singleline(&mut bookmark_query).desired_width(ui.available_width() - 32.0)).labelled_by(label.id);
|
||||
response.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::text_edit(ui.is_enabled(), &previous, &bookmark_query, "");
|
||||
info.label = Some(tl!("Search").to_string());
|
||||
info
|
||||
});
|
||||
if icons::button(ui, "x", 26.0, false, tl!("Clear")).clicked() {
|
||||
bookmark_query.clear();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(search_id, bookmark_query.clone()));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let bookmark_query = bookmark_query.trim().to_lowercase();
|
||||
let mut bookmark_matches =
|
||||
if panel == RightPanel::Bookmarks { outline_matches(&info.outline, &bookmark_query) } else { std::collections::HashSet::new() };
|
||||
// A new bookmark must stay nameable even when its initial title doesn't match.
|
||||
if let Some((path, _)) = &bm_rename {
|
||||
let mut ancestor = path.clone();
|
||||
while !ancestor.is_empty() {
|
||||
bookmark_matches.insert(ancestor.clone());
|
||||
ancestor.pop();
|
||||
}
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match panel {
|
||||
RightPanel::Comments => {
|
||||
if let Some(e) = crate::comments_panel::show(ui, &t, info, view, prefs, comment_allowed, &mut nav) {
|
||||
@@ -503,7 +534,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
editable: bm_editable,
|
||||
current: view.current,
|
||||
expand: bm_expand,
|
||||
matches: &bookmark_matches,
|
||||
searching: !bookmark_query.is_empty(),
|
||||
};
|
||||
if ctx.searching && bookmark_matches.is_empty() && !info.outline.is_empty() {
|
||||
ui.label(tl!("No matches."));
|
||||
}
|
||||
for (i, item) in info.outline.iter().enumerate() {
|
||||
outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx);
|
||||
}
|
||||
@@ -673,6 +709,30 @@ pub enum BmAction {
|
||||
Outdent(Vec<usize>),
|
||||
}
|
||||
|
||||
/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes.
|
||||
fn outline_matches(items: &[OutlineItem], query: &str) -> std::collections::HashSet<Vec<usize>> {
|
||||
let mut matches = std::collections::HashSet::new();
|
||||
if query.is_empty() {
|
||||
return matches;
|
||||
}
|
||||
let mut pending: Vec<_> = items.iter().enumerate().map(|(i, item)| (vec![i], item)).collect();
|
||||
while let Some((path, item)) = pending.pop() {
|
||||
if item.title.to_lowercase().contains(query) {
|
||||
let mut ancestor = path.clone();
|
||||
while !ancestor.is_empty() {
|
||||
matches.insert(ancestor.clone());
|
||||
ancestor.pop();
|
||||
}
|
||||
}
|
||||
for (i, child) in item.children.iter().enumerate() {
|
||||
let mut child_path = path.clone();
|
||||
child_path.push(i);
|
||||
pending.push((child_path, child));
|
||||
}
|
||||
}
|
||||
matches
|
||||
}
|
||||
|
||||
struct OutlineCtx<'a> {
|
||||
nav: &'a mut Option<Nav>,
|
||||
action: &'a mut Option<BmAction>,
|
||||
@@ -681,6 +741,8 @@ struct OutlineCtx<'a> {
|
||||
current: usize,
|
||||
/// Expand all (`Some(usize::MAX)`), collapse all (`Some(0)`) or expand to a depth, this frame.
|
||||
expand: Option<usize>,
|
||||
matches: &'a std::collections::HashSet<Vec<usize>>,
|
||||
searching: bool,
|
||||
}
|
||||
|
||||
/// The destination page label gets its own right-aligned column in a bookmark row; drawing
|
||||
@@ -709,13 +771,16 @@ fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) {
|
||||
if cx.searching && !cx.matches.contains(path) {
|
||||
return;
|
||||
}
|
||||
let depth = path.len() - 1;
|
||||
let indent = depth as f32 * 16.0;
|
||||
let id = ui.id().with(("outline", path));
|
||||
if let Some(levels) = cx.expand {
|
||||
ui.data_mut(|d| d.insert_temp(id, depth < levels));
|
||||
}
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(item.open || depth == 0);
|
||||
let mut open = cx.searching || ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(item.open || depth == 0);
|
||||
let x_text = indent + 20.0;
|
||||
if let Some((rpath, text)) = cx.rename.as_mut()
|
||||
&& rpath.as_slice() == path
|
||||
@@ -770,7 +835,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
if !item.children.is_empty() {
|
||||
let tri = Rect::from_min_size(pos2(x0, rect.top() + 6.0), vec2(16.0, 16.0));
|
||||
icons::paint(ui, tri, if open { "chevron-down" } else { "chevron-right" }, 14.0, t.text_muted);
|
||||
if ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
|
||||
if !cx.searching && ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(id, open));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,71 @@ fn number_pages_dialog_labels_the_selected_pages() {
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Number pages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_collapsed_bookmarks_and_keeps_original_paths() {
|
||||
use pdfcraft_engine::Edit;
|
||||
let mut h = harness();
|
||||
for (parent, index, title, page) in
|
||||
[(vec![], 0, "Unrelated", 0), (vec![], 1, "Reports", 0), (vec![1], 0, "Other chapter", 1), (vec![1], 1, "Résumé results", 2)]
|
||||
{
|
||||
h.state_mut().apply_edit(Edit::AddBookmark { parent, index, title: title.into(), page });
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Bookmark options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Collapse all bookmarks").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Résumé results").is_none());
|
||||
let id = h.state().views[0].id;
|
||||
let before = h.state().session.save_full_bytes(id).unwrap();
|
||||
h.get_by_label("Search").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Search").type_text("RÉSUMÉ");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Reports");
|
||||
h.get_by_label("Résumé results").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].current, 2);
|
||||
assert!(h.query_by_label("Unrelated").is_none());
|
||||
assert!(h.query_by_label("Other chapter").is_none());
|
||||
assert_eq!(h.state().session.save_full_bytes(id).unwrap(), before, "search doesn't edit the PDF");
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
|
||||
h.render().unwrap().save(std::path::Path::new(&dir).join("filtered-bookmarks.png")).unwrap();
|
||||
}
|
||||
menu(&mut h, "Résumé results", "Delete");
|
||||
assert_eq!(outline(&h).len(), 3);
|
||||
assert!(outline(&h).iter().any(|b| b.0 == "Other chapter"));
|
||||
h.get_by_label("No matches.");
|
||||
add(&mut h, "New section");
|
||||
assert!(outline(&h).iter().any(|b| b.0 == "New section"), "new bookmarks remain nameable while searching");
|
||||
h.get_by_label("No matches.");
|
||||
h.get_by_label("Clear").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Unrelated");
|
||||
assert!(h.query_by_label("Other chapter").is_none(), "search did not change saved expansion state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_search_is_per_document() {
|
||||
let mut h = harness();
|
||||
add(&mut h, "First document");
|
||||
h.get_by_label("Search").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("Search").type_text("not found");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("No matches.");
|
||||
h.state_mut().open_bytes("second.pdf", None, PAGES.to_vec()).unwrap();
|
||||
h.run_steps(3);
|
||||
add(&mut h, "Second document");
|
||||
h.get_by_label("Second document");
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("No matches.");
|
||||
h.get_by_label("Clear").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("First document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_and_collapsing_all_bookmarks() {
|
||||
use pdfcraft_engine::Edit;
|
||||
|
||||
@@ -69,6 +69,35 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa
|
||||
call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_titles_can_be_searched_over_control() {
|
||||
let (mut h, c) = harness();
|
||||
for (index, title, page) in [(0, "Background", 0), (1, "Target chapter", 3)] {
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index, title: title.into(), page });
|
||||
}
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "bookmarks" }));
|
||||
h.run_steps(3);
|
||||
let widgets = ok(&mut h, &c, "ui.inspect", json!({ "query": "Search", "role": "TextInput" }));
|
||||
let rect = &widgets["widgets"][0]["rect"];
|
||||
let x = (rect[0].as_f64().unwrap() + rect[2].as_f64().unwrap()) / 2.0;
|
||||
let y = (rect[1].as_f64().unwrap() + rect[3].as_f64().unwrap()) / 2.0;
|
||||
ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }));
|
||||
ok(&mut h, &c, "ui.type", json!({ "text": "target" }));
|
||||
h.get_by_label("Target chapter");
|
||||
assert!(h.query_by_label("Background").is_none());
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "Target chapter" }));
|
||||
assert_eq!(h.state().views[0].current, 3);
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/control-filtered-bookmarks.png")).unwrap();
|
||||
}
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "Clear" }));
|
||||
h.get_by_label("Background");
|
||||
h.get_by_label("Target chapter");
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_BOOKMARK_SHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/control-cleared-bookmarks.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_commands_and_options_report_preference_and_effective_colours() {
|
||||
let (mut h, c) = harness();
|
||||
|
||||
Reference in new issue
Block a user