diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8743d60..41db656 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -99,9 +99,15 @@ jobs: # for before it will run. # libcap2-bin provides capsh, which the Test step uses to drop the two # DAC capabilities so root obeys permission bits. + # zsync and appstream are for build-appimage.sh: appimagetool shells + # out to zsyncmake rather than bundling it, and reports success while + # writing nothing when it is absent, so the script checks for it up + # front. appstream provides the appstreamcli that validates the + # metainfo both packages ship. apt-get install -y --no-install-recommends \ build-essential perl pkg-config \ - binutils dpkg-dev desktop-file-utils gzip libcap2-bin + binutils dpkg-dev desktop-file-utils gzip libcap2-bin \ + zsync appstream - name: Trust the workspace # checkout writes as root into a directory git then considers dubiously @@ -187,6 +193,19 @@ jobs: fi echo "OK: glibc floor $floor <= $MAX_GLIBC" + - name: Build the AppImage + # After the glibc gate, so the cheaper check still fails first. --no-build + # reuses the binaries from the Build step, as the .deb step does, and + # SOURCE_DATE_EPOCH pins the date substituted into the AppStream release + # entry the same way it pins the .deb changelog. + # + # The script downloads appimagetool and the AppImage runtime, both pinned + # by sha256, and needs no FUSE: it runs appimagetool with + # APPIMAGE_EXTRACT_AND_RUN so the container needs no /dev/fuse. + run: | + SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" \ + ./packaging/build-appimage.sh --no-build + - name: Package the binaries # A tarball for anyone not installing the .deb, stripped to match what # build-deb.sh ships. @@ -214,9 +233,15 @@ jobs: - uses: actions/upload-artifact@v3-node20 with: name: linux-x86_64 + # The .zsync is not optional: the update URL baked into every AppImage + # points at it, so leaving it unpublished breaks AppImageUpdate for + # everyone who already installed one. It is the one asset named without + # a version, because that URL has to keep resolving across releases. path: | dist/*.deb dist/*.tar.gz + dist/*.AppImage + dist/*.zsync if-no-files-found: error retention-days: 14 diff --git a/Cargo.lock b/Cargo.lock index 2e37a4e..bb39a19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "quicksearch-core" -version = "1.0.3" +version = "1.0.5" dependencies = [ "argon2", "cfb", @@ -3129,7 +3129,7 @@ dependencies = [ [[package]] name = "quicksearch-gui" -version = "1.0.3" +version = "1.0.5" dependencies = [ "chrono", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 4016e4f..4e4c7d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "1.0.4" +version = "1.0.5" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/README.md b/README.md index fb7085f..b347a35 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ sidecar), keeps it fresh automatically with filesystem watchers and periodic reindexing, and serves ranked search-as-you-type results in a compact egui desktop app, or straight to your terminal. +## AI Disclaimer +QuickSearch has a core designed by it's developer and built by hand, however the majority of it's codebase including it's GUI was designed by a human and built using AI agents with human review, improvements, and testing. + +## GitHub Mirror +The primary home of this software is: +https://code.karsttech.com/jeremy/quick_search + +The code is also mirrored to GitHub for easier bug reporting and issue tracking: +https://github.com/DataScienceDIY/quick_search + ## Build & run ```sh @@ -85,6 +95,7 @@ The package installs: | `/usr/share/applications/quicksearch.desktop` | menu entry, so QuickSearch appears in the app launcher | | `/usr/share/icons/hicolor/{16,22,24,32,48,64,128,256}x*/apps/` | icons at each size | | `/usr/share/icons/hicolor/scalable/apps/quicksearch.svg` | the source icon | +| `/usr/share/metainfo/com.karsttech.quicksearch.metainfo.xml` | AppStream data, so software centres show a real listing | | `/usr/share/man/man1/quicksearch{,-cli}.1.gz` | `man quicksearch`; the `-cli` page is a `.so` stub pointing at it | | `/usr/share/doc/quicksearch/` | copyright, changelog, README, `config_example.toml` | @@ -118,6 +129,32 @@ largest first, and `save(..., format="ICO", sizes=[...], append_images=rest)` — passing the images rather than one image and a size list is what keeps the committed pixels instead of resampling them. +## Install (AppImage) + +For anything that is not Debian or Ubuntu. Download +`quicksearch--x86_64.AppImage` from the release page, make it +executable and run it: + +```sh +chmod +x quicksearch-1.0.4-x86_64.AppImage +./quicksearch-1.0.4-x86_64.AppImage +``` + +If it fails to start with a FUSE error — some distributions no longer install +FUSE by default — either install the distribution's FUSE package or run it +unpacked: + +```sh +APPIMAGE_EXTRACT_AND_RUN=1 ./quicksearch-1.0.4-x86_64.AppImage +``` + +To build one, `./packaging/build-appimage.sh` takes the same flags as +`build-deb.sh` (`--no-build`, `--no-strip`, `-o DIR`). It downloads +`appimagetool` and the AppImage runtime, both pinned by sha256 and cached under +`~/.cache/quicksearch`, and needs `zsync` and `appstream` installed for +`zsyncmake` and `appstreamcli`. `APPIMAGETOOL` points it at a copy you already +have. It needs no FUSE itself, which is what lets CI build one in a container. + ## Install (Windows) Download `quicksearch--windows-x86_64-setup.exe` from the release @@ -263,12 +300,9 @@ rebuilds the index — there is no in-place conversion. - Forgot the password? The unlock screen can delete the index and disable protection; your files are untouched and re-indexing rebuilds it. -What this protects: the index file at rest — disk theft, backups, other -accounts reading the file. What it does not protect: a compromised running -session (the derived key is in process memory while the app runs), and the -files themselves, which are exactly as readable as before. A wrong -password can never wipe the index; it is refused without touching the -file. +This protects the index itself and for attacks like data theft. +Anything malicious running with user permissions could bypass this protection, +but anything with user permissions can also access all of the same files. ### Query syntax @@ -574,8 +608,15 @@ pagination: the table is virtualized, so a single scroll list capped at crates, so the `--locked` build after it still fails on a dependency added or bumped without committing `Cargo.lock`. Once both build jobs are green, CI tags that commit `v` and publishes a release - with the `.deb`, a Linux tarball, the Windows installer and a Windows zip - attached; pushing a `v*` tag by hand does the same thing. The version is never taken from the branch + with the `.deb`, an AppImage and its `.zsync` sidecar, a Linux tarball, the + Windows installer and a Windows zip attached; pushing a `v*` tag by hand does + the same thing. The sidecar is the one asset named without a version, because + every released AppImage embeds its URL and that URL has to keep resolving as + releases come and go — Forgejo resolves the literal tag `latest` to the newest + release and looks an asset up by name, so it is always at + `.../releases/download/latest/quicksearch-x86_64.AppImage.zsync`. Note that is + `/releases/download/latest/`, not the GitHub-style `/releases/latest/download/`, + which Forgejo does not implement. The version is never taken from the branch name, and a tag that already exists at a different commit aborts the release rather than shipping two builds under one version. Every build carries its identity: `crates/quicksearch-gui/build.rs` bakes in the commit CI passes as @@ -585,7 +626,10 @@ pagination: the table is virtualized, so a single scroll list capped at `unknown` there rather than failing. The Linux job runs in an Ubuntu 22.04 container on purpose — `packaging/build-deb.sh` reads the package's `libc6` floor from the binary it just built, so the builder's glibc becomes - the package's minimum, and 22.04 pins it at 2.35. The Windows job + the package's minimum, and 22.04 pins it at 2.35. The AppImage is cut from + that same binary and bundles no libraries, so 2.35 is its floor too — it is + the one number that decides how far either Linux artifact reaches. + The Windows job cross-compiles with mingw-w64 and fails if either `.exe` picks up a dependency on a non-system DLL, then builds both Windows assets from those binaries — `packaging/build-installer.sh` runs `makensis`, which is a Linux diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index ff1bffa..ab4c7d7 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -730,9 +730,13 @@ fn status_contents(ui: &mut egui::Ui, state: &IndexerState, speed: &SpeedTracker } if let Some(rate) = speed.files_per_sec() { ui.label( - egui::RichText::new(format!("overall: {}", fmt_rate(rate))) - .small() - .weak(), + egui::RichText::new(format!( + "last {}s: {}", + crate::tracker::WINDOW.as_secs(), + fmt_rate(rate) + )) + .small() + .weak(), ); } } diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index 9a7be3a..bc5caae 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -17,6 +17,21 @@ use crate::ui_util::middle_elide; /// showing the spinner, a time or nothing, so the query box stays put. const STATUS_SLOT_WIDTH: f32 = 52.0; +/// How long the old results take to clear: a plain dip to nothing, with no +/// wipe. Very short on purpose — the swap waits for it, so it is latency in +/// front of every new result set, and there is nothing to look at on the +/// way out anyway. +const FADE_OUT_SECS: f32 = 0.15; +/// How long the new results take to wipe in. It can afford to be five times +/// as long, because the reveal starts at the top of the table — the first +/// hits are readable within a couple of frames either way. +const FADE_IN_SECS: f32 = 0.50; +/// The fraction of the reveal over which the section-wide opacity climbs to +/// full. The rest is carried by the wipe alone, so rows the edge has +/// already uncovered sit at full strength instead of dimming along with the +/// ones still to come. +const FADE_ALPHA_SPAN: f32 = 0.50; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortKey { Rank, @@ -93,6 +108,21 @@ pub struct SearchTab { staging_has_snippets: bool, /// True from search start until the staged set has been swapped in. swap_pending: bool, + /// How much of the results section the reveal still hides: 0 fully on + /// screen, 1 fully covered. Set to 1 at the swap and travels back down + /// to 0 as the new results wipe in — and *only* then. Clearing the old + /// set holds it still, so an interrupted reveal does not flash the rows + /// it had already covered back on screen on the way out. + wipe: f32, + /// The section's own opacity, which is all there is to clearing the old + /// results. Follows `wipe` while the new ones arrive and decays on its + /// own while the old ones go, so it is continuous across the turn. + /// + /// Both are stepped by frame time rather than handed to egui's animation + /// manager, which reads the value of a transition it is already running + /// against whatever duration the current call passes — and the two + /// directions here have very different ones. + fade: f32, /// Display permutation over `results`. order: Vec, sort: (SortKey, bool), @@ -134,6 +164,8 @@ impl SearchTab { staging: Vec::new(), staging_has_snippets: false, swap_pending: false, + wipe: 0.0, + fade: 1.0, order: Vec::new(), sort: (SortKey::Rank, true), sort_dirty: false, @@ -162,11 +194,14 @@ impl SearchTab { self.pending_edit = Some(Instant::now()); } - /// The query has executed and the fade/stage swap has landed — what the - /// capture driver's `wait_search_done` means by "done". + /// The query has executed, the stage swap has landed and the table has + /// wiped all the way back in — what the capture driver's + /// `wait_search_done` means by "done". The reveal is part of it because + /// it outlasts the swap by half a second, and a screenshot taken during + /// it catches a half-drawn table. #[cfg(feature = "capture")] pub(crate) fn capture_settled(&self) -> bool { - !self.running && self.pending_edit.is_none() && !self.swap_pending + !self.running && self.pending_edit.is_none() && self.fade_settled() } /// Re-arm the one-shot first-frame focus: tab switches drop egui focus, @@ -198,6 +233,34 @@ impl SearchTab { self.error = None; } + /// Nothing left to animate: the section is fully on screen and no result + /// swap is waiting on it. + fn fade_settled(&self) -> bool { + !self.swap_pending && self.wipe <= 0.0 && self.fade >= 1.0 + } + + /// Move the transition on by `dt` seconds. + /// + /// The two halves are not mirror images. Clearing the old results is a + /// plain fade — the reveal holds where it stands, because a search fired + /// part way through the previous one's wipe would otherwise un-cover the + /// rows it had just covered, flashing them back on screen on the way + /// out. The opacity carries straight on from whatever the reveal had it + /// at, so the turn is continuous either way. + /// + /// Steps are clamped rather than merely added: a stalled frame must not + /// overshoot into a value the swap test or the scrim would have to guard + /// against. + fn advance_fade(&mut self, dt: f32) { + let dt = dt.max(0.0); + if self.swap_pending { + self.fade = (self.fade - dt / FADE_OUT_SECS).max(0.0); + } else { + self.wipe = (self.wipe - dt / FADE_IN_SECS).max(0.0); + self.fade = ((1.0 - self.wipe) / FADE_ALPHA_SPAN).min(1.0); + } + } + pub fn apply_update(&mut self, update: SearchUpdate, display_limit: usize) { if update.generation() != self.generation { return; @@ -458,32 +521,40 @@ impl SearchTab { ui.label(egui::RichText::new("No results.").small().weak()); } - // Result-set transitions pulse instead of strobing: the old table - // fades out over 0.15 s while the new hits stage, the sets swap at - // zero opacity, and the new table fades back in over 0.15 s. - // `animate_value_with_time` keeps requesting repaints until the - // value settles. - let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; - let fade = - ui.ctx() - .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.15); - if self.swap_pending && fade <= 0.01 { + // Result-set transitions arrive instead of strobing: the old table + // dips out over `FADE_OUT_SECS` while the new hits stage, the sets + // swap once nothing is left on screen, and the new table is then + // uncovered from the top down over `FADE_IN_SECS`. Repaints have to + // be asked for by hand, since the values are ours rather than the + // animation manager's — and the swap frame needs one too, having + // just finished the fade-out without yet starting the reveal. + self.advance_fade(ui.input(|i| i.stable_dt)); + if self.swap_pending && self.fade <= 0.0 { self.results = std::mem::take(&mut self.staging); self.has_snippets = self.staging_has_snippets; self.selected = None; self.swap_pending = false; + // The new set starts fully covered, and the reveal walks it back + // down from there. + self.wipe = 1.0; // Staged hits arrived in scan order too, so the table has to be // ordered here as well — including under the default key. self.sort_dirty = true; } + if !self.fade_settled() { + ui.ctx().request_repaint(); + } if self.sort_dirty { self.resort(); } - // Fade covers the table and the preview strip below it; the modal - // windows and notices render at full opacity on their own layers. - ui.set_opacity(fade); + // The section-wide half of the effect: the whole of the fade-out, + // and a short climb at the start of the reveal so its leading edge + // does not have to carry that alone. Rows the edge has passed stay + // at full strength. The modal windows and the notices above render + // at full opacity on their own layers either way. + ui.set_opacity(self.fade); // --- Results table ------------------------------------------------ // Reserve room for the selected-row snippet preview strip. Only @@ -510,6 +581,10 @@ impl SearchTab { #[cfg(feature = "capture")] let mut capture_match_rects: Vec = Vec::new(); + // Where the wiped section begins. Its end is only known once the + // preview strip below has been laid out. + let section_top = ui.cursor().top(); + let table_scroll = ui .push_id("results", |ui| { let mut table = TableBuilder::new(ui) @@ -742,6 +817,16 @@ impl SearchTab { ui.label(job); } + // The reveal uncovers everything from the column headers to the + // bottom of the preview strip together — table, scroll bar, "more + // below" hint and all. Painted last so it covers them, and outside + // the opacity set above so the scrim itself is not faded by it. + let section = egui::Rect::from_x_y_ranges( + ui.max_rect().x_range(), + section_top..=ui.min_rect().bottom(), + ); + crate::ui_util::wipe_scrim(ui, section, self.wipe); + self.ignore_dialog_ui(ui.ctx(), &mut actions); self.help_window_ui(ui.ctx()); actions @@ -1620,8 +1705,9 @@ mod tests { ); } - /// Batches arriving during the fade get the same treatment; the ordering - /// problem must not simply move inside the 250 ms window. + /// Batches arriving while the old table wipes away get the same + /// treatment; the ordering problem must not simply move inside + /// `FADE_OUT_SECS`. #[test] fn staged_batches_are_ordered_once_the_fade_swaps() { let mut tab = SearchTab::new(false); @@ -1649,6 +1735,308 @@ mod tests { assert_eq!(displayed(&tab), vec!["best.txt", "worst.txt"]); } + /// Step the transition at a steady 60 fps until `done`, and report how + /// long it took. + fn run_fade(tab: &mut SearchTab, done: impl Fn(&SearchTab) -> bool) -> f32 { + let dt = 1.0 / 60.0; + for frame in 0..1000 { + if done(tab) { + return frame as f32 * dt; + } + tab.advance_fade(dt); + } + panic!("the transition never finished"); + } + + #[test] + fn each_half_of_the_transition_takes_its_own_duration() { + let mut tab = SearchTab::new(false); + tab.swap_pending = true; + let out = run_fade(&mut tab, |t| t.fade <= 0.0); + assert_eq!(tab.fade, 0.0, "settles exactly on invisible"); + assert!( + (out - FADE_OUT_SECS).abs() <= 1.0 / 60.0, + "clearing runs for FADE_OUT_SECS, took {out}" + ); + + // What `ui` does at the swap. + tab.swap_pending = false; + tab.wipe = 1.0; + let into = run_fade(&mut tab, |t| t.wipe <= 0.0); + assert_eq!(tab.fade, 1.0, "and the reveal settles on fully opaque"); + assert!( + (into - FADE_IN_SECS).abs() <= 1.0 / 60.0, + "the reveal runs for FADE_IN_SECS, took {into}" + ); + } + + #[test] + fn a_stalled_frame_does_not_overshoot() { + let mut tab = SearchTab::new(false); + tab.swap_pending = true; + tab.advance_fade(10.0); + assert_eq!(tab.fade, 0.0, "a whole ten seconds lands, not passes"); + + tab.swap_pending = false; + tab.wipe = 1.0; + tab.advance_fade(10.0); + assert_eq!(tab.wipe, 0.0); + assert_eq!(tab.fade, 1.0); + } + + /// Clearing the old results is a plain fade. Nothing about the reveal + /// runs backwards — a search fired part way through the previous one's + /// wipe would otherwise uncover the rows it had just covered, flashing + /// them back on screen on the very frame they were told to leave. + #[test] + fn clearing_results_holds_the_reveal_where_it_stands() { + let mut tab = SearchTab::new(false); + tab.wipe = 1.0; + tab.advance_fade(FADE_IN_SECS / 5.0); + let standing = tab.wipe; + assert!((standing - 0.8).abs() < 1e-4, "one fifth in: {standing}"); + let carried = tab.fade; + + tab.swap_pending = true; + tab.advance_fade(0.0); + assert_eq!(tab.wipe, standing, "the reveal is frozen, not rewound"); + assert_eq!(tab.fade, carried, "and the opacity carries on from here"); + + // The scrim holds its position for the whole of the fade-out, and + // the opacity takes the full FADE_OUT_SECS to get from here to zero. + let out = run_fade(&mut tab, |t| t.fade <= 0.0); + assert_eq!(tab.wipe, standing, "still frozen at the end of it"); + let expected = carried * FADE_OUT_SECS; + assert!( + (out - expected).abs() <= 1.0 / 60.0, + "a partly faded section clears proportionally: {out} vs {expected}" + ); + } + + #[test] + fn a_settled_section_stops_asking_for_frames() { + let mut tab = SearchTab::new(false); + // Nothing pending, nothing covered, nothing dimmed: the steady state + // must not repaint forever. + assert!(tab.fade_settled()); + tab.advance_fade(1.0 / 60.0); + assert!(tab.fade_settled()); + + // Whereas each stage of a transition keeps the frames coming, the + // swap frame — cleared out but not yet revealing — included. + tab.swap_pending = true; + assert!(!tab.fade_settled()); + tab.advance_fade(FADE_OUT_SECS); + tab.swap_pending = false; + tab.wipe = 1.0; + assert!(!tab.fade_settled()); + } + + /// Drive frames until the reveal has uncovered all but `to` of the + /// section, and hand back the frame it got there on. `run_frame` leaves + /// `RawInput::time` unset, so egui advances its own clock a predicted + /// frame at a time and the tab sees a steady `stable_dt`. + fn reveal_to(ctx: &egui::Context, tab: &mut SearchTab, to: f32) -> egui::FullOutput { + for _ in 0..200 { + let out = run_frame(ctx, tab, vec![]); + if tab.wipe <= to { + return out; + } + } + panic!("the reveal never got down to {to}"); + } + + /// Drive frames until the staged results swap in, and hand back the + /// frame it happened on — the one where the new set is fully covered + /// and the reveal is about to start. + fn swap_in(ctx: &egui::Context, tab: &mut SearchTab) -> egui::FullOutput { + for _ in 0..200 { + let out = run_frame(ctx, tab, vec![]); + if !tab.swap_pending { + return out; + } + } + panic!("the staged results never swapped in"); + } + + /// Twenty staged hits under whatever generation is in flight. + fn stage_results(tab: &mut SearchTab) { + batch( + tab, + (0..20) + .map(|i| hit(i, &format!("alpha_widget_{i}.txt"), 3.0, 116)) + .collect(), + ); + } + + /// Where the first and last result rows were painted. + fn row_bounds(out: &egui::FullOutput) -> (egui::Rect, egui::Rect) { + let rows: Vec = crate::test_ui::painted(out) + .into_iter() + .filter(|(text, _)| text.starts_with("alpha_widget_")) + .map(|(_, rect)| rect) + .collect(); + ( + *rows.first().expect("rows painted"), + *rows.last().expect("rows painted"), + ) + } + + /// Vertices down the scrim as (y, alpha), in paint order. + fn scrim(out: &egui::FullOutput) -> Vec<(f32, u8)> { + let meshes = crate::test_ui::painted_meshes(out); + assert_eq!(meshes.len(), 1, "one scrim over the section, no more"); + meshes[0] + .vertices + .iter() + .map(|v| (v.pos.y, v.color.a())) + .collect() + } + + /// The y where the scrim first turns fully solid — the edge of what is + /// still hidden. + fn solid_from(ramp: &[(f32, u8)]) -> f32 { + ramp.iter() + .find(|&&(_, a)| a == 255) + .expect("a solid stretch") + .0 + } + + /// Clearing the old results paints no scrim at all: it is a plain dip + /// to nothing, so there is no edge travelling anywhere on the way out. + #[test] + fn clearing_results_paints_no_scrim() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(20); + + let settled = run_frame(&ctx, &mut tab, vec![]); + assert!( + crate::test_ui::painted_meshes(&settled).is_empty(), + "a settled table pays nothing for the effect" + ); + + tab.on_search_started(1); + stage_results(&mut tab); + let mut dimmest: f32 = 1.0; + for frame in 0..200 { + let out = run_frame(&ctx, &mut tab, vec![]); + if !tab.swap_pending { + // The swap frame belongs to the new set, which starts + // covered — checked separately below. + assert!(frame > 0, "the fade-out was over before it began"); + break; + } + assert!( + crate::test_ui::painted_meshes(&out).is_empty(), + "no scrim while the old results clear, at fade {}", + tab.fade + ); + dimmest = dimmest.min(tab.fade); + } + assert!( + dimmest < 0.35, + "the section really does dim on the way out: got no lower than {dimmest}" + ); + } + + /// The new set arrives fully covered, headers included. The scrim + /// carries its own alpha — painting it through the `Ui` would have + /// scaled it by the section-wide opacity, which is exactly zero on this + /// frame, leaving the unsorted new table on screen at full strength. + #[test] + fn new_results_start_completely_covered() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(20); + run_frame(&ctx, &mut tab, vec![]); + + tab.on_search_started(1); + stage_results(&mut tab); + let swapped = swap_in(&ctx, &mut tab); + assert_eq!(tab.wipe, 1.0, "the reveal starts from the top"); + + let ramp = scrim(&swapped); + assert!( + ramp.iter().all(|&(_, a)| a == 255), + "nothing shows through: {ramp:?}" + ); + assert!( + !crate::test_ui::painted_text(&swapped) + .iter() + .any(|t| t.starts_with("alpha_widget_")), + "at zero opacity egui drops the section's shapes outright, so \ + the scrim is belt to that braces" + ); + + // Which is also why the rows have to be measured from a frame the + // reveal has let some light through. + let (first, last) = row_bounds(&reveal_to(&ctx, &mut tab, 0.8)); + let (top, bottom) = ( + ramp.first().expect("vertices").0, + ramp.last().expect("vertices").0, + ); + assert!( + top < first.top(), + "the scrim starts above the first row, so the column headers \ + are covered too: {top} vs {}", + first.top() + ); + assert!( + bottom >= last.bottom(), + "and runs past the last one: {bottom} vs {}", + last.bottom() + ); + } + + /// The reveal uncovers the table from the top down, and gets to the + /// first rows early — which is the whole reason it can afford to run + /// for half a second. + #[test] + fn new_results_are_uncovered_from_the_top_down() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(20); + run_frame(&ctx, &mut tab, vec![]); + + tab.on_search_started(1); + stage_results(&mut tab); + swap_in(&ctx, &mut tab); + + // A fifth of the way in: the head of the table is out from behind + // the scrim while the foot is still under it. + let early_frame = reveal_to(&ctx, &mut tab, 0.8); + let (first, last) = row_bounds(&early_frame); + let early = solid_from(&scrim(&early_frame)); + assert!( + early > first.bottom(), + "the first row is readable a fifth of the way in: {early} vs {}", + first.bottom() + ); + assert!( + early < last.top(), + "while the last is still covered: {early} vs {}", + last.top() + ); + + // …and the edge keeps going down, not back up. + let later = solid_from(&scrim(&reveal_to(&ctx, &mut tab, 0.4))); + assert!( + later > early, + "the edge travels downward: {early} then {later}" + ); + assert!( + later > last.top(), + "and has uncovered the last row by then: {later} vs {}", + last.top() + ); + + // It ends with the scrim gone entirely rather than lingering. + let done = reveal_to(&ctx, &mut tab, 0.0); + assert!( + crate::test_ui::painted_meshes(&done).is_empty(), + "the scrim clears away at the end of the reveal" + ); + assert!(tab.fade_settled(), "and the section stops animating"); + } + /// A selected row is identified by file id, so it survives both the /// re-ordering and the eviction that a new batch can cause. #[test] diff --git a/crates/quicksearch-gui/src/test_ui.rs b/crates/quicksearch-gui/src/test_ui.rs index 7f1feef..f4c5154 100644 --- a/crates/quicksearch-gui/src/test_ui.rs +++ b/crates/quicksearch-gui/src/test_ui.rs @@ -114,6 +114,31 @@ pub fn painted_rows(out: &egui::FullOutput) -> Vec { .collect() } +/// Every mesh painted this frame, in paint order. +/// +/// The app paints text and rectangles; a mesh means a shape assembled +/// vertex by vertex, which is the only way to get a gradient out of egui. +/// Reading the vertices back is the only way to check one, since the colour +/// that matters varies across the shape rather than being a property of it. +pub fn painted_meshes(out: &egui::FullOutput) -> Vec<&egui::Mesh> { + fn walk<'a>(shape: &'a egui::epaint::Shape, into: &mut Vec<&'a egui::Mesh>) { + match shape { + egui::epaint::Shape::Mesh(mesh) => into.push(mesh), + egui::epaint::Shape::Vec(shapes) => { + for s in shapes { + walk(s, into); + } + } + _ => {} + } + } + let mut meshes = Vec::new(); + for clipped in &out.shapes { + walk(&clipped.shape, &mut meshes); + } + meshes +} + /// The centre of `needle`'s galley, as a click target. /// /// The *last* match wins, so a string painted both behind a modal and on it diff --git a/crates/quicksearch-gui/src/tracker.rs b/crates/quicksearch-gui/src/tracker.rs index 0a97be1..c6adf43 100644 --- a/crates/quicksearch-gui/src/tracker.rs +++ b/crates/quicksearch-gui/src/tracker.rs @@ -1,17 +1,22 @@ //! Indexing-rate estimation for the status displays. //! -//! The old tracker sampled the counter every poll tick but pruned to a -//! 1-second window, so anything slower than ~1 file/sec measured a -//! genuine zero and displayed "0.0 files/sec" despite progress. This one -//! records a point only when the counter *changes*, keeps up to 60 s of -//! history but never fewer than two points (so slow rates stay -//! computable), and measures against `now` so the estimate decays during -//! stalls instead of freezing at the last burst. +//! The rate shown is a rolling [`WINDOW`] average, not a run average: what +//! matters while watching a run is what it is doing *now*, and a run that +//! spent its first minute on a fast SSD root drags a whole-run mean far +//! above the rate the slow root it is on is actually achieving. +//! +//! The tracker records a point only when the counter *changes*, prunes +//! points older than the window but never below two (so a rate slower than +//! one file per window stays computable rather than measuring a genuine +//! zero), and measures against `now` so the estimate decays during stalls +//! instead of freezing at the last burst. use std::collections::VecDeque; use std::time::{Duration, Instant}; -const HISTORY: Duration = Duration::from_secs(60); +/// The averaging window. Public so the display can name it and stay in +/// sync with it. +pub const WINDOW: Duration = Duration::from_secs(30); pub struct SpeedTracker { /// (when, counter value) — appended only on counter change. A deque @@ -44,15 +49,20 @@ impl SpeedTracker { _ => {} } self.points.push_back((now, files_processed)); - // Prune old points, but always keep at least two so a slow but - // steady rate never becomes unmeasurable. - while self.points.len() > 2 && now.duration_since(self.points[0].0) > HISTORY { + // Prune points that have fallen out of the window, but always keep + // at least two so a slow but steady rate never becomes unmeasurable. + while self.points.len() > 2 && now.duration_since(self.points[0].0) > WINDOW { self.points.pop_front(); } } - /// Estimated files/sec, measured from the oldest retained progress - /// point to *now*. `None` until two data points exist. + /// Estimated files/sec over the last [`WINDOW`], measured from the + /// oldest retained progress point to *now*. `None` until two data + /// points exist. + /// + /// The span is shorter than the window early in a run, and longer than + /// it during a stall (nothing is recorded then, so nothing prunes and + /// the growing span decays the estimate toward zero). pub fn files_per_sec(&self) -> Option { self.files_per_sec_at(Instant::now()) } @@ -139,6 +149,29 @@ mod tests { .is_some()); } + #[test] + fn window_forgets_an_older_burst() { + // 10,000 files in the first second, then a steady 10/s. A run + // average would still read in the hundreds; the window must report + // what the run is doing now. + let mut t = SpeedTracker::new(); + let base = Instant::now(); + t.record_at(base, 0); + t.record_at(base + Duration::from_secs(1), 10_000); + for i in 2..=90 { + t.record_at(base + Duration::from_secs(i), 10_000 + 10 * i as usize); + } + let now = base + Duration::from_secs(90); + let rate = t.files_per_sec_at(now).unwrap(); + assert!((rate - 10.0).abs() < 0.5, "expected ~10/s, got {}", rate); + assert!( + t.points + .iter() + .all(|&(at, _)| now.duration_since(at) <= WINDOW), + "no point older than the window survives while others remain" + ); + } + #[test] fn counter_regression_resets() { let mut t = SpeedTracker::new(); diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index 0fcd184..134385e 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -249,11 +249,97 @@ pub fn more_below_hint(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutp )); } +/// Height of the wipe's soft edge, as a fraction of the section it travels +/// over — a proportional band so the transition reads the same on a tall +/// window as on a short one. +const WIPE_BAND: f32 = 0.45; +/// …but never thinner than this, so a two-row table still gets a gradient +/// rather than a hard cut. +const WIPE_BAND_MIN: f32 = 24.0; + +/// The two y-coordinates a wipe's scrim ramps between: fully clear at and +/// above the first, fully opaque at and below the second. +/// +/// `wipe` is 1 when the section is entirely covered and 0 when it is +/// entirely on screen, and the edge travels monotonically with it — so +/// walking `wipe` down from 1 slides the covered region off the bottom of +/// the section, uncovering the top first. +fn wipe_edges(rect: egui::Rect, wipe: f32) -> (f32, f32) { + let band = (rect.height() * WIPE_BAND).max(WIPE_BAND_MIN); + let covered = rect.bottom() + band - wipe * (rect.height() + band); + (covered - band, covered) +} + +/// The scrim hiding the `wipe` of `rect` not yet revealed: a vertical +/// gradient from transparent to solid `fill`, or `None` once nothing is +/// covered. +/// +/// Painting the background colour over the results is equivalent to fading +/// them into it, and unlike a per-row opacity it reaches the parts of an +/// `egui_extras` table that the caller never gets a `Ui` for — the stripes, +/// the selection fill, the scroll bar. +pub fn wipe_mesh(rect: egui::Rect, wipe: f32, fill: egui::Color32) -> Option { + if wipe <= 0.0 || rect.height() <= 0.0 || rect.width() <= 0.0 { + return None; + } + let (clear, covered) = wipe_edges(rect, wipe); + let alpha_at = |y: f32| ((y - clear) / (covered - clear)).clamp(0.0, 1.0); + + // The gradient is linear between the two edges and flat outside them, so + // the quads only have to break where an edge falls inside the rect. + let mut stops = vec![rect.top(), rect.bottom()]; + stops.extend( + [clear, covered] + .into_iter() + .filter(|&y| rect.y_range().contains(y)), + ); + stops.sort_by(f32::total_cmp); + + let mut mesh = egui::Mesh::default(); + for pair in stops.windows(2) { + let (top, bottom) = (pair[0], pair[1]); + let (a_top, a_bottom) = (alpha_at(top), alpha_at(bottom)); + // Sub-point slivers and the still-clear stretch above the edge would + // contribute nothing but vertices. + if bottom - top < 0.5 || (a_top <= 0.0 && a_bottom <= 0.0) { + continue; + } + let base = mesh.vertices.len() as u32; + for (y, alpha) in [(top, a_top), (bottom, a_bottom)] { + // Mesh vertices carry premultiplied colors, which is exactly + // what scaling an opaque one by `gamma_multiply` produces. + let color = fill.gamma_multiply(alpha); + for x in [rect.left(), rect.right()] { + mesh.colored_vertex(egui::pos2(x, y), color); + } + } + mesh.add_triangle(base, base + 1, base + 2); + mesh.add_triangle(base + 1, base + 2, base + 3); + } + (!mesh.is_empty()).then_some(mesh) +} + +/// Paint [`wipe_mesh`] over `rect` in the panel's own background color. +/// +/// Drawn through the layer painter, like [`more_below_hint`]: last in the +/// caller's layer so it covers the content painted before it, and — the +/// reason it cannot use `ui.painter()` — outside the section-wide opacity +/// the caller has already set, which would otherwise scale the scrim along +/// with what it is meant to hide. +pub fn wipe_scrim(ui: &egui::Ui, rect: egui::Rect, wipe: f32) { + let Some(mesh) = wipe_mesh(rect, wipe, ui.visuals().panel_fill) else { + return; + }; + ui.ctx() + .layer_painter(ui.layer_id()) + .add(egui::Shape::mesh(mesh)); +} + #[cfg(test)] mod tests { use super::{ - ignore_pattern_valid, middle_elide, pattern_border, pattern_hint, Cow, INVALID_RED, - VALID_GREEN, + ignore_pattern_valid, middle_elide, pattern_border, pattern_hint, wipe_mesh, Cow, + INVALID_RED, VALID_GREEN, WIPE_BAND_MIN, }; use crate::test_ui::with_ui; @@ -434,4 +520,134 @@ mod tests { // worth flagging, unlike a box the user simply has not filled in. assert_eq!(pattern_border("/"), Some(INVALID_RED)); } + + // --- The results wipe --------------------------------------------------- + + const SECTION: egui::Rect = egui::Rect { + min: egui::pos2(10.0, 100.0), + max: egui::pos2(410.0, 500.0), + }; + const FILL: egui::Color32 = egui::Color32::from_rgb(27, 27, 27); + + /// Every vertex of `mesh` as (y, alpha), in paint order. + fn ramp(mesh: &egui::Mesh) -> Vec<(f32, u8)> { + mesh.vertices + .iter() + .map(|v| (v.pos.y, v.color.a())) + .collect() + } + + /// The y ranges the mesh's quads cover, merged where they touch. + fn covered_spans(mesh: &egui::Mesh) -> Vec<(f32, f32)> { + let mut spans: Vec<(f32, f32)> = Vec::new(); + for quad in mesh.vertices.chunks(4) { + let (top, bottom) = (quad[0].pos.y, quad[3].pos.y); + match spans.last_mut() { + Some(last) if (last.1 - top).abs() < 1e-3 => last.1 = bottom, + _ => spans.push((top, bottom)), + } + } + spans + } + + #[test] + fn a_revealed_section_paints_no_scrim() { + // The steady state is the common one: no shape, no vertices, no cost. + assert!(wipe_mesh(SECTION, 0.0, FILL).is_none()); + assert!(wipe_mesh(SECTION, -0.5, FILL).is_none()); + } + + #[test] + fn a_degenerate_section_paints_no_scrim() { + let flat = egui::Rect::from_min_max(egui::pos2(10.0, 100.0), egui::pos2(410.0, 100.0)); + assert!(wipe_mesh(flat, 0.5, FILL).is_none()); + let sliver = egui::Rect::from_min_max(egui::pos2(10.0, 100.0), egui::pos2(10.0, 500.0)); + assert!(wipe_mesh(sliver, 0.5, FILL).is_none()); + } + + #[test] + fn an_unstarted_wipe_covers_the_whole_section() { + let mesh = wipe_mesh(SECTION, 1.0, FILL).expect("fully hidden"); + assert!( + ramp(&mesh).iter().all(|&(_, a)| a == 255), + "nothing may show through before the reveal starts: {:?}", + ramp(&mesh) + ); + assert_eq!( + covered_spans(&mesh), + vec![(SECTION.top(), SECTION.bottom())], + "the quads must tile the section with no gap" + ); + } + + #[test] + fn the_scrim_ramps_clear_at_the_top_to_solid_at_the_bottom() { + let mesh = wipe_mesh(SECTION, 0.5, FILL).expect("mid-travel"); + let ramp = ramp(&mesh); + assert_eq!(ramp.first().expect("vertices").1, 0, "the top is untouched"); + assert_eq!(ramp.last().expect("vertices").1, 255, "the bottom is gone"); + // Alpha only ever increases downward, and the quads stay contiguous: + // a gradient, not a stack of steps. + for pair in ramp.windows(2) { + assert!( + pair[1].0 >= pair[0].0 && pair[1].1 >= pair[0].1, + "vertices run down the section, clear to opaque: {ramp:?}" + ); + } + assert_eq!(covered_spans(&mesh).len(), 1, "one contiguous scrim"); + } + + #[test] + fn less_of_the_section_shows_the_further_the_wipe_is_from_done() { + // How much of the section's height the scrim swallows: the integral + // of alpha down it, so a widening gradient counts as well as a + // growing solid block. + let hidden_height = |wipe: f32| { + let mesh = wipe_mesh(SECTION, wipe, FILL).expect("travelling"); + mesh.vertices + .chunks(4) + .map(|quad| { + let alpha = |v: &egui::epaint::Vertex| v.color.a() as f32 / 255.0; + (quad[3].pos.y - quad[0].pos.y) * (alpha(&quad[0]) + alpha(&quad[3])) / 2.0 + }) + .sum::() + }; + let mut previous = 0.0; + for step in 1..=10 { + let hidden = hidden_height(step as f32 / 10.0); + assert!( + hidden > previous, + "each step hides more than the last: {hidden} after {previous}" + ); + previous = hidden; + } + assert!( + (previous - SECTION.height()).abs() < 0.5, + "and the section is entirely gone by the end: {previous} of {}", + SECTION.height() + ); + } + + #[test] + fn a_short_section_still_gets_a_gradient() { + // Two rows tall: the proportional band would be a few points, small + // enough to read as a hard cut, so the floor takes over. + let short = egui::Rect::from_min_max(egui::pos2(10.0, 100.0), egui::pos2(410.0, 130.0)); + let mesh = wipe_mesh(short, 0.5, FILL).expect("mid-travel"); + let gradient = mesh + .vertices + .chunks(4) + .any(|quad| quad[0].color.a() < quad[3].color.a()); + assert!( + gradient, + "the edge ramps rather than cutting: {:?}", + ramp(&mesh) + ); + let (clear, covered) = super::wipe_edges(short, 0.5); + assert!( + (covered - clear - WIPE_BAND_MIN).abs() < 1e-3, + "the band is held at its floor: {}", + covered - clear + ); + } } diff --git a/packaging/build-appimage.sh b/packaging/build-appimage.sh new file mode 100644 index 0000000..0e1306c --- /dev/null +++ b/packaging/build-appimage.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# +# Build an AppImage for QuickSearch. +# +# ./packaging/build-appimage.sh build and package +# ./packaging/build-appimage.sh --no-build package an existing release binary +# ./packaging/build-appimage.sh --no-strip keep debug symbols +# ./packaging/build-appimage.sh -o /tmp/out write the AppImage somewhere else +# +# Environment: SOURCE_DATE_EPOCH, APPIMAGETOOL. +# +# The AppDir bundles no libraries at all. That is not an oversight: the binary's +# dynamic section names only libgcc_s, libm and libc, because SQLCipher, OpenSSL +# and libdbus are linked statically, and winit and glutin dlopen the whole +# display stack at runtime. Those dlopened libraries - libGL, libEGL, libX11, +# libxcb, libXcursor, libXi, libXrender, libxkbcommon{,-x11} and +# libwayland-{client,egl} - are exactly the ones an AppImage must take from the +# host, since they have to match the user's graphics driver and compositor. +# Bundling them is how AppImages break on other people's machines. +# +# So the glibc floor is the only portability limit, and it is the same one the +# .deb carries: whatever the machine that built the binary provides. Release +# builds happen in a 22.04 container to keep that at 2.35. + +set -euo pipefail +# Directories created along the way must be 0755, not whatever the caller's +# umask happens to be, matching build-deb.sh. +umask 022 + +readonly PKG=quicksearch +readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons" +readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg" +readonly METAINFO=com.karsttech.quicksearch.metainfo.xml + +# Pinned rather than tracking the `continuous` tag, so a rebuild of an old +# commit uses the tool that commit was tested with. Bump the version and the +# checksum together; the checksum is the release asset's own digest. +readonly APPIMAGETOOL_VERSION=1.9.1 +readonly APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 +readonly APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage" + +# The runtime is the ~1 MB ELF stub prepended to the squashfs: it is the first +# thing that executes on a user's machine, so it matters more than the tool that +# assembles it. Left alone, appimagetool downloads it from the type2-runtime +# `continuous` tag mid-build, which would put an unpinned binary inside every +# release and make the checksum above mostly decorative. Pinned to a dated tag +# and passed in with --runtime-file instead. +readonly RUNTIME_VERSION=20251108 +readonly RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d +readonly RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-x86_64" + +# Baked into every AppImage this produces, so AppImageUpdate can find newer +# builds. It cannot be corrected after the fact - a released binary keeps +# pointing wherever this said at the time - so moving the forge means the old +# URL has to keep resolving. +# +# Forgejo resolves the literal tag `latest` to the newest release and looks the +# asset up by name, so this path stays valid across releases. The GitHub-style +# /releases/latest/download/ form is not implemented and 404s. The .zsync +# name therefore carries no version, while the AppImage it points at does; zsync +# resolves that from the sidecar's own headers, relative to this URL. +readonly UPDATE_URL="https://code.karsttech.com/jeremy/quick_search/releases/download/latest/$PKG-x86_64.AppImage.zsync" + +do_build=1 +do_strip=1 +out_dir="$REPO_ROOT/dist" + +die() { printf 'build-appimage: %s\n' "$*" >&2; exit 1; } +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --no-build) do_build=0 ;; + --no-strip) do_strip=0 ;; + -o|--output-dir) shift; [ $# -gt 0 ] || die "--output-dir needs a path"; out_dir="$1" ;; + # Print the header comment block, however long it grows. + -h|--help) awk 'NR > 1 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac + shift +done + +# zsyncmake comes from the zsync package. appimagetool looks it up in PATH +# rather than bundling it, and - worse - reports success and produces nothing +# when it is missing, so it is checked for here instead. appstreamcli comes from +# the appstream package. Unlike build-deb.sh, which is deliberately buildable +# with nothing but a stock Debian install, this script already downloads +# appimagetool, so a couple more packages cost nothing in reach. +for tool in curl sha256sum desktop-file-validate zsyncmake appstreamcli strings; do + command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool" +done +[ "$do_strip" -eq 0 ] || command -v strip >/dev/null 2>&1 || die "missing strip (install binutils, or pass --no-strip)" + +# Version comes from [workspace.package] so the AppImage can never drift from +# the crate version. +version="$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' "$REPO_ROOT/Cargo.toml")" +[ -n "$version" ] || die "could not read version from Cargo.toml" + +# Absolute from here on: appimagetool is run from a scratch directory below, and +# -o could well have been given a relative path. +mkdir -p "$out_dir" +out_dir="$(cd -- "$out_dir" && pwd)" + +appdir="$out_dir/$PKG-$version-x86_64.AppDir" +appimage="$out_dir/$PKG-$version-x86_64.AppImage" +# Version-less on purpose: see UPDATE_URL above. +zsync="$out_dir/$PKG-x86_64.AppImage.zsync" + +# Scratch space for appimagetool's stray output and the verification unpack. +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +# ------------------------------------------------------- appimagetool ------ + +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/quicksearch" +appimagetool="${APPIMAGETOOL:-$cache_dir/appimagetool-$APPIMAGETOOL_VERSION-x86_64.AppImage}" +runtime="$cache_dir/runtime-$RUNTIME_VERSION-x86_64" + +# Fetch to $1 from $2 if it is not cached, then check it against $3 on every +# run, not only after a download: a cached file is as much a supply-chain input +# as a freshly fetched one, and this is all that stands between a poisoned cache +# and a published release. +fetch_pinned() { + local dest="$1" url="$2" sum="$3" + if [ ! -f "$dest" ]; then + say "Fetching $(basename -- "$dest")" + mkdir -p "$(dirname -- "$dest")" + curl --proto '=https' --tlsv1.2 -fsSL -o "$dest.part" "$url" \ + || die "could not download $url" + mv -- "$dest.part" "$dest" + fi + printf '%s %s\n' "$sum" "$dest" | sha256sum -c - >/dev/null 2>&1 \ + || die "checksum mismatch for $dest (delete it and retry)" +} + +if [ -z "${APPIMAGETOOL:-}" ]; then + fetch_pinned "$appimagetool" "$APPIMAGETOOL_URL" "$APPIMAGETOOL_SHA256" + # curl leaves it 0644, and the cached copy is ours to chmod. A caller-supplied + # one might be root-owned in /usr/bin, so that branch only checks. + chmod +x "$appimagetool" +else + [ -x "$appimagetool" ] || die "APPIMAGETOOL is not executable: $appimagetool" +fi + +fetch_pinned "$runtime" "$RUNTIME_URL" "$RUNTIME_SHA256" + +# ---------------------------------------------------------------- build ---- + +if [ "$do_build" -eq 1 ]; then + say "Building quicksearch $version (release)" + ( cd "$REPO_ROOT" && cargo build --release -p quicksearch-gui ) +fi + +[ -x "$REPO_ROOT/target/release/$PKG" ] \ + || die "no release binary at target/release/$PKG (drop --no-build?)" +[ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG" +[ -f "$REPO_ROOT/packaging/$METAINFO" ] || die "no metainfo at packaging/$METAINFO" + +say "Validating desktop entry" +desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop" + +# --------------------------------------------------------------- stage ----- + +say "Staging $appdir" +rm -rf "$appdir" +mkdir -p "$appdir" + +# Only the GUI binary ships, where build-deb.sh installs both: an AppImage has a +# single entry point, and on Unix `quicksearch` already does the terminal job +# too - quicksearch-cli is the same tool under a clearer name, which matters for +# something on PATH and not for a self-contained file the user runs directly. +install -Dm755 "$REPO_ROOT/target/release/$PKG" "$appdir/usr/bin/$PKG" +install -Dm644 "$REPO_ROOT/packaging/$PKG.desktop" "$appdir/usr/share/applications/$PKG.desktop" +install -Dm644 "$ICON_SVG" "$appdir/usr/share/icons/hicolor/scalable/apps/$PKG.svg" +install -Dm644 "$REPO_ROOT/packaging/copyright" "$appdir/usr/share/doc/$PKG/copyright" +install -Dm644 "$REPO_ROOT/config_example.toml" "$appdir/usr/share/doc/$PKG/config_example.toml" +install -Dm644 "$REPO_ROOT/README.md" "$appdir/usr/share/doc/$PKG/README.md" + +# Same glob-driven discovery as build-deb.sh, including the guard: the glob +# would also catch a non-size name like quicksearch-cli.png. +shopt -s nullglob +icons=("$ICON_SRC"/$PKG-*.png) +shopt -u nullglob +[ "${#icons[@]}" -gt 0 ] || die "no icons in $ICON_SRC" +for png in "${icons[@]}"; do + n="$(basename "$png" .png)"; n="${n#$PKG-}" + case "$n" in ''|*[!0-9]*) die "unexpected icon name: $(basename "$png")" ;; esac + install -Dm644 "$png" "$appdir/usr/share/icons/hicolor/${n}x${n}/apps/$PKG.png" +done +say "Installed ${#icons[@]} icon sizes plus the scalable SVG" + +# @VERSION@/@DATE@ substitution, as build-deb.sh does for the man page .TH line, +# so [workspace.package] version stays the only place a release is bumped. +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + metainfo_date="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d)" +else + metainfo_date="$(date -u +%Y-%m-%d)" +fi +install -dm755 "$appdir/usr/share/metainfo" +sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$metainfo_date/" \ + "$REPO_ROOT/packaging/$METAINFO" > "$appdir/usr/share/metainfo/$METAINFO" +chmod 644 "$appdir/usr/share/metainfo/$METAINFO" + +# Validated here rather than in CI so a local build fails the same way, and on +# the substituted file rather than the @VERSION@ template. build-deb.sh ships +# the identical file, so this covers both packages. appimagetool would run its +# own check, but against whatever appstreamcli happens to be in PATH at the +# time; --no-appstream below turns that off in favour of this one. +say "Validating AppStream metainfo" +appstreamcli validate --no-net --explain "$appdir/usr/share/metainfo/$METAINFO" + +# appimagetool looks for these three at the AppDir root. AppRun is a relative +# symlink rather than a wrapper script because nothing is bundled: there is no +# LD_LIBRARY_PATH or XDG_DATA_DIRS to set up, so a wrapper would only put a +# shell between the runtime and the app. +ln -s usr/bin/$PKG "$appdir/AppRun" +install -Dm644 "$ICON_SRC/$PKG-256.png" "$appdir/$PKG.png" +ln -s $PKG.png "$appdir/.DirIcon" +install -Dm644 "$REPO_ROOT/packaging/$PKG.desktop" "$appdir/$PKG.desktop" + +if [ "$do_strip" -eq 1 ]; then + before="$(du -h "$appdir/usr/bin/$PKG" | cut -f1)" + strip --strip-unneeded "$appdir/usr/bin/$PKG" + say "Stripped $PKG: $before -> $(du -h "$appdir/usr/bin/$PKG" | cut -f1)" +fi + +# -------------------------------------------------------------- package ---- + +say "Building $appimage" +rm -f "$appimage" "$zsync" +# Run from the scratch directory: -u makes appimagetool shell out to zsyncmake, +# which writes its sidecar into the working directory rather than beside the +# output, and that stray would otherwise land in the repo root. The one this +# script actually ships is generated below. +# +# APPIMAGE_EXTRACT_AND_RUN makes appimagetool unpack itself instead of mounting +# itself, which is what lets this run in a container with no /dev/fuse. ARCH is +# set explicitly rather than left to autodetection. +( cd "$scratch" && ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 \ + "$appimagetool" --no-appstream --runtime-file "$runtime" \ + -u "zsync|$UPDATE_URL" "$appdir" "$appimage" ) + +[ -f "$appimage" ] || die "appimagetool produced no $appimage" +chmod 755 "$appimage" + +# The shipped sidecar is generated here rather than left to appimagetool, which +# reports Success even when zsyncmake is absent and wrote nothing. Doing it +# directly also pins both headers: a bare relative name in URL: is what makes +# the stable, version-less sidecar name work, because zsync resolves it against +# wherever the sidecar was fetched from and so lands on the current release's +# versioned AppImage. +say "Generating $zsync" +zsyncmake -u "$(basename -- "$appimage")" -f "$(basename -- "$appimage")" \ + -o "$zsync" "$appimage" +[ -f "$zsync" ] || die "zsyncmake produced no $zsync" + +rm -rf "$appdir" + +# --------------------------------------------------------------- verify ---- + +say "Verifying $appimage" +( cd "$scratch" && "$appimage" --appimage-extract >/dev/null ) + +root="$scratch/squashfs-root" +for path in AppRun "$PKG.desktop" "$PKG.png" .DirIcon \ + "usr/bin/$PKG" "usr/share/applications/$PKG.desktop" \ + "usr/share/metainfo/$METAINFO"; do + [ -e "$root/$path" ] || die "$path missing from the AppImage" +done +# The whole point of the layout: no bundled libraries. +[ ! -d "$root/usr/lib" ] || die "the AppDir grew a usr/lib - see the header comment" + +# --version is handled before any window is created, so this works headless. +reported="$("$root/usr/bin/$PKG" --version)" +case "$reported" in + *"v$version"*) ;; + *) die "the packaged binary reports '$reported', expected v$version" ;; +esac + +# A sidecar describing a different file is worse than no sidecar: AppImageUpdate +# would fetch and then reject every delta. Length is the cheap way to catch it. +zsync_len="$(sed -n 's/^Length: //p' "$zsync" | head -1)" +appimage_len="$(stat -c %s "$appimage")" +[ "$zsync_len" = "$appimage_len" ] \ + || die "$zsync describes $zsync_len bytes, but the AppImage is $appimage_len" + +# The update information the runtime itself carries, which is what +# AppImageUpdate reads before it ever looks for a sidecar. +embedded="$(strings -a "$appimage" | grep -m1 '^zsync|' || true)" +[ "$embedded" = "zsync|$UPDATE_URL" ] \ + || die "embedded update info is '$embedded', expected 'zsync|$UPDATE_URL'" + +echo +say "Done: $appimage" +echo " reports: $reported" +echo " update info: $UPDATE_URL" +echo " sidecar: $zsync" +echo " run with: chmod +x $appimage && $appimage" diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index ed11384..a8cb8e4 100755 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -26,6 +26,7 @@ readonly BINARIES=(quicksearch quicksearch-cli) readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons" readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg" +readonly METAINFO=com.karsttech.quicksearch.metainfo.xml do_build=1 do_strip=1 @@ -78,6 +79,7 @@ done # Both binaries link the same crates, so either gives the same glibc floor. readonly primary_binary="$REPO_ROOT/target/release/$PKG" [ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG" +[ -f "$REPO_ROOT/packaging/$METAINFO" ] || die "no metainfo at packaging/$METAINFO" say "Validating desktop entry" desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop" @@ -119,6 +121,22 @@ for png in "${icons[@]}"; do done say "Installed ${#icons[@]} icon sizes plus the scalable SVG" +# AppStream metadata, so software centres show a real listing rather than a bare +# package name. @VERSION@/@DATE@ are substituted the same way the man page .TH +# line is below, keeping [workspace.package] version the only thing a release +# bumps. build-appimage.sh ships the identical file and is where it gets +# validated - appstreamcli is not something a stock Debian install has, and this +# script deliberately needs nothing beyond dpkg-deb and desktop-file-utils. +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + metainfo_date="$(date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%d)" +else + metainfo_date="$(date -u +%Y-%m-%d)" +fi +install -dm755 "$stage/usr/share/metainfo" +sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$metainfo_date/" \ + "$REPO_ROOT/packaging/$METAINFO" > "$stage/usr/share/metainfo/$METAINFO" +chmod 644 "$stage/usr/share/metainfo/$METAINFO" + # Debian wants man pages and the changelog compressed, with no gzip timestamp so # repeat builds are byte-identical. quicksearch-cli.1 is a one-line .so stub # pointing at quicksearch.1, which documents both binaries. diff --git a/packaging/capture-scenario.txt b/packaging/capture-scenario.txt index ce2391d..232ad50 100644 --- a/packaging/capture-scenario.txt +++ b/packaging/capture-scenario.txt @@ -24,7 +24,7 @@ wait_index_idle # hard wait: the full index must exist tab search focus_search # tab switches drop egui focus; re-arm it clear_query -window 1120 750 # compact clip: the smallest layout at +window 1200 600 # compact clip: the smallest layout at # which every results column still fits # (any narrower clips the Match column # away, defeating the demo), at 1.5x diff --git a/packaging/capture.sh b/packaging/capture.sh index a9235ed..97f2806 100644 --- a/packaging/capture.sh +++ b/packaging/capture.sh @@ -71,7 +71,7 @@ ignore_patterns = [] # 1.5x zoom + proportionally larger windows (set in the scenario) render the # same layout at ~1.5x the pixel density, for crisper website assets. [ui] -scale = 1.5 +scale = 1.25 EOF # --- run the scripted app --------------------------------------------------- diff --git a/packaging/com.karsttech.quicksearch.metainfo.xml b/packaging/com.karsttech.quicksearch.metainfo.xml new file mode 100644 index 0000000..bd4ba90 --- /dev/null +++ b/packaging/com.karsttech.quicksearch.metainfo.xml @@ -0,0 +1,70 @@ + + + + com.karsttech.quicksearch + QuickSearch + Fast full-text search across your files + + CC0-1.0 + GPL-3.0-or-later + + + quicksearch.desktop + + +

+ QuickSearch keeps a SQLite/FTS5 index of the directories you choose and + searches them by both filename and file content. It extracts text from + documents, PDFs, archives and office files, watches the indexed paths for + changes and reindexes in the background while the application is open. +

+

+ The same binary doubles as a terminal search tool: "quicksearch" followed + by search terms prints ranked results and exits without starting the + indexer, the file watcher or any background thread. +

+
+ + https://quicksearch.karsttech.com + https://github.com/DataScienceDIY/quick_search/issues + https://code.karsttech.com/jeremy/quick_search + + + quicksearch + quicksearch-cli + + + + + Utility + Filesystem + + + + search + find + index + full-text + files + content + + + + + + + +