Added AppImage to CI
This commit is contained in:
parent
89fb0317e8
commit
dc9580a659
14 changed files with 1173 additions and 51 deletions
|
|
@ -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
4
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>"]
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -6,6 +6,16 @@ sidecar), keeps it fresh automatically with filesystem watchers and
|
|||
periodic reindexing, and serves ranked search-as-you-type results in a
|
||||
compact egui desktop app, or straight to your terminal.
|
||||
|
||||
## AI Disclaimer
|
||||
QuickSearch has a core designed by it's developer and built by hand, however the majority of it's codebase including it's GUI was designed by a human and built using AI agents with human review, improvements, and testing.
|
||||
|
||||
## GitHub Mirror
|
||||
The primary home of this software is:
|
||||
https://code.karsttech.com/jeremy/quick_search
|
||||
|
||||
The code is also mirrored to GitHub for easier bug reporting and issue tracking:
|
||||
https://github.com/DataScienceDIY/quick_search
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
|
|
@ -85,6 +95,7 @@ The package installs:
|
|||
| `/usr/share/applications/quicksearch.desktop` | menu entry, so QuickSearch appears in the app launcher |
|
||||
| `/usr/share/icons/hicolor/{16,22,24,32,48,64,128,256}x*/apps/` | icons at each size |
|
||||
| `/usr/share/icons/hicolor/scalable/apps/quicksearch.svg` | the source icon |
|
||||
| `/usr/share/metainfo/com.karsttech.quicksearch.metainfo.xml` | AppStream data, so software centres show a real listing |
|
||||
| `/usr/share/man/man1/quicksearch{,-cli}.1.gz` | `man quicksearch`; the `-cli` page is a `.so` stub pointing at it |
|
||||
| `/usr/share/doc/quicksearch/` | copyright, changelog, README, `config_example.toml` |
|
||||
|
||||
|
|
@ -118,6 +129,32 @@ largest first, and `save(..., format="ICO", sizes=[...], append_images=rest)`
|
|||
— passing the images rather than one image and a size list is what keeps the
|
||||
committed pixels instead of resampling them.
|
||||
|
||||
## Install (AppImage)
|
||||
|
||||
For anything that is not Debian or Ubuntu. Download
|
||||
`quicksearch-<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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
299
packaging/build-appimage.sh
Normal 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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ---------------------------------------------------
|
||||
|
|
|
|||
70
packaging/com.karsttech.quicksearch.metainfo.xml
Normal file
70
packaging/com.karsttech.quicksearch.metainfo.xml
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue