Compare commits

..

No commits in common. "dc9580a659c412b6d69c5b3a2411e3f44a7972af" and "89815842912078037c78984e826c520d43c1ac42" have entirely different histories.

16 changed files with 94 additions and 1430 deletions

View file

@ -99,15 +99,9 @@ jobs:
# for before it will run. # for before it will run.
# libcap2-bin provides capsh, which the Test step uses to drop the two # libcap2-bin provides capsh, which the Test step uses to drop the two
# DAC capabilities so root obeys permission bits. # 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 \ apt-get install -y --no-install-recommends \
build-essential perl pkg-config \ 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 - name: Trust the workspace
# checkout writes as root into a directory git then considers dubiously # checkout writes as root into a directory git then considers dubiously
@ -193,19 +187,6 @@ jobs:
fi fi
echo "OK: glibc floor $floor <= $MAX_GLIBC" 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 - name: Package the binaries
# A tarball for anyone not installing the .deb, stripped to match what # A tarball for anyone not installing the .deb, stripped to match what
# build-deb.sh ships. # build-deb.sh ships.
@ -233,15 +214,9 @@ jobs:
- uses: actions/upload-artifact@v3-node20 - uses: actions/upload-artifact@v3-node20
with: with:
name: linux-x86_64 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: | path: |
dist/*.deb dist/*.deb
dist/*.tar.gz dist/*.tar.gz
dist/*.AppImage
dist/*.zsync
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14

4
Cargo.lock generated
View file

@ -3097,7 +3097,7 @@ dependencies = [
[[package]] [[package]]
name = "quicksearch-core" name = "quicksearch-core"
version = "1.0.5" version = "1.0.3"
dependencies = [ dependencies = [
"argon2", "argon2",
"cfb", "cfb",
@ -3129,7 +3129,7 @@ dependencies = [
[[package]] [[package]]
name = "quicksearch-gui" name = "quicksearch-gui"
version = "1.0.5" version = "1.0.3"
dependencies = [ dependencies = [
"chrono", "chrono",
"eframe", "eframe",

View file

@ -6,7 +6,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "1.0.5" version = "1.0.3"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"] authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -6,16 +6,6 @@ sidecar), keeps it fresh automatically with filesystem watchers and
periodic reindexing, and serves ranked search-as-you-type results in a periodic reindexing, and serves ranked search-as-you-type results in a
compact egui desktop app, or straight to your terminal. 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 ## Build & run
```sh ```sh
@ -95,7 +85,6 @@ The package installs:
| `/usr/share/applications/quicksearch.desktop` | menu entry, so QuickSearch appears in the app launcher | | `/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/{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/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/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` | | `/usr/share/doc/quicksearch/` | copyright, changelog, README, `config_example.toml` |
@ -129,32 +118,6 @@ 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 — passing the images rather than one image and a size list is what keeps the
committed pixels instead of resampling them. 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) ## Install (Windows)
Download `quicksearch-<version>-windows-x86_64-setup.exe` from the release Download `quicksearch-<version>-windows-x86_64-setup.exe` from the release
@ -300,9 +263,12 @@ rebuilds the index — there is no in-place conversion.
- Forgot the password? The unlock screen can delete the index and disable - Forgot the password? The unlock screen can delete the index and disable
protection; your files are untouched and re-indexing rebuilds it. protection; your files are untouched and re-indexing rebuilds it.
This protects the index itself and for attacks like data theft. What this protects: the index file at rest — disk theft, backups, other
Anything malicious running with user permissions could bypass this protection, accounts reading the file. What it does not protect: a compromised running
but anything with user permissions can also access all of the same files. 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.
### Query syntax ### Query syntax
@ -608,15 +574,8 @@ 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 crates, so the `--locked` build after it still fails on a dependency added or
bumped without committing `Cargo.lock`. Once both bumped without committing `Cargo.lock`. Once both
build jobs are green, CI tags that commit `v<version>` and publishes a release build jobs are green, CI tags that commit `v<version>` and publishes a release
with the `.deb`, an AppImage and its `.zsync` sidecar, a Linux tarball, the with the `.deb`, a Linux tarball, the Windows installer and a Windows zip
Windows installer and a Windows zip attached; pushing a `v*` tag by hand does attached; pushing a `v*` tag by hand does the same thing. The version is never taken from the branch
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 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 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 identity: `crates/quicksearch-gui/build.rs` bakes in the commit CI passes as
@ -626,10 +585,7 @@ pagination: the table is virtualized, so a single scroll list capped at
`unknown` there rather than failing. The Linux job runs in an Ubuntu `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 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 `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 AppImage is cut from the package's minimum, and 22.04 pins it at 2.35. The Windows job
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 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 dependency on a non-system DLL, then builds both Windows assets from those
binaries — `packaging/build-installer.sh` runs `makensis`, which is a Linux binaries — `packaging/build-installer.sh` runs `makensis`, which is a Linux

View file

@ -206,17 +206,14 @@ struct Shared {
} }
impl IndexCoordinator { impl IndexCoordinator {
/// `notify` is called when this coordinator starts doing something the pub fn start(config: Config) -> Result<IndexCoordinator, String> {
/// frontend should be drawing; see [`Notify`]. Self::start_with_watcher_config(config, WatcherConfig::default())
pub fn start(config: Config, notify: Notify) -> Result<IndexCoordinator, String> {
Self::start_with_watcher_config(config, notify, WatcherConfig::default())
} }
/// [`Self::start`] with explicit watcher debounce tuning (tests use /// [`Self::start`] with explicit watcher debounce tuning (tests use
/// short windows; the default 30 s throttle is right for real use). /// short windows; the default 30 s throttle is right for real use).
pub fn start_with_watcher_config( pub fn start_with_watcher_config(
config: Config, config: Config,
notify: Notify,
watcher_config: WatcherConfig, watcher_config: WatcherConfig,
) -> Result<IndexCoordinator, String> { ) -> Result<IndexCoordinator, String> {
let indexing = Arc::new(IndexingService::new()); let indexing = Arc::new(IndexingService::new());
@ -242,8 +239,6 @@ impl IndexCoordinator {
config, config,
indexing: indexing.clone(), indexing: indexing.clone(),
shared: shared.clone(), shared: shared.clone(),
notify,
awake: false,
reconcile_stop: reconcile_stop.clone(), reconcile_stop: reconcile_stop.clone(),
event_tx, event_tx,
event_rx, event_rx,
@ -439,22 +434,10 @@ fn collapse_pending_removals(pending: &mut HashMap<PathBuf, FsEvent>) {
/// multi-tick drain is visible rather than silent. /// multi-tick drain is visible rather than silent.
const APPLY_BUDGET: Duration = Duration::from_millis(250); const APPLY_BUDGET: Duration = Duration::from_millis(250);
/// Wakes the frontend when this thread changes something worth drawing.
///
/// Same shape, and the same reason, as the one [`crate::search::SearchService`]
/// takes: a reactive GUI paints on input and on request, and only this thread
/// knows a run has begun.
pub type Notify = Arc<dyn Fn() + Send + Sync>;
struct Inner { struct Inner {
config: Config, config: Config,
indexing: Arc<IndexingService>, indexing: Arc<IndexingService>,
shared: Arc<Mutex<Shared>>, shared: Arc<Mutex<Shared>>,
notify: Notify,
/// Last published value of the "something is moving" predicate, so
/// [`Inner::publish`] can wake the frontend on the rising edge instead of
/// every turn of the loop. See there for why the edge matters.
awake: bool,
/// Read inside the reconciliation, set from the thread that shuts this /// Read inside the reconciliation, set from the thread that shuts this
/// one down; see [`ReconcileStop`]. /// one down; see [`ReconcileStop`].
reconcile_stop: Arc<ReconcileStop>, reconcile_stop: Arc<ReconcileStop>,
@ -994,17 +977,7 @@ impl Inner {
let last = self.shared.lock().unwrap().last_full_index; let last = self.shared.lock().unwrap().last_full_index;
match last { match last {
None => true, None => true,
Some(last) => { Some(last) => now_unix().saturating_sub(last) >= interval_secs,
let now = now_unix();
// A stamp ahead of the clock is not a schedule, it is a broken
// one: an NTP correction backwards, or an index carried over
// from a machine that was running fast. Subtracting would
// saturate to zero and read as "just indexed", suppressing the
// periodic reindex for as long as the skew lasted — days, in
// the case that produces the largest stamps. Due instead, which
// re-stamps it from this machine's clock and ends the problem.
now < last || now - last >= interval_secs
}
} }
} }
@ -1222,6 +1195,14 @@ impl Inner {
Ok(()) Ok(())
} }
/// Re-read the stamp the last completed full run left behind.
///
/// A failure to open is deliberately *not* published as `None`. Only a
/// successful read means "never indexed", and `periodic_due` answers that
/// by starting a full run immediately — so treating a locked or
/// contended database as "never" would schedule a fresh run every tick
/// for as long as the condition lasts. Keeping the previous value leaves
/// the schedule where it was until a read succeeds.
/// Re-read the published row count, at most every [`FILE_COUNT_INTERVAL`]. /// Re-read the published row count, at most every [`FILE_COUNT_INTERVAL`].
/// ///
/// Called only from the idle half of [`Inner::tick`], so it cannot run /// Called only from the idle half of [`Inner::tick`], so it cannot run
@ -1259,14 +1240,6 @@ impl Inner {
} }
} }
/// Re-read the stamp the last completed full run left behind.
///
/// A failure to open is deliberately *not* published as `None`. Only a
/// successful read means "never indexed", and `periodic_due` answers that
/// by starting a full run immediately — so treating a locked or
/// contended database as "never" would schedule a fresh run every tick
/// for as long as the condition lasts. Keeping the previous value leaves
/// the schedule where it was until a read succeeds.
fn refresh_last_full_index(&self) { fn refresh_last_full_index(&self) {
match db::open_existing(&self.db_path(), false) { match db::open_existing(&self.db_path(), false) {
Ok(conn) => { Ok(conn) => {
@ -1293,36 +1266,10 @@ impl Inner {
.map(|(progress, _)| ReconcileState::Finished(progress)) .map(|(progress, _)| ReconcileState::Finished(progress))
} }
}; };
let busy = reconcile.is_some()
|| !matches!(
self.indexing.get_status(),
IndexingStatus::Idle | IndexingStatus::Error(_)
);
let mut shared = self.shared.lock().unwrap(); let mut shared = self.shared.lock().unwrap();
shared.mode = self.mode; shared.mode = self.mode;
shared.queued_events = self.pending.len(); shared.queued_events = self.pending.len();
shared.reconcile = reconcile; shared.reconcile = reconcile;
drop(shared);
// Wake the frontend when the work starts. A reactive GUI keeps itself
// painting for as long as it can see something moving, but only a frame
// that observes the movement can begin that chain — and the frame that
// would have observed the *first* one is the frame nothing asks for.
// The periodic reindex a fresh launch is due is exactly that case: the
// window has settled at "Idle; last full index: 2 days ago" by the time
// the first tick starts the run, and it stays there until the pointer
// happens to move over it.
//
// Edge-triggered, because this runs every turn of the loop and a
// level-triggered call would hold a repaint at the tick rate for as
// long as the work lasts — and, worse, cost a wake-up per second
// forever after `go_idle` has given the memory back. One repaint on the
// transition is enough; the frontend's own cadence takes over from that
// frame and is still running to catch the fall back to idle.
if busy && !self.awake {
(self.notify)();
}
self.awake = busy;
} }
/// Must stay fast: it runs (transitively) on the GUI thread during /// Must stay fast: it runs (transitively) on the GUI thread during
@ -1385,17 +1332,6 @@ mod tests {
panic!("timed out waiting for {}", what); panic!("timed out waiting for {}", what);
} }
/// A coordinator whose wake-up goes nowhere, which is what every test that
/// polls `state()` wants. The tests that are *about* the wake-up pass their
/// own sink; see `a_run_it_schedules_itself_wakes_the_frontend`.
fn start_coord(config: Config) -> IndexCoordinator {
IndexCoordinator::start(config, Arc::new(|| {})).unwrap()
}
fn start_coord_watching(config: Config, watcher: WatcherConfig) -> IndexCoordinator {
IndexCoordinator::start_with_watcher_config(config, Arc::new(|| {}), watcher).unwrap()
}
struct Fixture { struct Fixture {
dir: PathBuf, dir: PathBuf,
db: PathBuf, db: PathBuf,
@ -1438,30 +1374,6 @@ mod tests {
) )
.ok() .ok()
} }
/// Leave a real, complete index on disk and stop — the state a
/// program that ran once and was closed leaves behind, and the
/// starting point for every test about what the *next* launch does.
///
/// Runs off `reindex_now` rather than automatic mode so the seeding
/// is one run and not a schedule.
fn seed_index(&self) {
let mut manual = self.config.clone();
manual.indexing.auto_index = false;
let coord = start_coord(manual);
coord.reindex_now();
wait_for("seed index", Duration::from_secs(30), || {
coord.state().last_full_index.is_some()
});
coord.shutdown();
}
/// Rewrite the stamp the periodic scheduler measures against, as a
/// program that last finished indexing at `ts` would have left it.
fn stamp_last_index(&self, ts: u64) {
let conn = db::open_existing(&self.db.to_string_lossy(), true).unwrap();
db::repo::set_last_full_index(&conn, ts).unwrap();
}
} }
impl Drop for Fixture { impl Drop for Fixture {
@ -1476,7 +1388,7 @@ mod tests {
let f = Fixture::new(false); let f = Fixture::new(false);
std::fs::write(f.dir.join("one.txt"), "manual mode content").unwrap(); std::fs::write(f.dir.join("one.txt"), "manual mode content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
assert_eq!(coord.state().mode, IndexMode::ManualStopped); assert_eq!(coord.state().mode, IndexMode::ManualStopped);
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
assert_eq!(f.file_count(), -1, "no run without a command"); assert_eq!(f.file_count(), -1, "no run without a command");
@ -1505,7 +1417,7 @@ mod tests {
} }
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(60), || { wait_for("initial run", Duration::from_secs(60), || {
coord.state().last_full_index.is_some() && f.file_count() == 401 coord.state().last_full_index.is_some() && f.file_count() == 401
@ -1545,7 +1457,7 @@ mod tests {
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap(); std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1589,7 +1501,7 @@ mod tests {
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap(); std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1631,7 +1543,7 @@ mod tests {
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap(); std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1677,7 +1589,7 @@ mod tests {
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap(); std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1727,7 +1639,7 @@ mod tests {
let mut narrowed = f.config.clone(); let mut narrowed = f.config.clone();
narrowed.indexing.ignore_patterns.push("*.log".into()); narrowed.indexing.ignore_patterns.push("*.log".into());
let coord = start_coord(narrowed.clone()); let coord = IndexCoordinator::start(narrowed.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1752,7 +1664,7 @@ mod tests {
std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap(); std::fs::write(f.dir.join("keep.txt"), "kept content").unwrap();
std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap(); std::fs::write(f.dir.join("drop.log"), "dropped content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("initial run", Duration::from_secs(20), || { wait_for("initial run", Duration::from_secs(20), || {
let s = coord.state(); let s = coord.state();
@ -1801,136 +1713,13 @@ mod tests {
} }
} }
/// The interval is measured against a stamp on disk, not against process
/// uptime, so the time the program spent closed counts toward it: come
/// back later than the interval and the reindex is owed on the first
/// tick, not an interval after the window opened.
#[test]
fn a_lapsed_stamp_starts_a_run_at_startup() {
let mut f = Fixture::new(false);
std::fs::write(f.dir.join("seed.txt"), "content").unwrap();
f.seed_index();
let stamped = now_unix() - 7200;
f.stamp_last_index(stamped);
f.config.indexing.auto_index = true;
f.config.indexing.reindex_interval_minutes = 1;
let coord = start_coord_watching(f.config.clone(), fast_watcher());
wait_for(
"a run without waiting out the interval",
Duration::from_secs(20),
|| coord.state().last_full_index.is_some_and(|t| t > stamped),
);
coord.shutdown();
}
/// The other half of the same rule, and the one that keeps it from being
/// satisfied by "always run at startup": a stamp still inside the
/// interval is not due, and a launch must leave it alone.
#[test]
fn a_fresh_stamp_waits_out_the_interval() {
let mut f = Fixture::new(false);
std::fs::write(f.dir.join("seed.txt"), "content").unwrap();
f.seed_index();
let stamped = now_unix();
f.stamp_last_index(stamped);
f.config.indexing.auto_index = true;
f.config.indexing.reindex_interval_minutes = 600;
let coord = start_coord_watching(f.config.clone(), fast_watcher());
std::thread::sleep(Duration::from_secs(3));
assert_eq!(
coord.state().last_full_index,
Some(stamped),
"a stamp inside the interval must not start a run"
);
coord.shutdown();
}
/// A clock corrected backwards by NTP, or an index carried over from a
/// machine that was running fast. The elapsed time is negative, and
/// subtracting saturates it to zero — which reads as "just indexed", so
/// without the guard the reindex is suppressed for as long as the skew
/// lasts rather than for the interval.
#[test]
fn a_stamp_from_the_future_is_treated_as_due() {
let mut f = Fixture::new(false);
std::fs::write(f.dir.join("seed.txt"), "content").unwrap();
f.seed_index();
let ahead = now_unix() + 86_400;
f.stamp_last_index(ahead);
f.config.indexing.auto_index = true;
f.config.indexing.reindex_interval_minutes = 600;
let coord = start_coord_watching(f.config.clone(), fast_watcher());
// The run re-stamps from *this* machine's clock, so the skew is not
// just ignored for one tick — it is gone.
wait_for(
"a run despite the future stamp",
Duration::from_secs(20),
|| coord.state().last_full_index.is_some_and(|t| t < ahead),
);
coord.shutdown();
}
/// A reactive frontend paints on input and on request, and this thread is
/// the only one that knows a *scheduled* run began — nothing the user did
/// starts it. Without the wake-up the window keeps showing "Idle; last
/// full index: 2 days ago" while the run it should be reporting is
/// already going, until the pointer happens to move over it.
#[test]
fn a_run_it_schedules_itself_wakes_the_frontend() {
use std::sync::atomic::AtomicUsize;
let mut f = Fixture::new(false);
std::fs::write(f.dir.join("seed.txt"), "content").unwrap();
f.seed_index();
let stamped = now_unix() - 7200;
f.stamp_last_index(stamped);
f.config.indexing.auto_index = true;
f.config.indexing.reindex_interval_minutes = 1;
let wakes = Arc::new(AtomicUsize::new(0));
let counter = wakes.clone();
let coord = IndexCoordinator::start_with_watcher_config(
f.config.clone(),
Arc::new(move || {
counter.fetch_add(1, Ordering::Relaxed);
}),
fast_watcher(),
)
.unwrap();
wait_for("the scheduled run", Duration::from_secs(20), || {
coord.state().last_full_index.is_some_and(|t| t > stamped)
});
let during = wakes.load(Ordering::Relaxed);
assert!(during > 0, "the run started without waking the frontend");
// And the wake is an edge into work, not a heartbeat. An idle
// coordinator still ticks once a second, and a wake per tick would
// hold a reactive frontend at a repaint per second forever — undoing
// exactly what `go_idle` is there to achieve. Well inside the one
// minute before this stamp comes due again.
std::thread::sleep(Duration::from_secs(3));
assert_eq!(
wakes.load(Ordering::Relaxed),
during,
"an idle coordinator must not keep waking the frontend"
);
coord.shutdown();
}
#[test] #[test]
fn auto_mode_runs_initial_index_and_applies_watcher_events() { fn auto_mode_runs_initial_index_and_applies_watcher_events() {
let f = Fixture::new(true); let f = Fixture::new(true);
std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap(); std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap();
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("initial auto index", Duration::from_secs(20), || { wait_for("initial auto index", Duration::from_secs(20), || {
coord.state().last_full_index.is_some() && f.file_count() == 1 coord.state().last_full_index.is_some() && f.file_count() == 1
}); });
@ -1956,7 +1745,8 @@ mod tests {
fn auto_mode_reports_an_active_watcher() { fn auto_mode_reports_an_active_watcher() {
let f = Fixture::new(true); let f = Fixture::new(true);
std::fs::create_dir_all(f.dir.join("sub")).unwrap(); std::fs::create_dir_all(f.dir.join("sub")).unwrap();
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("watcher active", Duration::from_secs(20), || { wait_for("watcher active", Duration::from_secs(20), || {
matches!(coord.state().watcher, WatcherStatus::Active { .. }) matches!(coord.state().watcher, WatcherStatus::Active { .. })
@ -1983,7 +1773,8 @@ mod tests {
max_watched_dirs: 2, max_watched_dirs: 2,
..fast_watcher() ..fast_watcher()
}; };
let coord = start_coord_watching(f.config.clone(), watcher_config); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), watcher_config).unwrap();
wait_for("watcher disabled", Duration::from_secs(20), || { wait_for("watcher disabled", Duration::from_secs(20), || {
matches!(coord.state().watcher, WatcherStatus::Disabled { .. }) matches!(coord.state().watcher, WatcherStatus::Disabled { .. })
@ -2011,7 +1802,7 @@ mod tests {
#[test] #[test]
fn manual_mode_reports_the_watcher_off() { fn manual_mode_reports_the_watcher_off() {
let f = Fixture::new(false); let f = Fixture::new(false);
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
assert_eq!(coord.state().watcher, WatcherStatus::Off); assert_eq!(coord.state().watcher, WatcherStatus::Off);
coord.shutdown(); coord.shutdown();
@ -2020,7 +1811,8 @@ mod tests {
#[test] #[test]
fn stopping_turns_the_watcher_status_off() { fn stopping_turns_the_watcher_status_off() {
let f = Fixture::new(true); let f = Fixture::new(true);
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("watcher active", Duration::from_secs(20), || { wait_for("watcher active", Duration::from_secs(20), || {
matches!(coord.state().watcher, WatcherStatus::Active { .. }) matches!(coord.state().watcher, WatcherStatus::Active { .. })
}); });
@ -2036,7 +1828,7 @@ mod tests {
fn manual_stop_drops_watcher_and_events() { fn manual_stop_drops_watcher_and_events() {
let f = Fixture::new(true); let f = Fixture::new(true);
std::fs::write(f.dir.join("seed.txt"), "content").unwrap(); std::fs::write(f.dir.join("seed.txt"), "content").unwrap();
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
wait_for("initial index", Duration::from_secs(20), || { wait_for("initial index", Duration::from_secs(20), || {
f.file_count() == 1 f.file_count() == 1
}); });
@ -2061,7 +1853,8 @@ mod tests {
#[test] #[test]
fn applying_a_config_switches_the_mode_to_match_auto_index() { fn applying_a_config_switches_the_mode_to_match_auto_index() {
let f = Fixture::new(true); let f = Fixture::new(true);
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("watcher active", Duration::from_secs(20), || { wait_for("watcher active", Duration::from_secs(20), || {
matches!(coord.state().watcher, WatcherStatus::Active { .. }) matches!(coord.state().watcher, WatcherStatus::Active { .. })
}); });
@ -2093,7 +1886,8 @@ mod tests {
// the command loop). // the command loop).
let f = Fixture::new(true); let f = Fixture::new(true);
std::fs::write(f.dir.join("first.txt"), "one").unwrap(); std::fs::write(f.dir.join("first.txt"), "one").unwrap();
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("initial index", Duration::from_secs(20), || { wait_for("initial index", Duration::from_secs(20), || {
f.file_count() == 1 f.file_count() == 1
}); });
@ -2184,7 +1978,8 @@ mod tests {
} }
std::fs::write(f.dir.join("keep.txt"), "survivor").unwrap(); std::fs::write(f.dir.join("keep.txt"), "survivor").unwrap();
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("initial index", Duration::from_secs(30), || { wait_for("initial index", Duration::from_secs(30), || {
f.file_count() == 41 f.file_count() == 41
}); });
@ -2210,7 +2005,8 @@ mod tests {
std::fs::write(&p, format!("body {}", i)).unwrap(); std::fs::write(&p, format!("body {}", i)).unwrap();
} }
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
coord.reindex_now(); coord.reindex_now();
wait_for("first run", Duration::from_secs(30), || { wait_for("first run", Duration::from_secs(30), || {
coord.state().last_full_index.is_some() && f.file_count() == 300 coord.state().last_full_index.is_some() && f.file_count() == 300
@ -2262,7 +2058,8 @@ mod tests {
fn clear_index_deletes_db_and_stays_manual() { fn clear_index_deletes_db_and_stays_manual() {
let f = Fixture::new(true); // auto mode — clear must not auto-resurrect let f = Fixture::new(true); // auto mode — clear must not auto-resurrect
std::fs::write(f.dir.join("a.txt"), "content").unwrap(); std::fs::write(f.dir.join("a.txt"), "content").unwrap();
let coord = start_coord_watching(f.config.clone(), fast_watcher()); let coord =
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
wait_for("initial index", Duration::from_secs(20), || { wait_for("initial index", Duration::from_secs(20), || {
f.file_count() == 1 f.file_count() == 1
}); });
@ -2296,7 +2093,7 @@ mod tests {
.paths .paths
.indexing_paths .indexing_paths
.push(child.to_string_lossy().into_owned()); .push(child.to_string_lossy().into_owned());
let coord = start_coord(config); let coord = IndexCoordinator::start(config).unwrap();
coord.reindex_now(); coord.reindex_now();
std::thread::sleep(Duration::from_secs(3)); std::thread::sleep(Duration::from_secs(3));
assert_eq!( assert_eq!(
@ -2310,7 +2107,7 @@ mod tests {
#[test] #[test]
fn shutdown_is_idempotent_and_joins() { fn shutdown_is_idempotent_and_joins() {
let f = Fixture::new(false); let f = Fixture::new(false);
let coord = start_coord(f.config.clone()); let coord = IndexCoordinator::start(f.config.clone()).unwrap();
coord.shutdown(); coord.shutdown();
coord.shutdown(); // second call is a no-op coord.shutdown(); // second call is a no-op
} }

View file

@ -3,9 +3,7 @@
//! All communication is non-blocking from the UI's point of view: //! All communication is non-blocking from the UI's point of view:
//! searches stream over an mpsc receiver drained each frame, indexing //! searches stream over an mpsc receiver drained each frame, indexing
//! state is polled, and the duplicates query runs on a throwaway worker //! state is polled, and the duplicates query runs on a throwaway worker
//! thread. Core threads wake the UI through `ctx.request_repaint()` — every //! thread. Core threads wake the UI through `ctx.request_repaint()`.
//! one of them, which is what makes polling enough: the frame that starts the
//! UI's own cadence is the one the waking thread asks for.
//! //!
//! The duplicates scan is the *only* throwaway thread left here, and it fires //! The duplicates scan is the *only* throwaway thread left here, and it fires
//! on a user action rather than a timer. The status bar's file count used to //! on a user action rather than a timer. The status bar's file count used to
@ -30,16 +28,7 @@ pub struct Backend {
impl Backend { impl Backend {
pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> { pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> {
// The coordinator gets the same repaint hook the search worker does, let coordinator = Arc::new(IndexCoordinator::start(config.clone())?);
// and needs it for the same reason: eframe is reactive, and a run it
// schedules on its own — the periodic reindex a launch is already due —
// would otherwise sit unseen behind a settled window until the pointer
// moved. It calls this on the edge into work, not on a cadence.
let coord_ctx = ctx.clone();
let coordinator = Arc::new(IndexCoordinator::start(
config.clone(),
Arc::new(move || coord_ctx.request_repaint()),
)?);
if let Err(e) = shutdown::install_signal_handler(coordinator.clone()) { if let Err(e) = shutdown::install_signal_handler(coordinator.clone()) {
quicksearch_core::log_warn!("failed to install signal handler: {}", e); quicksearch_core::log_warn!("failed to install signal handler: {}", e);
} }

View file

@ -730,11 +730,7 @@ fn status_contents(ui: &mut egui::Ui, state: &IndexerState, speed: &SpeedTracker
} }
if let Some(rate) = speed.files_per_sec() { if let Some(rate) = speed.files_per_sec() {
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!("overall: {}", fmt_rate(rate)))
"last {}s: {}",
crate::tracker::WINDOW.as_secs(),
fmt_rate(rate)
))
.small() .small()
.weak(), .weak(),
); );

View file

@ -17,21 +17,6 @@ use crate::ui_util::middle_elide;
/// showing the spinner, a time or nothing, so the query box stays put. /// showing the spinner, a time or nothing, so the query box stays put.
const STATUS_SLOT_WIDTH: f32 = 52.0; 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey { pub enum SortKey {
Rank, Rank,
@ -108,21 +93,6 @@ pub struct SearchTab {
staging_has_snippets: bool, staging_has_snippets: bool,
/// True from search start until the staged set has been swapped in. /// True from search start until the staged set has been swapped in.
swap_pending: bool, 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`. /// Display permutation over `results`.
order: Vec<u32>, order: Vec<u32>,
sort: (SortKey, bool), sort: (SortKey, bool),
@ -164,8 +134,6 @@ impl SearchTab {
staging: Vec::new(), staging: Vec::new(),
staging_has_snippets: false, staging_has_snippets: false,
swap_pending: false, swap_pending: false,
wipe: 0.0,
fade: 1.0,
order: Vec::new(), order: Vec::new(),
sort: (SortKey::Rank, true), sort: (SortKey::Rank, true),
sort_dirty: false, sort_dirty: false,
@ -194,14 +162,11 @@ impl SearchTab {
self.pending_edit = Some(Instant::now()); self.pending_edit = Some(Instant::now());
} }
/// The query has executed, the stage swap has landed and the table has /// The query has executed and the fade/stage swap has landed — what the
/// wiped all the way back in — what the capture driver's /// capture driver's `wait_search_done` means by "done".
/// `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")] #[cfg(feature = "capture")]
pub(crate) fn capture_settled(&self) -> bool { pub(crate) fn capture_settled(&self) -> bool {
!self.running && self.pending_edit.is_none() && self.fade_settled() !self.running && self.pending_edit.is_none() && !self.swap_pending
} }
/// Re-arm the one-shot first-frame focus: tab switches drop egui focus, /// Re-arm the one-shot first-frame focus: tab switches drop egui focus,
@ -233,34 +198,6 @@ impl SearchTab {
self.error = None; 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) { pub fn apply_update(&mut self, update: SearchUpdate, display_limit: usize) {
if update.generation() != self.generation { if update.generation() != self.generation {
return; return;
@ -521,40 +458,32 @@ impl SearchTab {
ui.label(egui::RichText::new("No results.").small().weak()); ui.label(egui::RichText::new("No results.").small().weak());
} }
// Result-set transitions arrive instead of strobing: the old table // Result-set transitions pulse instead of strobing: the old table
// dips out over `FADE_OUT_SECS` while the new hits stage, the sets // fades out over 0.15 s while the new hits stage, the sets swap at
// swap once nothing is left on screen, and the new table is then // zero opacity, and the new table fades back in over 0.15 s.
// uncovered from the top down over `FADE_IN_SECS`. Repaints have to // `animate_value_with_time` keeps requesting repaints until the
// be asked for by hand, since the values are ours rather than the // value settles.
// animation manager's — and the swap frame needs one too, having let fade_target = if self.swap_pending { 0.0 } else { 1.0 };
// just finished the fade-out without yet starting the reveal. let fade =
self.advance_fade(ui.input(|i| i.stable_dt)); ui.ctx()
if self.swap_pending && self.fade <= 0.0 { .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.15);
if self.swap_pending && fade <= 0.01 {
self.results = std::mem::take(&mut self.staging); self.results = std::mem::take(&mut self.staging);
self.has_snippets = self.staging_has_snippets; self.has_snippets = self.staging_has_snippets;
self.selected = None; self.selected = None;
self.swap_pending = false; 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 // Staged hits arrived in scan order too, so the table has to be
// ordered here as well — including under the default key. // ordered here as well — including under the default key.
self.sort_dirty = true; self.sort_dirty = true;
} }
if !self.fade_settled() {
ui.ctx().request_repaint();
}
if self.sort_dirty { if self.sort_dirty {
self.resort(); self.resort();
} }
// The section-wide half of the effect: the whole of the fade-out, // Fade covers the table and the preview strip below it; the modal
// and a short climb at the start of the reveal so its leading edge // windows and notices render at full opacity on their own layers.
// does not have to carry that alone. Rows the edge has passed stay ui.set_opacity(fade);
// 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 ------------------------------------------------ // --- Results table ------------------------------------------------
// Reserve room for the selected-row snippet preview strip. Only // Reserve room for the selected-row snippet preview strip. Only
@ -581,10 +510,6 @@ impl SearchTab {
#[cfg(feature = "capture")] #[cfg(feature = "capture")]
let mut capture_match_rects: Vec<egui::Rect> = Vec::new(); 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 let table_scroll = ui
.push_id("results", |ui| { .push_id("results", |ui| {
let mut table = TableBuilder::new(ui) let mut table = TableBuilder::new(ui)
@ -817,16 +742,6 @@ impl SearchTab {
ui.label(job); 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.ignore_dialog_ui(ui.ctx(), &mut actions);
self.help_window_ui(ui.ctx()); self.help_window_ui(ui.ctx());
actions actions
@ -1705,9 +1620,8 @@ mod tests {
); );
} }
/// Batches arriving while the old table wipes away get the same /// Batches arriving during the fade get the same treatment; the ordering
/// treatment; the ordering problem must not simply move inside /// problem must not simply move inside the 250 ms window.
/// `FADE_OUT_SECS`.
#[test] #[test]
fn staged_batches_are_ordered_once_the_fade_swaps() { fn staged_batches_are_ordered_once_the_fade_swaps() {
let mut tab = SearchTab::new(false); let mut tab = SearchTab::new(false);
@ -1735,308 +1649,6 @@ mod tests {
assert_eq!(displayed(&tab), vec!["best.txt", "worst.txt"]); 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 /// A selected row is identified by file id, so it survives both the
/// re-ordering and the eviction that a new batch can cause. /// re-ordering and the eviction that a new batch can cause.
#[test] #[test]

View file

@ -114,31 +114,6 @@ pub fn painted_rows(out: &egui::FullOutput) -> Vec<String> {
.collect() .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 centre of `needle`'s galley, as a click target.
/// ///
/// The *last* match wins, so a string painted both behind a modal and on it /// The *last* match wins, so a string painted both behind a modal and on it

View file

@ -1,22 +1,17 @@
//! Indexing-rate estimation for the status displays. //! Indexing-rate estimation for the status displays.
//! //!
//! The rate shown is a rolling [`WINDOW`] average, not a run average: what //! The old tracker sampled the counter every poll tick but pruned to a
//! matters while watching a run is what it is doing *now*, and a run that //! 1-second window, so anything slower than ~1 file/sec measured a
//! spent its first minute on a fast SSD root drags a whole-run mean far //! genuine zero and displayed "0.0 files/sec" despite progress. This one
//! above the rate the slow root it is on is actually achieving. //! 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
//! The tracker records a point only when the counter *changes*, prunes //! computable), and measures against `now` so the estimate decays during
//! points older than the window but never below two (so a rate slower than //! stalls instead of freezing at the last burst.
//! 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::collections::VecDeque;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// The averaging window. Public so the display can name it and stay in const HISTORY: Duration = Duration::from_secs(60);
/// sync with it.
pub const WINDOW: Duration = Duration::from_secs(30);
pub struct SpeedTracker { pub struct SpeedTracker {
/// (when, counter value) — appended only on counter change. A deque /// (when, counter value) — appended only on counter change. A deque
@ -49,20 +44,15 @@ impl SpeedTracker {
_ => {} _ => {}
} }
self.points.push_back((now, files_processed)); self.points.push_back((now, files_processed));
// Prune points that have fallen out of the window, but always keep // Prune old points, but always keep at least two so a slow but
// at least two so a slow but steady rate never becomes unmeasurable. // steady rate never becomes unmeasurable.
while self.points.len() > 2 && now.duration_since(self.points[0].0) > WINDOW { while self.points.len() > 2 && now.duration_since(self.points[0].0) > HISTORY {
self.points.pop_front(); self.points.pop_front();
} }
} }
/// Estimated files/sec over the last [`WINDOW`], measured from the /// Estimated files/sec, measured from the oldest retained progress
/// oldest retained progress point to *now*. `None` until two data /// point to *now*. `None` until two data points exist.
/// 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> { pub fn files_per_sec(&self) -> Option<f64> {
self.files_per_sec_at(Instant::now()) self.files_per_sec_at(Instant::now())
} }
@ -149,29 +139,6 @@ mod tests {
.is_some()); .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] #[test]
fn counter_regression_resets() { fn counter_regression_resets() {
let mut t = SpeedTracker::new(); let mut t = SpeedTracker::new();

View file

@ -249,97 +249,11 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
ignore_pattern_valid, middle_elide, pattern_border, pattern_hint, wipe_mesh, Cow, ignore_pattern_valid, middle_elide, pattern_border, pattern_hint, Cow, INVALID_RED,
INVALID_RED, VALID_GREEN, WIPE_BAND_MIN, VALID_GREEN,
}; };
use crate::test_ui::with_ui; use crate::test_ui::with_ui;
@ -520,134 +434,4 @@ mod tests {
// worth flagging, unlike a box the user simply has not filled in. // worth flagging, unlike a box the user simply has not filled in.
assert_eq!(pattern_border("/"), Some(INVALID_RED)); 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
);
}
} }

View file

@ -1,299 +0,0 @@
#!/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,7 +26,6 @@ readonly BINARIES=(quicksearch quicksearch-cli)
readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons" readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons"
readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg" readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg"
readonly METAINFO=com.karsttech.quicksearch.metainfo.xml
do_build=1 do_build=1
do_strip=1 do_strip=1
@ -79,7 +78,6 @@ done
# Both binaries link the same crates, so either gives the same glibc floor. # Both binaries link the same crates, so either gives the same glibc floor.
readonly primary_binary="$REPO_ROOT/target/release/$PKG" readonly primary_binary="$REPO_ROOT/target/release/$PKG"
[ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG" [ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG"
[ -f "$REPO_ROOT/packaging/$METAINFO" ] || die "no metainfo at packaging/$METAINFO"
say "Validating desktop entry" say "Validating desktop entry"
desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop" desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop"
@ -121,22 +119,6 @@ for png in "${icons[@]}"; do
done done
say "Installed ${#icons[@]} icon sizes plus the scalable SVG" 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 # 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 # repeat builds are byte-identical. quicksearch-cli.1 is a one-line .so stub
# pointing at quicksearch.1, which documents both binaries. # 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 tab search
focus_search # tab switches drop egui focus; re-arm it focus_search # tab switches drop egui focus; re-arm it
clear_query clear_query
window 1200 600 # compact clip: the smallest layout at window 1120 750 # compact clip: the smallest layout at
# which every results column still fits # which every results column still fits
# (any narrower clips the Match column # (any narrower clips the Match column
# away, defeating the demo), at 1.5x # 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 # 1.5x zoom + proportionally larger windows (set in the scenario) render the
# same layout at ~1.5x the pixel density, for crisper website assets. # same layout at ~1.5x the pixel density, for crisper website assets.
[ui] [ui]
scale = 1.25 scale = 1.5
EOF EOF
# --- run the scripted app --------------------------------------------------- # --- run the scripted app ---------------------------------------------------

View file

@ -1,70 +0,0 @@
<?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>