Added AppImage to CI
Some checks failed
CI / linux (push) Has been cancelled
CI / release (push) Has been skipped
CI / windows-cross (push) Successful in 2m8s

This commit is contained in:
= 2026-08-07 20:44:07 -04:00
parent 89fb0317e8
commit dc9580a659
14 changed files with 1173 additions and 51 deletions

View file

@ -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

4
Cargo.lock generated
View file

@ -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",

View file

@ -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 <jeremy@karsttech.com>"]

View file

@ -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-<version>-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-<version>-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<version>` 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

View file

@ -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(),
);
}
}

View file

@ -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<u32>,
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<egui::Rect> = 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<egui::Rect> = 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]

View file

@ -114,6 +114,31 @@ pub fn painted_rows(out: &egui::FullOutput) -> Vec<String> {
.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

View file

@ -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<f64> {
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();

View file

@ -249,11 +249,97 @@ pub fn more_below_hint<R>(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<egui::Mesh> {
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::<f32>()
};
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
);
}
}

299
packaging/build-appimage.sh Normal file
View file

@ -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/<asset> 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"

View file

@ -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.

View file

@ -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

View file

@ -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 ---------------------------------------------------

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
AppStream metadata for QuickSearch.
@VERSION@ and @DATE@ are substituted at package time by build-deb.sh and
build-appimage.sh, the same way build-deb.sh rewrites the man page .TH version.
[workspace.package] version in Cargo.toml stays the single source of truth, so
nothing here needs a hand-edit at release time.
The file name has to match the component id, which is why it is not simply
quicksearch.metainfo.xml.
-->
<component type="desktop-application">
<id>com.karsttech.quicksearch</id>
<name>QuickSearch</name>
<summary>Fast full-text search across your files</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<!-- A reverse-DNS id cannot also be the desktop file's basename, so this is
what ties the component to packaging/quicksearch.desktop. -->
<launchable type="desktop-id">quicksearch.desktop</launchable>
<description>
<p>
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.
</p>
<p>
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.
</p>
</description>
<url type="homepage">https://quicksearch.karsttech.com</url>
<url type="bugtracker">https://github.com/DataScienceDIY/quick_search/issues</url>
<url type="vcs-browser">https://code.karsttech.com/jeremy/quick_search</url>
<provides>
<binary>quicksearch</binary>
<binary>quicksearch-cli</binary>
</provides>
<!-- Kept in step with packaging/quicksearch.desktop. A distribution's metadata
generator merges these from the desktop file, but an AppImage has no such
step, so they are spelled out here too. -->
<categories>
<category>Utility</category>
<category>Filesystem</category>
</categories>
<keywords>
<keyword>search</keyword>
<keyword>find</keyword>
<keyword>index</keyword>
<keyword>full-text</keyword>
<keyword>files</keyword>
<keyword>content</keyword>
</keywords>
<content_rating type="oars-1.1"/>
<releases>
<release version="@VERSION@" date="@DATE@"/>
</releases>
</component>