mirror of
https://github.com/storytold/craft-launcher.git
synced 2026-10-13 23:08:16 +00:00
Add a details drawer for each Craft App (#60)
* Add a hand-written details page for each Craft App
core::details holds, for every app in the suite, a headline, a few
paragraphs, a list of features, where it runs and the list of its
pictures. The copy is the launcher's own: written from each app's page on
getartcraft.com and, for the five newer apps, from its README. It is
compiled in, so it works offline and in the web build, and it leaves out
figures that go stale (effect counts, timings).
The pictures are not in this repository. A page names them (a key and a
caption each) and AppDetails::screenshot_url turns a key into the address
of a 1200 px JPEG in storytold/artcraft-launcher-assets, pinned to a tag
(SCREENSHOTS_REV) so a saved copy never goes stale. It only answers for
keys the page lists, so no address is ever built from anyone else's text.
MAX_SCREENSHOT_BYTES caps what is accepted, and looks_like_jpeg refuses a
login page or error page that arrives as a 200.
PdfCraft lists none yet: its screenshots still show its old name
(PrintCraft). ATTRIBUTION.md points to the pictures repository, which
carries each picture's own provenance.
Tests check that every app has exactly one page, that each is filled in,
and that addresses come only from a page's own pictures.
* Open an app's details in a drawer from its card
Clicking a card (or an app's name in the table) slides a drawer in from
the right over the dimmed grid. It shows the app's headline, version and
main button, links to its website, GitHub and releases, a gallery of
screenshots, its features, the facts and notes of its latest release, and
the same trademark notice as the grid. Esc, the close button or a click
on the grid closes it. The page's text is the hand-written copy in
core::details; the version, date, download size and notes come from the
release the launcher already fetches.
The pictures are fetched when a page is opened, not bundled. The UI sends
Command::FetchScreenshots and gets one Event::Screenshot per picture, the
lead one first. The desktop backend downloads them through the launcher's
Http (its User-Agent, a 2 MB cap, redirects followed) and keeps them under
<cache>/screenshots/<revision>/<app>/<key>.jpg, so later opens, and any
time offline, read from disk. Only a JPEG is kept: a login page or error
page that arrives as a 200 is refused. A missing picture doesn't hold the
others back, but once the network has failed the rest fail at once instead
of each waiting for its own timeout. The web build does the same with
fetch and leaves the caching to the browser.
Until a picture is here, a frame of the same size says "Loading pictures…"
or, when it failed, that it couldn't be loaded and that reopening the page
tries again; nothing moves when the picture arrives. The lead picture
opens full-window, the arrows over the pictures and the arrow keys flip
through them, wrapping around, and a scroll bar stays visible (egui's
floating one only shows under the pointer).
The drawer's labels are tl! strings, with 13 rows in each of ru, zh-CN,
de, fr and pt-BR; details.rs joins the sources the catalog coverage test
reads. Every size taken from the window is kept non-negative: the new
tests resize it from 40 x 30 to 4000 x 2400 with the drawer open.
actions() becomes actions_in(), which can leave the links out of the
... menu where the page already shows them as buttons. The image crate's
jpeg feature is enabled to decode the pictures (zune-jpeg and zune-core
join Cargo.lock). The dim and the enlarged picture's backdrop are colours
in theme.rs.
* Bring the details drawer up to date with main
- Website is always a button now (#198); drop the "Like {name}" line and the trademark notice,
which main removed with the product comparisons (93caf01).
- Bring back widgets::eyebrow for the drawer's section headings (#187 dropped its last caller).
- The drawer's ⋯ menu leaves out only the links, so Hide/Unhide (#185) stays.
- No slide when the OS asks for reduced motion (#201).
- Translate the screen readers' "{name} details" label, and add the drawer's strings to the
Spanish, Czech, Polish, Hungarian and Japanese catalogs.
- AGENTS.md: name the pictures' download origin.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
1 parent
0e87e90aef
commit
cb82e851cd
31 files changed
+1864
-33
No files matched your search
@@ -62,6 +62,9 @@ settings file must produce an error the user can act on, never a panic. Shared s
|
||||
`cargo xtask local`); keep it loopback-only and behind the `local-mirror` cargo feature, which
|
||||
release builds never enable.
|
||||
- Verify each download against the release's `SHA256SUMS.txt` before running or installing it.
|
||||
- The details drawer's pictures are the one other download: JPEGs only, from a pinned tag of
|
||||
`https://raw.githubusercontent.com/storytold/artcraft-launcher-assets/` (`core::details`),
|
||||
capped at 2 MB each, cached, and never run. Pictures are not bundled in this repo.
|
||||
- Never run an installer with elevated rights silently; tell the user what is about to happen.
|
||||
- Never delete a user's files outside what the launcher itself installed.
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ next to them.
|
||||
| `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. |
|
||||
| `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` | X, YouTube, TikTok and Instagram logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance and Meta Platforms, Inc. |
|
||||
| `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) |
|
||||
| (not bundled) the pictures on each app's details page | Screenshots of the Craft Apps, fetched when a page is opened and kept in the launcher's cache folder | The apps' contributors | [storytold/artcraft-launcher-assets](https://github.com/storytold/artcraft-launcher-assets), pinned to a tag (`SCREENSHOTS_REV` in `crates/core/src/details.rs`); each picture's source and license are listed in that repository's `ATTRIBUTION.md` | Each app's own license for the interface (MIT OR Apache-2.0); the work shown in them is public domain or made in the app, as that file says |
|
||||
| `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `docs/brand/artcraft-mark-64.png`, `artcraft-mark-black-44.png` | ArtCraft "A" mark, the tray / menu-bar icon | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` and `artcraft-mark-black.svg` by `packaging/icons.sh` | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/inter> (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
|
||||
Generated
+2
@@ -2285,6 +2285,8 @@ dependencies = [
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{self, Release};
|
||||
use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version};
|
||||
@@ -132,11 +133,43 @@ struct WebBackend {
|
||||
platform: Option<Platform>,
|
||||
checking: Rc<RefCell<bool>>,
|
||||
prereleases: bool,
|
||||
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
|
||||
pictures_busy: Rc<RefCell<std::collections::HashSet<&'static str>>>,
|
||||
}
|
||||
|
||||
impl WebBackend {
|
||||
fn new(ctx: egui::Context) -> Self {
|
||||
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false }
|
||||
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false, pictures_busy: Rc::default() }
|
||||
}
|
||||
|
||||
/// Fetches the pictures of an app's details page, lead first. The browser keeps them in its own
|
||||
/// cache, so there is nothing to save here.
|
||||
fn fetch_screenshots(&self, app: &str) {
|
||||
let Some(details) = AppDetails::by_id(app) else { return };
|
||||
if !self.pictures_busy.borrow_mut().insert(details.id) {
|
||||
return;
|
||||
}
|
||||
let (events, ctx, busy) = (self.events.clone(), self.ctx.clone(), self.pictures_busy.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Once the network has failed the rest would fail the same way: say so at once.
|
||||
let mut offline: Option<String> = None;
|
||||
for shot in details.shots {
|
||||
let result = match (&offline, details.screenshot_url(shot.key)) {
|
||||
(Some(why), _) => Err(why.clone()),
|
||||
(None, Some(url)) => picture(&url).await.map_err(|failure| match failure {
|
||||
PictureError::Network(why) => {
|
||||
offline = Some(why.clone());
|
||||
why
|
||||
}
|
||||
PictureError::Picture(why) => why,
|
||||
}),
|
||||
(None, None) => Err("this page has no such picture".to_string()),
|
||||
};
|
||||
events.borrow_mut().push(Event::Screenshot { app: details.id.to_string(), key: shot.key.to_string(), result });
|
||||
ctx.request_repaint();
|
||||
}
|
||||
busy.borrow_mut().remove(details.id);
|
||||
});
|
||||
}
|
||||
|
||||
fn check(&self) {
|
||||
@@ -175,6 +208,49 @@ impl WebBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a picture couldn't be had.
|
||||
enum PictureError {
|
||||
/// This picture only: it isn't published, or what came back isn't a picture.
|
||||
Picture(String),
|
||||
/// The network or the server: the other pictures would fail the same way.
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// One picture of a details page. The size cap is checked on the announced length first and again
|
||||
/// on what arrived; the bytes must start like a JPEG.
|
||||
async fn picture(url: &str) -> Result<Vec<u8>, PictureError> {
|
||||
let offline = |what: &str| PictureError::Network(format!("couldn't load a picture ({what})"));
|
||||
let window = web_sys::window().ok_or_else(|| offline("no window"))?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method("GET");
|
||||
init.set_mode(web_sys::RequestMode::Cors);
|
||||
let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|_| offline("bad request"))?;
|
||||
let resp: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|_| offline("no connection"))?
|
||||
.dyn_into()
|
||||
.map_err(|_| offline("no answer"))?;
|
||||
match resp.status() {
|
||||
200 => {}
|
||||
404 => return Err(PictureError::Picture("this picture isn't published".into())),
|
||||
status => return Err(PictureError::Network(format!("the server answered HTTP {status}"))),
|
||||
}
|
||||
let too_big = || PictureError::Picture(format!("a picture of more than {MAX_SCREENSHOT_BYTES} bytes"));
|
||||
let announced = resp.headers().get("content-length").ok().flatten().and_then(|n| n.parse::<usize>().ok());
|
||||
if announced.is_some_and(|n| n > MAX_SCREENSHOT_BYTES) {
|
||||
return Err(too_big());
|
||||
}
|
||||
let buffer = JsFuture::from(resp.array_buffer().map_err(|_| offline("unreadable"))?).await.map_err(|_| offline("unreadable"))?;
|
||||
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
|
||||
if bytes.len() > MAX_SCREENSHOT_BYTES {
|
||||
return Err(too_big());
|
||||
}
|
||||
if !looks_like_jpeg(&bytes) {
|
||||
return Err(PictureError::Picture("the answer isn't a JPEG picture".into()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The latest release; `Err(Some(reset))` when rate-limited, `Err(None)` on other failures.
|
||||
async fn latest(repo: &str, prereleases: bool) -> Result<LatestRelease, Option<i64>> {
|
||||
let url = if prereleases {
|
||||
@@ -236,6 +312,7 @@ impl Backend for WebBackend {
|
||||
fn send(&mut self, command: Command) {
|
||||
match command {
|
||||
Command::CheckForUpdates { .. } => self.check(),
|
||||
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
|
||||
Command::Configure(o) => {
|
||||
let changed = o.prereleases != self.prereleases;
|
||||
self.prereleases = o.prereleases;
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Hand-written product pages: what each app is, in the launcher's own words.
|
||||
//!
|
||||
//! The text is compiled in, so it works offline and on the web, and nothing from the network is
|
||||
//! ever shown as page copy. What changes with every release (version, date, size, notes) comes
|
||||
//! from the GitHub release instead. An app without an entry here simply has no details page.
|
||||
//!
|
||||
//! The pictures are not compiled in: they are fetched when a page is opened (see
|
||||
//! [`AppDetails::screenshot_url`]), so the repository and the binary stay small.
|
||||
|
||||
/// One feature: a short name and a sentence.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Feature {
|
||||
pub title: &'static str,
|
||||
pub text: &'static str,
|
||||
}
|
||||
|
||||
/// A screenshot: `key` names the file to fetch (`<app>/<key>.jpg` in the pictures repository).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Shot {
|
||||
pub key: &'static str,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
|
||||
const fn shot(key: &'static str, caption: &'static str) -> Shot {
|
||||
Shot { key, caption }
|
||||
}
|
||||
|
||||
/// The editorial half of an app's page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AppDetails {
|
||||
/// Matches [`crate::CraftApp::id`].
|
||||
pub id: &'static str,
|
||||
/// One line under the name.
|
||||
pub headline: &'static str,
|
||||
/// One or two paragraphs, separated by a blank line.
|
||||
pub about: &'static str,
|
||||
pub features: &'static [Feature],
|
||||
/// The first is the lead picture. All are 8:5.
|
||||
pub shots: &'static [Shot],
|
||||
/// Where it runs, for display (`"macOS, Windows, Linux, web"`).
|
||||
pub platforms: &'static str,
|
||||
}
|
||||
|
||||
const fn feature(title: &'static str, text: &'static str) -> Feature {
|
||||
Feature { title, text }
|
||||
}
|
||||
|
||||
/// Where the pictures live: one 1200 px wide, 8:5 JPEG per [`Shot`], at `<app>/<key>.jpg`.
|
||||
///
|
||||
/// Pinned to a tag (`SCREENSHOTS_REV`) so that what a launcher fetches never changes under it, and
|
||||
/// so that a saved copy never goes stale. To change a picture, publish a new tag and bump the
|
||||
/// revision: older launchers keep working from the tag they know.
|
||||
pub const SCREENSHOTS_BASE: &str = "https://raw.githubusercontent.com/storytold/artcraft-launcher-assets";
|
||||
|
||||
/// The tag of the pictures repository the launcher reads. Also part of the saved copies' path.
|
||||
pub const SCREENSHOTS_REV: &str = "v1";
|
||||
|
||||
/// The largest picture the launcher accepts. The real ones are 60 to 150 KB.
|
||||
pub const MAX_SCREENSHOT_BYTES: usize = 2 << 20;
|
||||
|
||||
/// True when `bytes` start like a JPEG file. A captive portal's login page, a proxy's error page or
|
||||
/// a truncated answer is refused before it is saved or decoded.
|
||||
pub fn looks_like_jpeg(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(&[0xFF, 0xD8, 0xFF])
|
||||
}
|
||||
|
||||
/// Where every Craft App runs (getartcraft.com lists the same for each of them).
|
||||
const PLATFORMS: &str = "macOS, Windows, Linux, FreeBSD, web";
|
||||
|
||||
pub const DETAILS: [AppDetails; 12] = [
|
||||
AppDetails {
|
||||
id: "photocraft",
|
||||
headline: "The image editor you already know how to use.",
|
||||
about: "Layers, masks, adjustment layers, layer styles, type, vectors and brushes in a native app \
|
||||
written in Rust. Open source, offline, and yours.\n\n\
|
||||
PhotoCraft keeps the menus, shortcuts and panels your hands already know, so there is \
|
||||
nothing to relearn.",
|
||||
features: &[
|
||||
feature("Native GPU canvas", "No Electron and no web view: a fast native compositor."),
|
||||
feature("Real layered files", "Opens and saves layered PSD and PSB documents."),
|
||||
feature("Non-destructive editing", "Adjustment layers, live-preview filters, smart objects and layer styles."),
|
||||
feature("On-device selection", "Subject and object selection and mask refinement run locally."),
|
||||
feature("Serious color", "8-, 16- and 32-bit RGB, CMYK, Lab and Grayscale, with ICC color management and soft proofing."),
|
||||
],
|
||||
shots: &[
|
||||
shot("demo", "Curves and type layers"),
|
||||
shot("adjustments", "Levels with a live histogram"),
|
||||
shot("layer-styles", "Layer styles on live type"),
|
||||
shot("masks", "Selections and masked adjustments"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "vectorcraft",
|
||||
headline: "Vector illustration, reimagined in pure Rust.",
|
||||
about: "A native vector illustration app with the pen, panels and shortcuts illustrators expect. \
|
||||
Open source, and it also runs in a browser.",
|
||||
features: &[
|
||||
feature(
|
||||
"The workflow you know",
|
||||
"Pen, direct selection, shape booleans, snapping guides and an appearance panel, laid out the way you expect.",
|
||||
),
|
||||
feature("Genuinely fast", "Drawings with thousands of shapes stay responsive on a retina display."),
|
||||
feature(
|
||||
"Booleans that don't fail",
|
||||
"Shape booleans work on the exact curves, so there is no \"cannot perform operation\", and undo is unlimited.",
|
||||
),
|
||||
feature("Live, editable effects", "Blends, envelope distort, gradient mesh, repeats, glows and drop shadows all stay editable."),
|
||||
feature("Image tracing", "Turn raster images into clean vector paths, with presets for the common cases."),
|
||||
feature("Open formats", "Native JSON documents, SVG and PDF import and export, and PNG, JPEG and WebP output."),
|
||||
],
|
||||
shots: &[
|
||||
shot("neon", "The Neon Drive poster"),
|
||||
shot("ribbons", "Live blends"),
|
||||
shot("sheet", "A feature sheet and the Layers panel"),
|
||||
shot("bezier", "Pathfinder and exact booleans"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "filmcraft",
|
||||
headline: "Professional video editing, rebuilt from scratch.",
|
||||
about: "A native video editor written in pure Rust, with source and program monitors, a real timeline and \
|
||||
every trim mode.",
|
||||
features: &[
|
||||
feature("A timeline you already know", "Source and program monitors, three-point editing, every trim mode and J/K/L trimming."),
|
||||
feature("Frame-exact time", "An integer timebase keeps 23.976 and 29.97 drop-frame footage exact, with no drift."),
|
||||
feature("Its own codecs", "No FFmpeg inside: native support for H.264, HEVC, ProRes, VP9, AAC and Opus."),
|
||||
feature("Color grading", "Curves, color wheels, HSL secondaries, LUTs and HDR and log workflows, with scopes to check the result."),
|
||||
feature(
|
||||
"Effects and keyframes",
|
||||
"Video effects and transitions, keyframes with value and velocity graphs, and a linear-light GPU compositor.",
|
||||
),
|
||||
feature("Broadcast-ready audio", "EBU R128 loudness meters, a mixer with automation and native audio effects."),
|
||||
],
|
||||
shots: &[
|
||||
shot("color", "The Color workspace, with scopes"),
|
||||
shot("assembly", "The Assembly workspace"),
|
||||
shot("keyframes", "Keyframes in Effect Controls"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "lightcraft",
|
||||
headline: "Your photos. Your pixels. Your machine.",
|
||||
about: "A fast photo library and non-destructive raw developer, written from scratch in pure Rust. \
|
||||
No account, no cloud, no telemetry and no subscription.",
|
||||
features: &[
|
||||
feature("Non-destructive by design", "A wide-gamut, 32-bit float pipeline that never touches your originals."),
|
||||
feature("Every control you reach for", "Light, color, effects, tone curve, a color mixer, color grading wheels and built-in presets."),
|
||||
feature("Precise masking", "Brush, gradient and range masks, plus sky, subject and background masks."),
|
||||
feature("Native raw decoding", "Pure-Rust decoders for the main camera raw formats: DNG, CR2, ARW, NEF, RAF, RW2, PEF and ORF."),
|
||||
feature("A library that keeps up", "Albums, ratings, flags and search, with grids that stay smooth on large catalogs."),
|
||||
feature("Instant feedback", "The GPU pipeline updates the preview as you move a slider, even on large raw files."),
|
||||
],
|
||||
shots: &[
|
||||
shot("hero", "The Edit view"),
|
||||
shot("curve", "The tone curve"),
|
||||
shot("grading", "Color grading wheels"),
|
||||
shot("masking", "A radial mask over a sky mask"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "pdfcraft",
|
||||
headline: "The open-source PDF workbench.",
|
||||
about: "Read, organize, combine, split and secure PDFs in a fast, native app written in Rust from the \
|
||||
ground up. No account, no telemetry and no cloud.",
|
||||
features: &[
|
||||
feature("Faithful rendering", "World scripts, vertical Japanese, color emoji and transparency."),
|
||||
feature("Search as you type", "Instant find, and text selection that follows the document's real reading order."),
|
||||
feature("Organize like cards", "Rotate, delete, insert and reorder pages, with deep undo behind every change."),
|
||||
feature("Combine, extract, split", "Assemble and break apart documents while keeping links, form fields, layers and bookmarks intact."),
|
||||
feature("Fearless saves", "Incremental, atomic saves with autosave and crash recovery."),
|
||||
feature("Built-in security", "Opens files from RC4 to AES-256 encryption and honors the author's permissions."),
|
||||
],
|
||||
shots: &[],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "effectcraft",
|
||||
headline: "Motion graphics and visual effects, in pure Rust.",
|
||||
about: "A native compositor with compositions, layers, keyframes, effects, expressions, 3D cameras and \
|
||||
lights, and a render queue. Young, moving fast, and already usable.",
|
||||
features: &[
|
||||
feature(
|
||||
"Animate the way you know",
|
||||
"Familiar panels and keyframes (linear, bezier, hold and eased), with a graph editor for value and speed curves.",
|
||||
),
|
||||
feature(
|
||||
"Layers of every kind",
|
||||
"Solids, shapes, text, footage, nested compositions, nulls and adjustment layers, with parenting, track mattes and blend modes.",
|
||||
),
|
||||
feature(
|
||||
"A large effects library",
|
||||
"Blur, color correction, distortion, generators, keying, particles and simulation, stylize and time effects.",
|
||||
),
|
||||
feature("Real 3D", "3D layers with cameras, depth of field, and lights that cast soft ray-traced shadows."),
|
||||
feature("Expressions", "JavaScript expressions with wiggle, loops and layer references, edited inline."),
|
||||
feature("Export anywhere", "H.264 and ProRes with alpha, PNG, TIFF and EXR sequences, GIF, and Lottie import and export."),
|
||||
],
|
||||
shots: &[
|
||||
shot("hero", "An animated composition"),
|
||||
shot("graph", "The Graph Editor"),
|
||||
shot("effects", "Particles and Effect Controls"),
|
||||
shot("3d", "3D layers with cameras and lights"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "designcraft",
|
||||
headline: "Page layout and publishing, rebuilt in pure Rust.",
|
||||
about: "A native layout app with spreads, parent pages, threaded stories, styles, swatches, text wrap and \
|
||||
a paragraph composer. No subscription, no license server and no telemetry.",
|
||||
features: &[
|
||||
feature("The layout tools you know", "Spreads and parent pages, frames and threaded stories, with paragraph and character styles."),
|
||||
feature(
|
||||
"Beautiful type",
|
||||
"A Knuth–Plass paragraph composer, dictionary hyphenation, optical margin alignment and baseline grids, with line breaks that match on screen and in PDF.",
|
||||
),
|
||||
feature("Print-ready PDF", "Real selectable text with embedded fonts, CMYK and spot colors, bleed, crop marks and PDF/A output."),
|
||||
feature("Fast", "Multithreaded rendering, instant undo and cached text composition."),
|
||||
feature("Open formats", "A documented native format, PNG export and layout interchange import and export."),
|
||||
feature("Built for automation", "Every menu item, tool, panel and dialog can be driven over a JSON control channel and an MCP server."),
|
||||
],
|
||||
shots: &[shot("spread", "A magazine spread with a threaded story")],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "wordcraft",
|
||||
headline: "The familiar word processor, native and offline.",
|
||||
about: "A word processor with the ribbon, shortcuts and behavior you already know, written in Rust. \
|
||||
Open source, and built from public specifications.",
|
||||
features: &[
|
||||
feature(
|
||||
"Familiar",
|
||||
"Ribbon tabs and groups, a live Styles gallery and the editing behavior you expect, from list continuation to Format Painter.",
|
||||
),
|
||||
feature(
|
||||
"Your files",
|
||||
"Opens and saves .docx, and also .odt, .rtf, .html, .md, .tex and .txt. Opens older .doc files and exports PDF with real, selectable text.",
|
||||
),
|
||||
feature("Fast", "Paragraph layout is cached, so typing stays responsive even in long documents."),
|
||||
feature("Private", "Spelling, grammar and everything else work offline."),
|
||||
feature("Everywhere", "One Rust codebase for macOS, Windows, Linux, BSD and the web."),
|
||||
feature(
|
||||
"Built for agents",
|
||||
"Every action is a command, driven from the ribbon, shortcuts, a command-line tool, a JSON control channel or an MCP server.",
|
||||
),
|
||||
],
|
||||
shots: &[
|
||||
shot("hero", "The Home tab and the Navigation pane"),
|
||||
shot("review", "Track Changes and comments"),
|
||||
shot("design", "The Design tab"),
|
||||
shot("layout", "The Layout tab"),
|
||||
],
|
||||
platforms: "macOS, Windows, Linux, BSD, web",
|
||||
},
|
||||
AppDetails {
|
||||
id: "deckcraft",
|
||||
headline: "Presentations, rebuilt in Rust.",
|
||||
about: "A presentation app with slide masters, shapes, animations and a presenter view, written in Rust. \
|
||||
In alpha, heading for beta.",
|
||||
features: &[
|
||||
feature("Make decks", "Slide masters and layouts with placeholders that inherit formatting, themes, sections, notes and comments."),
|
||||
feature(
|
||||
"Draw",
|
||||
"Preset shapes with adjust handles, connectors that stay glued to shapes, freeform tools, merged shapes, gradients, shadows and glow.",
|
||||
),
|
||||
feature("Write", "In-place text editing with full font and paragraph formatting, bullets and numbering, columns and autofit."),
|
||||
feature("Add content", "Pictures with crop and corrections, styled tables, charts, audio and video."),
|
||||
feature(
|
||||
"Present",
|
||||
"Transitions including Morph, animations and motion paths, a full-screen slide show, presenter view and rehearsed timings.",
|
||||
),
|
||||
feature("Exchange files", "Opens and saves PowerPoint .pptx and exports PDF (slides, notes pages and handouts), PNG and JPEG."),
|
||||
],
|
||||
shots: &[
|
||||
shot("editor", "Editing a slide, with sections and notes"),
|
||||
shot("shape", "The Shape Format tab"),
|
||||
shot("sorter", "The slide sorter"),
|
||||
shot("format", "The Format Shape pane"),
|
||||
],
|
||||
platforms: "macOS, Windows, Linux, BSD, web",
|
||||
},
|
||||
AppDetails {
|
||||
id: "gridcraft",
|
||||
headline: "Spreadsheets you already know how to use.",
|
||||
about: "A spreadsheet with the ribbon, formula bar, sheet tabs and shortcuts you know, on a calculation engine \
|
||||
written in Rust. No subscription, no license server and no telemetry.",
|
||||
features: &[
|
||||
feature(
|
||||
"Familiar",
|
||||
"The ribbon, formula bar, Name Box, fill handle, point-mode formulas, Format Cells, Paste Special, conditional formatting, tables and charts.",
|
||||
),
|
||||
feature(
|
||||
"Compatible",
|
||||
"XLSX is the native format, with formulas, tables, conditional formats, charts, pivot tables and print settings round-tripping. CSV and TSV too.",
|
||||
),
|
||||
feature(
|
||||
"Capable",
|
||||
"A dependency-graph calculation engine with dynamic arrays, LET and LAMBDA, structured table references, sort, AutoFilter and Flash Fill.",
|
||||
),
|
||||
feature("Fast", "Copy-on-write workbooks, incremental recalculation and a grid that only lays out what is on screen."),
|
||||
feature(
|
||||
"Agent-native",
|
||||
"Every ribbon button, menu item, dialog and keystroke is a command, available over a JSON control channel, an MCP server or the CLI.",
|
||||
),
|
||||
feature("Everywhere", "One Rust codebase for the desktop and the web."),
|
||||
],
|
||||
shots: &[
|
||||
shot("hero", "A quarterly sales dashboard"),
|
||||
shot("budget", "A household budget"),
|
||||
shot("pivot", "A pivot table"),
|
||||
shot("chart", "Formatting a chart"),
|
||||
],
|
||||
platforms: "macOS, Windows, Linux, BSD, web",
|
||||
},
|
||||
AppDetails {
|
||||
id: "cadcraft",
|
||||
headline: "Computer-aided drafting, in pure Rust.",
|
||||
about: "A drafting app with the command line, object snaps and workflow that drafters already know. Free, with no \
|
||||
account and no subscription. In early, fast development.",
|
||||
features: &[
|
||||
feature(
|
||||
"The workflow you know",
|
||||
"Type L, click two points, type @5<45 and press Enter. Prompts with clickable keywords, polar tracking, direct distance entry and grips work the way drafting habit expects.",
|
||||
),
|
||||
feature(
|
||||
"Open files",
|
||||
"Reads and writes DXF natively (R12 through 2018) and opens and saves DWG (R13 through 2018) with the open-source acadrust library. Exports SVG and PNG.",
|
||||
),
|
||||
feature("Fast and native", "Pure Rust and egui, with no Electron and no web view."),
|
||||
feature(
|
||||
"A full drafting toolset",
|
||||
"Draw, modify, dimension, hatch and block commands, layers with a properties manager, paper-space layouts and PDF plotting.",
|
||||
),
|
||||
feature("Built for agents", "Every menu item, tool and prompt is a command, available over MCP, a JSON control channel or the CLI."),
|
||||
],
|
||||
shots: &[shot("apartment", "An apartment floor plan"), shot("bracket", "A mechanical bracket drawing"), shot("layout", "A layout sheet")],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
AppDetails {
|
||||
id: "soundcraft",
|
||||
headline: "Recording, editing and mixing, in Rust.",
|
||||
about: "A digital audio workstation that follows the workflow audio engineers already know: an Edit window for \
|
||||
arranging and editing, and a Mix window for balancing. Pre-alpha.",
|
||||
features: &[
|
||||
feature(
|
||||
"Editing",
|
||||
"Shuffle, Slip, Spot and Grid modes, Smart, Grabber, Trim and other tools, fades and crossfades, playlists with comping lanes and clip gain.",
|
||||
),
|
||||
feature(
|
||||
"Mixing",
|
||||
"Faders and pans with automation, sends, busses, aux inputs, VCA masters, routing folders and automatic plugin delay compensation.",
|
||||
),
|
||||
feature(
|
||||
"Automation",
|
||||
"Volume, pan, mute, send and every plugin parameter, with Write, Touch, Latch and Trim passes recorded live from the faders.",
|
||||
),
|
||||
feature(
|
||||
"Plugins",
|
||||
"EQ, dynamics, reverbs, modulation effects, a subtractive synth and a drum synth, all original, and CLAP plugins load too.",
|
||||
),
|
||||
],
|
||||
shots: &[
|
||||
shot("edit", "The Edit window with a nine-track session"),
|
||||
shot("mix", "The Mix window"),
|
||||
shot("automation", "Automation and memory locations"),
|
||||
shot("score", "The Score Editor"),
|
||||
],
|
||||
platforms: PLATFORMS,
|
||||
},
|
||||
];
|
||||
|
||||
impl AppDetails {
|
||||
/// Where the picture `key` of this app is published, or `None` if the page has no such picture.
|
||||
/// The only way to turn a request into an address: callers never build one from their own text.
|
||||
pub fn screenshot_url(&self, key: &str) -> Option<String> {
|
||||
self.shots.iter().find(|s| s.key == key).map(|s| format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/{}/{}.jpg", self.id, s.key))
|
||||
}
|
||||
|
||||
pub fn by_id(id: &str) -> Option<&'static AppDetails> {
|
||||
DETAILS.iter().find(|d| d.id == id)
|
||||
}
|
||||
|
||||
pub fn paragraphs(&self) -> impl Iterator<Item = &'static str> {
|
||||
self.about.split("\n\n").map(str::trim).filter(|p| !p.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CraftApp, SUITE};
|
||||
|
||||
#[test]
|
||||
fn every_page_belongs_to_a_suite_app_once() {
|
||||
let mut ids: Vec<_> = DETAILS.iter().map(|d| d.id).collect();
|
||||
for id in &ids {
|
||||
assert!(CraftApp::by_id(id).is_some(), "{id}");
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), DETAILS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_app_has_a_page() {
|
||||
for a in &SUITE {
|
||||
assert!(AppDetails::by_id(a.id).is_some(), "{}", a.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_addresses_come_only_from_the_pages_own_pictures() {
|
||||
let page = AppDetails::by_id("photocraft").unwrap();
|
||||
assert_eq!(
|
||||
page.screenshot_url("demo").as_deref(),
|
||||
Some("https://raw.githubusercontent.com/storytold/artcraft-launcher-assets/v1/photocraft/demo.jpg")
|
||||
);
|
||||
for key in ["", "nope", "../vectorcraft/neon", "demo/../masks", "demo.jpg", "/etc/passwd", "demo?x=1", "demo#"] {
|
||||
assert_eq!(page.screenshot_url(key), None, "{key:?}");
|
||||
}
|
||||
// Every picture of every page has an address of the one shape, with nothing to escape.
|
||||
for d in &DETAILS {
|
||||
for s in d.shots {
|
||||
let url = d.screenshot_url(s.key).unwrap();
|
||||
let path = url.strip_prefix(&format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/")).unwrap();
|
||||
assert_eq!(path, format!("{}/{}.jpg", d.id, s.key));
|
||||
assert!(path.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '/' | '-' | '.')), "{path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_jpeg_files_are_accepted() {
|
||||
assert!(looks_like_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]));
|
||||
assert!(!looks_like_jpeg(b""));
|
||||
assert!(!looks_like_jpeg(&[0xFF, 0xD8]));
|
||||
assert!(!looks_like_jpeg(b"<!DOCTYPE html>"));
|
||||
// A PNG's signature.
|
||||
assert!(!looks_like_jpeg(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_are_filled_in() {
|
||||
for d in &DETAILS {
|
||||
assert!(!d.headline.is_empty() && !d.features.is_empty() && d.paragraphs().next().is_some(), "{}", d.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod catalog;
|
||||
pub mod details;
|
||||
pub mod distro;
|
||||
pub mod feed;
|
||||
pub mod platform;
|
||||
|
||||
@@ -43,6 +43,12 @@ pub enum Command {
|
||||
},
|
||||
/// Download the newer ArtCraft Launcher, put it in place and restart into it.
|
||||
UpdateLauncher,
|
||||
/// Fetch the pictures of an app's details page (`core::details`), one [`Event::Screenshot`]
|
||||
/// each, the lead picture first. Pictures already saved on this machine arrive without
|
||||
/// touching the network. Asking again while they are on their way does nothing.
|
||||
FetchScreenshots {
|
||||
app: String,
|
||||
},
|
||||
/// Change how the backend behaves.
|
||||
Configure(BackendOptions),
|
||||
}
|
||||
@@ -187,6 +193,13 @@ pub enum Event {
|
||||
app: String,
|
||||
result: Result<String, String>,
|
||||
},
|
||||
/// One picture of an app's details page, or why it couldn't be had (offline, not published,
|
||||
/// not a JPEG). `key` is the [`crate::details::Shot::key`].
|
||||
Screenshot {
|
||||
app: String,
|
||||
key: String,
|
||||
result: Result<Vec<u8>, String>,
|
||||
},
|
||||
CheckStarted,
|
||||
CheckFinished {
|
||||
/// Unix seconds.
|
||||
|
||||
@@ -77,6 +77,8 @@ struct Shared {
|
||||
feed_ok_at: Mutex<Option<i64>>,
|
||||
/// How often ArtCraft's API may be called.
|
||||
feed_throttle: Mutex<FeedThrottle>,
|
||||
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
|
||||
pictures_busy: Mutex<std::collections::HashSet<&'static str>>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -131,6 +133,7 @@ impl NativeBackend {
|
||||
feed_failures: Mutex::new(0),
|
||||
feed_ok_at: Mutex::new(None),
|
||||
feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
|
||||
pictures_busy: Mutex::new(std::collections::HashSet::new()),
|
||||
last_check: Mutex::new(None),
|
||||
});
|
||||
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
|
||||
@@ -858,6 +861,36 @@ fn native_platform() -> Option<Platform> {
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Clears an app's "pictures on their way" mark when the thread fetching them ends, panic or not.
|
||||
struct PicturesBusy {
|
||||
shared: Arc<Shared>,
|
||||
app: &'static str,
|
||||
}
|
||||
|
||||
impl Drop for PicturesBusy {
|
||||
fn drop(&mut self) {
|
||||
self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).remove(self.app);
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeBackend {
|
||||
/// Starts fetching the pictures of an app's details page, unless they are already on their way.
|
||||
/// An app without a page, or a name that isn't an app, is ignored.
|
||||
fn fetch_screenshots(&self, app: &str) {
|
||||
let Some(details) = artcraft_launcher_core::details::AppDetails::by_id(app) else { return };
|
||||
if !self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).insert(details.id) {
|
||||
return;
|
||||
}
|
||||
let (shared, http, paths) = (self.shared.clone(), self.http.clone(), self.paths.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _busy = PicturesBusy { shared: shared.clone(), app: details.id };
|
||||
crate::screenshots::fetch_all(http.as_ref(), &paths, details, |key, result| {
|
||||
shared.emit(Event::Screenshot { app: details.id.to_string(), key: key.to_string(), result });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for NativeBackend {
|
||||
fn send(&mut self, command: Command) {
|
||||
match command {
|
||||
@@ -906,6 +939,7 @@ impl Backend for NativeBackend {
|
||||
}
|
||||
}
|
||||
Command::UpdateLauncher => self.update_launcher(),
|
||||
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
|
||||
Command::Configure(options) => {
|
||||
let changed_pre = {
|
||||
let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
@@ -1006,6 +1040,7 @@ mod tests {
|
||||
feed_failures: Mutex::new(0),
|
||||
feed_ok_at: Mutex::new(None),
|
||||
feed_throttle: Mutex::new(FeedThrottle::new()),
|
||||
pictures_busy: Mutex::new(std::collections::HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,27 @@ impl Http {
|
||||
}
|
||||
}
|
||||
|
||||
/// GET a small binary file (following redirects). `Ok(None)` for a 404. A file of more than
|
||||
/// `max` bytes is refused: by its announced length before a byte is read, and by a hard limit
|
||||
/// on the body when the length wasn't announced or was wrong.
|
||||
pub fn get_bytes(&self, url: &str, max: usize) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut resp = self.small.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
|
||||
match resp.status().as_u16() {
|
||||
200 => {
|
||||
let announced = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
|
||||
let too_big = || format!("{} sent more than {max} bytes", host(url));
|
||||
if announced.is_some_and(|n| n > max as u64) {
|
||||
return Err(too_big());
|
||||
}
|
||||
// ureq errors once the limit is reached, so ask for one byte more than we accept.
|
||||
let body = resp.body_mut().with_config().limit(max as u64 + 1).read_to_vec().map_err(|e| format!("unreadable answer ({e})"))?;
|
||||
if body.len() > max { Err(too_big()) } else { Ok(Some(body)) }
|
||||
}
|
||||
404 => Ok(None),
|
||||
s => Err(format!("{} answered HTTP {s}", host(url))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `url` redirects to, without following it.
|
||||
pub fn redirect_target(&self, url: &str) -> Result<Option<String>, String> {
|
||||
let resp = self.no_redirect.head(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
|
||||
@@ -681,6 +702,25 @@ pub(crate) mod tests {
|
||||
(result, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_binary_files_are_read_whole_and_a_404_is_none() {
|
||||
let (body, _) = body();
|
||||
let http = Http::new().unwrap();
|
||||
let (url, _) = serve(body.clone(), vec![Reply::Full]);
|
||||
assert_eq!(http.get_bytes(&url, body.len()).unwrap(), Some(body.clone()), "a body of exactly the cap is fine");
|
||||
let (url, _) = serve(body, vec![Reply::Missing]);
|
||||
assert_eq!(http.get_bytes(&url, 1 << 20).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_over_the_cap_is_refused() {
|
||||
let (body, _) = body();
|
||||
let http = Http::new().unwrap();
|
||||
let (url, _) = serve(body.clone(), vec![Reply::Full]);
|
||||
let err = http.get_bytes(&url, body.len() - 1).unwrap_err();
|
||||
assert!(err.contains("more than"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_body_of_exactly_the_published_size_succeeds() {
|
||||
// The bug this guards: a reader limit equal to the size failed on the final read.
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod paths;
|
||||
pub mod process;
|
||||
pub mod proxy;
|
||||
pub mod queue;
|
||||
pub mod screenshots;
|
||||
pub mod self_update;
|
||||
pub mod single_instance;
|
||||
pub mod source;
|
||||
|
||||
@@ -46,6 +46,12 @@ impl Paths {
|
||||
self.cache.join("feed-throttle.json")
|
||||
}
|
||||
|
||||
/// A picture of an app's details page, saved after its first download: under the revision of the
|
||||
/// pictures repository it was fetched from, so a new revision never reads an old picture.
|
||||
pub fn screenshot(&self, app: &str, key: &str) -> PathBuf {
|
||||
self.cache.join("screenshots").join(artcraft_launcher_core::details::SCREENSHOTS_REV).join(app).join(format!("{key}.jpg"))
|
||||
}
|
||||
|
||||
pub fn manifest(&self) -> PathBuf {
|
||||
self.data.join("installed.json")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! The pictures on an app's details page. They are not part of the launcher: opening a page
|
||||
//! fetches them once and keeps them in the cache folder, so the next time (and any time offline)
|
||||
//! they come from disk.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
|
||||
|
||||
use crate::http::Http;
|
||||
use crate::paths::{Paths, write_atomic};
|
||||
|
||||
/// The picture saved at `saved`, if there is one and it's still a JPEG of a sane size. A damaged or
|
||||
/// swapped file is ignored, and the picture is fetched again over it.
|
||||
fn saved_copy(saved: &Path) -> Option<Vec<u8>> {
|
||||
if std::fs::metadata(saved).ok()?.len() > MAX_SCREENSHOT_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(saved).ok().filter(|bytes| looks_like_jpeg(bytes))
|
||||
}
|
||||
|
||||
/// Why a picture couldn't be had.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Failure {
|
||||
/// This picture only: it isn't published, or what came back isn't a picture.
|
||||
Picture(String),
|
||||
/// The network or the server: the other pictures would fail the same way.
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// Downloads the picture at `url` and saves it at `saved`. A saved file that can't be written is
|
||||
/// only a missed cache: the picture is still shown.
|
||||
fn download(http: &Http, url: &str, saved: &Path) -> Result<Vec<u8>, Failure> {
|
||||
let bytes = match http.get_bytes(url, MAX_SCREENSHOT_BYTES) {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
Ok(None) => return Err(Failure::Picture("this picture isn't published".into())),
|
||||
Err(why) => return Err(Failure::Network(why)),
|
||||
};
|
||||
// A captive portal's login page or a proxy's error page arrives as a 200: don't keep it.
|
||||
if !looks_like_jpeg(&bytes) {
|
||||
return Err(Failure::Picture("the answer isn't a JPEG picture".into()));
|
||||
}
|
||||
if let Err(e) = write_atomic(saved, &bytes) {
|
||||
log::warn!("couldn't keep {}: {e}", saved.display());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Every picture of `details`, lead first, handed to `emit` one by one as it is had. Saved copies
|
||||
/// need no network. Once the network or the server has failed, the pictures still to fetch fail at
|
||||
/// once with the same reason instead of each waiting for its own timeout; saved ones are still
|
||||
/// served. A picture that is merely missing doesn't hold the others back.
|
||||
pub fn fetch_all(http: Option<&Http>, paths: &Paths, details: &AppDetails, mut emit: impl FnMut(&'static str, Result<Vec<u8>, String>)) {
|
||||
let mut failed: Option<String> = None;
|
||||
for shot in details.shots {
|
||||
let saved = paths.screenshot(details.id, shot.key);
|
||||
if let Some(bytes) = saved_copy(&saved) {
|
||||
emit(shot.key, Ok(bytes));
|
||||
continue;
|
||||
}
|
||||
if let Some(why) = &failed {
|
||||
emit(shot.key, Err(why.clone()));
|
||||
continue;
|
||||
}
|
||||
let result = match (http, details.screenshot_url(shot.key)) {
|
||||
(Some(http), Some(url)) => download(http, &url, &saved),
|
||||
(None, _) => Err(Failure::Network("HTTPS isn't available".into())),
|
||||
(_, None) => Err(Failure::Picture("this page has no such picture".into())),
|
||||
};
|
||||
let result = result.map_err(|failure| {
|
||||
let why = match failure {
|
||||
Failure::Network(why) => {
|
||||
failed = Some(why.clone());
|
||||
why
|
||||
}
|
||||
Failure::Picture(why) => why,
|
||||
};
|
||||
log::warn!("picture {}/{} unavailable: {why}", details.id, shot.key);
|
||||
why
|
||||
});
|
||||
emit(shot.key, result);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::tests::{Reply, serve};
|
||||
|
||||
/// A body that starts like a JPEG, and its address on the test server.
|
||||
fn jpeg() -> Vec<u8> {
|
||||
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
|
||||
bytes.extend((0..5_000u32).map(|i| (i % 251) as u8));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_downloaded_picture_is_saved_where_it_will_be_read_from() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = dir.path().join("x").join("pic.jpg");
|
||||
let http = Http::new().unwrap();
|
||||
let (url, _) = serve(jpeg(), vec![Reply::Full]);
|
||||
assert_eq!(download(&http, &url, &saved).unwrap(), jpeg());
|
||||
assert_eq!(saved_copy(&saved), Some(jpeg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_picture_is_not_kept() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = dir.path().join("pic.jpg");
|
||||
let http = Http::new().unwrap();
|
||||
let (url, _) = serve(b"<html>Sign in to the Wi-Fi</html>".to_vec(), vec![Reply::Full]);
|
||||
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("the answer isn't a JPEG picture".into())));
|
||||
assert!(!saved.exists());
|
||||
let (url, _) = serve(Vec::new(), vec![Reply::Missing]);
|
||||
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("this picture isn't published".into())));
|
||||
assert!(!saved.exists());
|
||||
// A server that can't be reached is the network's failure, not this picture's.
|
||||
let gone = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
|
||||
assert!(matches!(download(&http, &format!("http://{gone}/pic.jpg"), &saved), Err(Failure::Network(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_saved_copy_is_ignored() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let saved = dir.path().join("pic.jpg");
|
||||
assert_eq!(saved_copy(&saved), None, "nothing saved");
|
||||
std::fs::write(&saved, b"<html>oops</html>").unwrap();
|
||||
assert_eq!(saved_copy(&saved), None, "not a JPEG");
|
||||
std::fs::write(&saved, vec![0xFF; MAX_SCREENSHOT_BYTES + 1]).unwrap();
|
||||
assert_eq!(saved_copy(&saved), None, "too big");
|
||||
std::fs::write(&saved, jpeg()).unwrap();
|
||||
assert_eq!(saved_copy(&saved), Some(jpeg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_pictures_need_no_network_and_a_failure_ends_the_run() {
|
||||
let paths = Paths::under(tempfile::tempdir().unwrap().path());
|
||||
let page = AppDetails::by_id("photocraft").unwrap();
|
||||
// The second picture is saved already; the first and the others need the network, which is not there.
|
||||
let second = paths.screenshot(page.id, page.shots[1].key);
|
||||
write_atomic(&second, &jpeg()).unwrap();
|
||||
let mut seen = Vec::new();
|
||||
fetch_all(None, &paths, page, |key, result| seen.push((key, result)));
|
||||
let keys: Vec<_> = seen.iter().map(|(k, _)| *k).collect();
|
||||
assert_eq!(keys, page.shots.iter().map(|s| s.key).collect::<Vec<_>>(), "every picture is answered, in order");
|
||||
assert!(seen[0].1.is_err() && seen[2].1.is_err() && seen[3].1.is_err());
|
||||
assert_eq!(seen[1].1.as_ref().unwrap(), &jpeg());
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ workspace = true
|
||||
artcraft-launcher-core = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { workspace = true, features = ["image", "svg"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
serde = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -27,4 +27,4 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
@@ -110,6 +110,16 @@ pub struct LauncherUi {
|
||||
pub(crate) search: String,
|
||||
pub(crate) toasts: Vec<Toast>,
|
||||
pub(crate) dialog: Option<Dialog>,
|
||||
/// The app whose details drawer is open (or closing: see `detail_shown`).
|
||||
pub(crate) detail: Option<String>,
|
||||
/// The app the drawer is drawn for: it outlives `detail` while the drawer slides out.
|
||||
pub(crate) detail_shown: Option<String>,
|
||||
/// Which screenshot the drawer shows.
|
||||
pub(crate) detail_shot: usize,
|
||||
/// The screenshot is enlarged over the whole window.
|
||||
pub(crate) detail_zoom: bool,
|
||||
/// The details pictures the backend has sent (or been asked for), by app and picture key.
|
||||
pub(crate) pictures: std::collections::HashMap<(String, String), crate::details::Picture>,
|
||||
started: bool,
|
||||
applied_theme: Option<theme::ThemeChoice>,
|
||||
/// The OS asks for fewer animations: what the host last said, and the context's time to ask
|
||||
@@ -173,6 +183,11 @@ impl LauncherUi {
|
||||
search: String::new(),
|
||||
toasts: Vec::new(),
|
||||
dialog: None,
|
||||
detail: None,
|
||||
detail_shown: None,
|
||||
detail_shot: 0,
|
||||
detail_zoom: false,
|
||||
pictures: std::collections::HashMap::new(),
|
||||
started: false,
|
||||
applied_theme: None,
|
||||
reduce_motion: false,
|
||||
@@ -310,6 +325,10 @@ impl LauncherUi {
|
||||
}
|
||||
true
|
||||
}
|
||||
Event::Screenshot { app, key, result } => {
|
||||
self.pictures.insert((app, key), crate::details::Picture::from_result(result));
|
||||
true
|
||||
}
|
||||
Event::Detected { app, installed } => {
|
||||
if let Some(e) = self.entry_mut(&app) {
|
||||
e.installed = installed;
|
||||
@@ -538,6 +557,7 @@ impl LauncherUi {
|
||||
ui.add_space(16.0);
|
||||
});
|
||||
});
|
||||
self.details_drawer(&ctx);
|
||||
self.dialogs(&ctx, host);
|
||||
self.draw_toasts(&ctx);
|
||||
if host.title_bar() == TitleBar::Custom {
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
//! An app's details drawer: it slides in from the right over a dimmed grid. Hand-written copy and
|
||||
//! screenshots come from `core::details`; the live facts (version, date, download size, release
|
||||
//! notes) from the app's latest GitHub release.
|
||||
|
||||
use artcraft_launcher_core::CraftApp;
|
||||
use std::sync::Arc;
|
||||
|
||||
use artcraft_launcher_core::details::AppDetails;
|
||||
use artcraft_launcher_core::platform::pick_asset;
|
||||
use artcraft_launcher_core::protocol::Command;
|
||||
use egui::{Color32, CornerRadius, Id, Margin, Order, Rect, RichText, Sense, Stroke, Ui, emath::easing, pos2, vec2};
|
||||
|
||||
use crate::app::LauncherUi;
|
||||
use crate::model::AppEntry;
|
||||
use crate::theme::{self, Tokens, rgb, tint};
|
||||
use crate::views::{FOOTER_H, HEADER_H, date_text};
|
||||
use crate::widgets::{self, Glyph, Kind};
|
||||
|
||||
/// Screenshots are 8:5.
|
||||
const SHOT_ASPECT: f32 = 0.625;
|
||||
const PAD: f32 = 24.0;
|
||||
/// As tall as the name, headline, "Like …" and version lines beside it, so the icon fills the row.
|
||||
const ICON: f32 = 112.0;
|
||||
/// The previous / next buttons over a picture.
|
||||
const ARROW: f32 = 36.0;
|
||||
const SLIDE_SECS: f32 = 0.22;
|
||||
|
||||
/// A details picture, as the backend has sent it.
|
||||
pub(crate) enum Picture {
|
||||
/// Asked for, not here yet.
|
||||
Loading,
|
||||
Ready(Arc<[u8]>),
|
||||
/// Why it couldn't be had (offline, not published…).
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
pub(crate) fn from_result(result: Result<Vec<u8>, String>) -> Self {
|
||||
match result {
|
||||
Ok(bytes) => Self::Ready(bytes.into()),
|
||||
Err(why) => Self::Failed(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LauncherUi {
|
||||
/// Asks the backend for the pictures of `app`'s page that we don't have. Pictures that failed
|
||||
/// earlier are asked for again: opening the page is the "try again".
|
||||
fn request_pictures(&mut self, app: &str) {
|
||||
let Some(details) = AppDetails::by_id(app) else { return };
|
||||
let mut missing = false;
|
||||
for shot in details.shots {
|
||||
let key = (details.id.to_string(), shot.key.to_string());
|
||||
if !matches!(self.pictures.get(&key), Some(Picture::Ready(_) | Picture::Loading)) {
|
||||
self.pictures.insert(key, Picture::Loading);
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
if missing {
|
||||
self.send(Command::FetchScreenshots { app: details.id.to_string() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the picture couldn't be had, if that is what happened to it.
|
||||
fn failure(&self, app: &str, key: &str) -> Option<String> {
|
||||
match self.pictures.get(&(app.to_string(), key.to_string())) {
|
||||
Some(Picture::Failed(why)) => Some(why.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture to draw, once it has arrived.
|
||||
fn picture_source(&self, app: &str, key: &str) -> Option<egui::ImageSource<'static>> {
|
||||
match self.pictures.get(&(app.to_string(), key.to_string())) {
|
||||
Some(Picture::Ready(bytes)) => Some(egui::ImageSource::Bytes {
|
||||
uri: format!("bytes://screenshots/{app}/{key}.jpg").into(),
|
||||
bytes: egui::load::Bytes::Shared(bytes.clone()),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the drawer for `self.detail`, sliding in and out. Call it every frame, after the
|
||||
/// main panels, so it covers them.
|
||||
pub(crate) fn details_drawer(&mut self, ctx: &egui::Context) {
|
||||
if let Some(id) = &self.detail
|
||||
&& self.detail_shown.as_ref() != Some(id)
|
||||
{
|
||||
self.detail_shown = Some(id.clone());
|
||||
self.detail_shot = 0;
|
||||
self.detail_zoom = false;
|
||||
let app = id.clone();
|
||||
self.request_pictures(&app);
|
||||
}
|
||||
// Asked every frame, even while closed, so the first opening starts from 0 and slides.
|
||||
let open = self.detail.is_some();
|
||||
let slide = if self.reduce_motion { 0.0 } else { SLIDE_SECS };
|
||||
let k = ctx.animate_bool_with_time_and_easing(Id::new("details-slide"), open, slide, easing::cubic_out);
|
||||
let Some(id) = self.detail_shown.clone() else { return };
|
||||
let Some(details) = AppDetails::by_id(&id) else {
|
||||
self.detail = None;
|
||||
self.detail_shown = None;
|
||||
return;
|
||||
};
|
||||
if k <= 0.0 && !open {
|
||||
self.detail_shown = None;
|
||||
return;
|
||||
}
|
||||
let Some(entry) = self.entry(&id).cloned() else { return };
|
||||
if !open {
|
||||
self.detail_zoom = false;
|
||||
}
|
||||
self.details_keys(ctx, details, open);
|
||||
|
||||
let t = Tokens::get(ctx);
|
||||
let area = drawer_area(ctx);
|
||||
let width = (area.width() * 0.5).clamp(440.0, 680.0).min(area.width());
|
||||
let left = area.right() - width * k;
|
||||
self.details_scrim(ctx, area, k, &t);
|
||||
|
||||
// Not constrained to the screen: the drawer is partly off it while it slides, and a constrained
|
||||
// area would keep its contents in place while only the panel behind them moved.
|
||||
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(pos2(left, area.top())).constrain(false).show(ctx, |ui| {
|
||||
let panel = Rect::from_min_size(pos2(left, area.top()), vec2(width, area.height()));
|
||||
let painter = ui.painter();
|
||||
painter.add(theme::shadow(&t).as_shape(panel, CornerRadius::ZERO));
|
||||
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
|
||||
painter.vline(panel.left() + 0.5, panel.y_range(), Stroke::new(1.0, t.line_strong));
|
||||
|
||||
ui.set_min_size(panel.size());
|
||||
ui.set_max_size(panel.size());
|
||||
// A bar that stays on screen says there is more below the fold. egui's default one is
|
||||
// floating and invisible until the pointer is over it, so the drawer uses the solid style.
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
self.details_hero(ui, &entry, details, &t);
|
||||
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
self.gallery(ui, details, &t, rgb(entry.app.color));
|
||||
ui.add_space(8.0);
|
||||
for p in details.paragraphs() {
|
||||
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
features(ui, details, entry.app, &t);
|
||||
ui.add_space(14.0);
|
||||
self.release_section(ui, &entry, details, &t);
|
||||
});
|
||||
});
|
||||
});
|
||||
if self.detail_zoom && open {
|
||||
self.lightbox(ctx, details, &t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Esc closes the enlarged picture first, then the drawer; ← and → flip through the pictures.
|
||||
/// Nothing happens under a dialog, and the arrows leave a text field alone.
|
||||
fn details_keys(&mut self, ctx: &egui::Context, details: &AppDetails, open: bool) {
|
||||
if !open || self.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
if self.detail_zoom {
|
||||
self.detail_zoom = false;
|
||||
} else {
|
||||
self.detail = None;
|
||||
}
|
||||
}
|
||||
let shots = details.shots.len();
|
||||
if shots > 1 && !ctx.egui_wants_keyboard_input() {
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
|
||||
self.flip_shot(shots, true);
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
|
||||
self.flip_shot(shots, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves to the next (or previous) of `count` pictures, wrapping around.
|
||||
fn flip_shot(&mut self, count: usize, forward: bool) {
|
||||
if count < 2 {
|
||||
return;
|
||||
}
|
||||
let at = self.detail_shot.min(count - 1);
|
||||
self.detail_shot = if forward { (at + 1) % count } else { (at + count - 1) % count };
|
||||
}
|
||||
|
||||
/// The page behind the drawer, dimmed (egui can't blur what is under a layer); a click on it closes the drawer.
|
||||
fn details_scrim(&mut self, ctx: &egui::Context, area: Rect, k: f32, t: &Tokens) {
|
||||
egui::Area::new(Id::new("details-scrim")).order(Order::Middle).fixed_pos(area.min).show(ctx, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(area.size(), Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::scrim(t, k));
|
||||
if resp.on_hover_cursor(egui::CursorIcon::Default).clicked() {
|
||||
self.detail = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The drawer's top: a wash of the app's colour under a 2 px rule, the close button, the name,
|
||||
/// version and main button, and the app's links.
|
||||
fn details_hero(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
|
||||
let app = entry.app;
|
||||
let app_color = rgb(app.color);
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
let hero = egui::Frame::new().fill(tint(app_color, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
widgets::hud(ui, tl!("App details"), t.faint);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
|
||||
self.detail = None;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(icon) = crate::icons::app(app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(ICON, ICON)));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
widgets::wordmark(ui, app, 32.0);
|
||||
ui.add(egui::Label::new(RichText::new(details.headline).size(15.0).color(t.ink)).wrap());
|
||||
// "Version 0.5.0 · Oct 8, 2026", or the installed-versus-latest line.
|
||||
let version = crate::views::version_line(entry, &status);
|
||||
if !version.trim().is_empty() {
|
||||
ui.label(RichText::new(version).size(12.5).color(t.muted));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// The menu leaves out the links: they are the buttons right below.
|
||||
let row_w = ui.available_width();
|
||||
ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false));
|
||||
link_buttons(ui, app, row_w);
|
||||
});
|
||||
let r = hero.response.rect;
|
||||
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 2.0)), CornerRadius::ZERO, app_color);
|
||||
ui.painter().hline(r.x_range(), r.bottom() - 0.5, t.hairline());
|
||||
}
|
||||
|
||||
/// The facts of the latest release in cells, then its notes.
|
||||
fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
|
||||
widgets::eyebrow(ui, "02", tl!("Latest release"));
|
||||
ui.add_space(4.0);
|
||||
let Some(r) = entry.release() else {
|
||||
ui.label(RichText::new(tl!("No release published yet")).color(t.muted));
|
||||
return;
|
||||
};
|
||||
let size = self.platform.and_then(|p| {
|
||||
let stems: Vec<&str> = entry.app.stems().collect();
|
||||
pick_asset(r, &stems, p, p.default_kinds()).map(|(a, _)| widgets::human_bytes(a.size))
|
||||
});
|
||||
let facts: Vec<(&str, String)> = [
|
||||
(tl!("Version"), Some(r.version.as_ref().map_or_else(|| r.tag.clone(), |v| v.to_string()))),
|
||||
(tl!("Released"), r.published_at.map(date_text)),
|
||||
(tl!("Download"), size),
|
||||
(tl!("Platforms"), Some(details.platforms.to_string())),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(label, value)| value.map(|v| (label, v)))
|
||||
.collect();
|
||||
for pair in facts.chunks(2) {
|
||||
ui.columns(2, |cols| {
|
||||
for (col, (label, value)) in cols.iter_mut().zip(pair) {
|
||||
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::same(12)).show(col, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
widgets::hud(ui, label, t.faint);
|
||||
ui.add(egui::Label::new(RichText::new(value).size(14.0).color(t.ink_strong)).wrap());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
crate::notes::show(ui, &r.notes);
|
||||
}
|
||||
|
||||
/// The current screenshot, large, over the whole window; ‹ › flip through them, a click or Esc closes it.
|
||||
fn lightbox(&mut self, ctx: &egui::Context, details: &AppDetails, t: &Tokens) {
|
||||
let count = details.shots.len();
|
||||
let at = self.detail_shot.min(count.saturating_sub(1));
|
||||
let Some(shot) = details.shots.get(at) else {
|
||||
self.detail_zoom = false;
|
||||
return;
|
||||
};
|
||||
let src = self.picture_source(details.id, shot.key);
|
||||
let failed = self.failure(details.id, shot.key);
|
||||
let full = ctx.content_rect();
|
||||
egui::Area::new(Id::new("details-lightbox")).order(Order::Tooltip).fixed_pos(full.min).show(ctx, |ui| {
|
||||
let (rect, backdrop) = ui.allocate_exact_size(full.size(), Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::backdrop());
|
||||
// As large as the window allows, but no larger than 1400 points: the files are 1200 px wide.
|
||||
let room = rect.shrink2(vec2(112.0, 96.0));
|
||||
let w = room.width().min(room.height() / SHOT_ASPECT).clamp(1.0, 1400.0);
|
||||
let pic = Rect::from_center_size(rect.center(), vec2(w, w * SHOT_ASPECT));
|
||||
match src {
|
||||
Some(src) => egui::Image::new(src).paint_at(ui, pic),
|
||||
// Flipped to a picture that hasn't arrived: the same frame, saying so.
|
||||
None => placeholder(ui, pic, Id::new("lightbox-placeholder"), failed.as_deref(), t, true),
|
||||
}
|
||||
ui.painter().rect_stroke(pic, CornerRadius::ZERO, Stroke::new(1.0, theme::backdrop_ink(40)), egui::StrokeKind::Outside);
|
||||
let caption = if count > 1 { format!("{} · {}/{count}", shot.caption, at + 1) } else { shot.caption.to_string() };
|
||||
let ink = theme::backdrop_ink(190);
|
||||
let galley = widgets::hud_galley(ui, &caption, ink);
|
||||
let caption_at = pos2(rect.center().x - galley.size().x / 2.0, pic.bottom() + 14.0);
|
||||
ui.painter().galley(caption_at, galley, ink);
|
||||
|
||||
if count > 1 {
|
||||
let y = pic.center().y - ARROW / 2.0;
|
||||
let prev = Rect::from_min_size(pos2(pic.left() - 16.0 - ARROW, y), vec2(ARROW, ARROW));
|
||||
let next = Rect::from_min_size(pos2(pic.right() + 16.0, y), vec2(ARROW, ARROW));
|
||||
if arrow_button(ui, prev, Id::new("zoom-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
self.flip_shot(count, false);
|
||||
}
|
||||
if arrow_button(ui, next, Id::new("zoom-next"), false, tl!("Next picture (→)"), t) {
|
||||
self.flip_shot(count, true);
|
||||
}
|
||||
}
|
||||
let close =
|
||||
Rect::from_min_size(pos2(rect.right() - 16.0 - theme::CONTROL_H, rect.top() + 16.0), vec2(theme::CONTROL_H, theme::CONTROL_H));
|
||||
let mut done = backdrop.clicked();
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
|
||||
done |= widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked();
|
||||
});
|
||||
if done {
|
||||
self.detail_zoom = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The lead screenshot, its caption, and a row of thumbnails to switch between them. A picture
|
||||
/// that hasn't arrived is a frame of the same size saying so, so nothing moves when it does.
|
||||
fn gallery(&mut self, ui: &mut Ui, details: &AppDetails, t: &Tokens, app_color: Color32) {
|
||||
let Some(¤t) = details.shots.get(self.detail_shot).or(details.shots.first()) else { return };
|
||||
let w = ui.available_width();
|
||||
let pic = match self.picture_source(details.id, current.key) {
|
||||
Some(lead) => {
|
||||
let image = ui.add(egui::Image::new(lead).fit_to_exact_size(vec2(w, w * SHOT_ASPECT)).sense(Sense::click()));
|
||||
image.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Enlarge picture")));
|
||||
let rect = image.rect;
|
||||
// The arrows below are added after the picture, so they keep their own clicks.
|
||||
if image.on_hover_cursor(egui::CursorIcon::ZoomIn).on_hover_text(tl!("Click to enlarge")).clicked() {
|
||||
self.detail_zoom = true;
|
||||
}
|
||||
rect
|
||||
}
|
||||
None => {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, w * SHOT_ASPECT), Sense::hover());
|
||||
let failed = self.failure(details.id, current.key);
|
||||
placeholder(ui, rect, Id::new("lead-placeholder"), failed.as_deref(), t, false);
|
||||
rect
|
||||
}
|
||||
};
|
||||
ui.painter().rect_stroke(pic, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
|
||||
let count = details.shots.len();
|
||||
if count < 2 {
|
||||
widgets::hud(ui, current.caption, t.faint);
|
||||
return;
|
||||
}
|
||||
// Previous and next, over the sides of the picture.
|
||||
let y = pic.center().y - ARROW / 2.0;
|
||||
let prev = Rect::from_min_size(pos2(pic.left() + 10.0, y), vec2(ARROW, ARROW));
|
||||
let next = Rect::from_min_size(pos2(pic.right() - 10.0 - ARROW, y), vec2(ARROW, ARROW));
|
||||
if arrow_button(ui, prev, Id::new("shot-prev"), true, tl!("Previous picture (←)"), t) {
|
||||
self.flip_shot(count, false);
|
||||
}
|
||||
if arrow_button(ui, next, Id::new("shot-next"), false, tl!("Next picture (→)"), t) {
|
||||
self.flip_shot(count, true);
|
||||
}
|
||||
widgets::hud(ui, &format!("{} · {}/{count}", current.caption, self.detail_shot.min(count - 1) + 1), t.faint);
|
||||
let gap = 8.0;
|
||||
let thumb_w = ((w - gap * (count as f32 - 1.0)) / count as f32).floor().max(1.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = gap;
|
||||
for (i, shot) in details.shots.iter().enumerate() {
|
||||
let selected = i == self.detail_shot;
|
||||
let size = vec2(thumb_w, thumb_w * SHOT_ASPECT);
|
||||
let resp = match self.picture_source(details.id, shot.key) {
|
||||
Some(src) => ui.add(egui::Image::new(src).fit_to_exact_size(size).sense(Sense::click())),
|
||||
// Too small for words: a blank frame, still there to pick.
|
||||
None => {
|
||||
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, t.sunken);
|
||||
resp
|
||||
}
|
||||
};
|
||||
// The other pictures sit under a veil of the page's own background (a little lighter on hover):
|
||||
// they fade toward the page, not toward grey, in both themes.
|
||||
if !selected {
|
||||
let veil = if resp.hovered() { 0.25 } else { 0.55 };
|
||||
ui.painter().rect_filled(resp.rect, CornerRadius::ZERO, t.bg.gamma_multiply(veil));
|
||||
}
|
||||
let stroke = if selected { Stroke::new(2.0, app_color) } else { t.hairline() };
|
||||
ui.painter().rect_stroke(resp.rect, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
|
||||
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(shot.caption).clicked() {
|
||||
self.detail_shot = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// What stands in for a picture that isn't here: a frame of the picture's size saying why. `failed`
|
||||
/// is the reason the picture couldn't be had; without one it is still on its way.
|
||||
fn placeholder(ui: &mut Ui, rect: Rect, id: Id, failed: Option<&str>, t: &Tokens, over_backdrop: bool) {
|
||||
let text = if failed.is_some() { tl!("Pictures couldn't be loaded. Reopen this page to try again.") } else { tl!("Loading pictures…") };
|
||||
ui.painter().rect_filled(rect, CornerRadius::ZERO, if over_backdrop { theme::backdrop_panel() } else { t.sunken });
|
||||
let ink = if over_backdrop { theme::backdrop_ink(190) } else { t.faint };
|
||||
let galley = ui.painter().layout(text.to_string(), egui::FontId::proportional(13.5), ink, (rect.width() - 32.0).max(1.0));
|
||||
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink);
|
||||
let resp = ui.interact(rect, id, Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
|
||||
if let Some(why) = failed {
|
||||
resp.on_hover_text(why);
|
||||
}
|
||||
}
|
||||
|
||||
/// Between the header and the footer, which stay usable. In a window shorter than the two bars
|
||||
/// together there is no room left: an empty area, never a negative one.
|
||||
fn drawer_area(ctx: &egui::Context) -> Rect {
|
||||
let full = ctx.content_rect();
|
||||
let top = (full.top() + HEADER_H).min(full.bottom());
|
||||
let bottom = (full.bottom() - FOOTER_H).max(top);
|
||||
Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), bottom))
|
||||
}
|
||||
|
||||
/// The app's links as a row of buttons sharing `row_w`; the ↗ says each opens the browser, like the links on the cards.
|
||||
fn link_buttons(ui: &mut Ui, app: &CraftApp, row_w: f32) {
|
||||
let links = [(tl!("Website"), app.website()), (tl!("GitHub"), app.repo_url()), (tl!("Releases"), app.releases_url())];
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let link_w = ((row_w - gap * (links.len() as f32 - 1.0)) / links.len() as f32).floor();
|
||||
ui.horizontal(|ui| {
|
||||
for (label, url) in links {
|
||||
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, link_w).on_hover_text(url.as_str()).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The numbered feature cells.
|
||||
fn features(ui: &mut Ui, details: &AppDetails, app: &CraftApp, t: &Tokens) {
|
||||
widgets::eyebrow(ui, "01", tl!("Features"));
|
||||
ui.add_space(4.0);
|
||||
for (i, f) in details.features.iter().enumerate() {
|
||||
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
ui.horizontal(|ui| {
|
||||
widgets::hud(ui, &format!("{:02}", i + 1), t.app_ink(app));
|
||||
ui.label(RichText::new(f.title).size(15.0).family(theme::semibold()).color(t.ink_strong));
|
||||
});
|
||||
ui.add(egui::Label::new(RichText::new(f.text).size(13.5).color(t.muted)).wrap());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A square button with a chevron, drawn over a picture: opaque enough to read on any image,
|
||||
/// inverted on hover like the site's outlined buttons. True when clicked.
|
||||
fn arrow_button(ui: &mut Ui, rect: Rect, id: Id, left: bool, tip: &str, t: &Tokens) -> bool {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
let (fill, ink) = if resp.hovered() { (t.invert_bg, t.invert_fg) } else { (t.bg.gamma_multiply(0.92), t.ink) };
|
||||
let painter = ui.painter();
|
||||
painter.rect(rect, CornerRadius::ZERO, fill, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
|
||||
let (c, s) = (rect.center(), if left { -1.0 } else { 1.0 });
|
||||
let points = vec![pos2(c.x - s * 3.5, c.y - 6.0), pos2(c.x + s * 3.5, c.y), pos2(c.x - s * 3.5, c.y + 6.0)];
|
||||
painter.add(egui::Shape::line(points, Stroke::new(1.75, ink)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
|
||||
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
|
||||
}
|
||||
@@ -237,6 +237,20 @@ column Installed Nainstalováno
|
||||
column Latest Nejnovější
|
||||
column Released Vydáno
|
||||
filter Installed Nainstalováno
|
||||
App details Podrobnosti o aplikaci
|
||||
Close (Esc) Zavřít (Esc)
|
||||
Features Funkce
|
||||
Latest release Nejnovější verze
|
||||
Version Verze
|
||||
Platforms Platformy
|
||||
Releases Vydání
|
||||
Enlarge picture Zvětšit obrázek
|
||||
Click to enlarge Kliknutím zvětšíte
|
||||
Previous picture (←) Předchozí obrázek (←)
|
||||
Next picture (→) Další obrázek (→)
|
||||
Loading pictures… Načítání obrázků…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Obrázky se nepodařilo načíst. Po opětovném otevření stránky se načtení zkusí znovu.
|
||||
{name} details Podrobnosti: {name}
|
||||
@plural {n} app|{n} apps {n} aplikace|{n} aplikace|{n} aplikací
|
||||
@plural {n} min ago|{n} min ago před {n} min|před {n} min|před {n} min
|
||||
@plural {n} h ago|{n} h ago před {n} h|před {n} h|před {n} h
|
||||
|
||||
|
@@ -260,6 +260,7 @@ column Released Datum
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Installiert
|
||||
{name} details Details zu {name}
|
||||
|
||||
# Plural forms for German (one|other)
|
||||
@plural {n} app|{n} apps {n} App|{n} Apps
|
||||
@@ -328,4 +329,17 @@ filter Installed Installiert
|
||||
Visit getartcraft.com getartcraft.com besuchen
|
||||
ArtCraft on GitHub ArtCraft auf GitHub
|
||||
Join the ArtCraft Discord Dem ArtCraft-Discord beitreten
|
||||
App details App-Details
|
||||
Close (Esc) Schließen (Esc)
|
||||
Features Funktionen
|
||||
Latest release Neueste Version
|
||||
Version Version
|
||||
Platforms Plattformen
|
||||
Releases Versionen
|
||||
Enlarge picture Bild vergrößern
|
||||
Click to enlarge Zum Vergrößern klicken
|
||||
Previous picture (←) Vorheriges Bild (←)
|
||||
Next picture (→) Nächstes Bild (→)
|
||||
Loading pictures… Bilder werden geladen…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht.
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 16.
|
@@ -250,6 +250,20 @@ column Installed Instalada
|
||||
column Latest Última
|
||||
column Released Publicada
|
||||
filter Installed Instaladas
|
||||
App details Detalles de la app
|
||||
Close (Esc) Cerrar (Esc)
|
||||
Features Funciones
|
||||
Latest release Última versión
|
||||
Version Versión
|
||||
Platforms Plataformas
|
||||
Releases Versiones
|
||||
Enlarge picture Ampliar imagen
|
||||
Click to enlarge Haz clic para ampliar
|
||||
Previous picture (←) Imagen anterior (←)
|
||||
Next picture (→) Imagen siguiente (→)
|
||||
Loading pictures… Cargando imágenes…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. No se pudieron cargar las imágenes. Vuelve a abrir esta página para intentarlo de nuevo.
|
||||
{name} details Detalles de {name}
|
||||
@plural {n} app|{n} apps {n} aplicación|{n} aplicaciones
|
||||
@plural {n} min ago|{n} min ago hace {n} min|hace {n} min
|
||||
@plural {n} h ago|{n} h ago hace {n} h|hace {n} h
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 48.
|
@@ -258,6 +258,7 @@ column Released Date de publication
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Installées
|
||||
{name} details Détails de {name}
|
||||
|
||||
# Plural forms for French (one|other)
|
||||
@plural {n} app|{n} apps {n} application|{n} applications
|
||||
@@ -326,4 +327,17 @@ filter Installed Installées
|
||||
Visit getartcraft.com Visiter getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft sur GitHub
|
||||
Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft
|
||||
App details Détails de l'application
|
||||
Close (Esc) Fermer (Esc)
|
||||
Features Fonctionnalités
|
||||
Latest release Dernière version
|
||||
Version Version
|
||||
Platforms Plateformes
|
||||
Releases Versions
|
||||
Enlarge picture Agrandir l'image
|
||||
Click to enlarge Cliquer pour agrandir
|
||||
Previous picture (←) Image précédente (←)
|
||||
Next picture (→) Image suivante (→)
|
||||
Loading pictures… Chargement des images…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Impossible de charger les images. Rouvrir la page permet de réessayer.
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 50.
|
@@ -261,6 +261,20 @@ column Released Dátum
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Telepítve
|
||||
App details Alkalmazás részletei
|
||||
Close (Esc) Bezárás (Esc)
|
||||
Features Funkciók
|
||||
Latest release Legújabb kiadás
|
||||
Version Verzió
|
||||
Platforms Platformok
|
||||
Releases Kiadások
|
||||
Enlarge picture Kép nagyítása
|
||||
Click to enlarge Kattints a nagyításhoz
|
||||
Previous picture (←) Előző kép (←)
|
||||
Next picture (→) Következő kép (→)
|
||||
Loading pictures… Képek betöltése…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. A képeket nem sikerült betölteni. Nyisd meg újra az oldalt, és újra megpróbáljuk.
|
||||
{name} details {name} – részletek
|
||||
|
||||
# Plural forms for Hungarian (one|other). After a number the noun stays singular.
|
||||
@plural {n} app|{n} apps {n} alkalmazás|{n} alkalmazás
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 16.
|
@@ -263,6 +263,20 @@ column Released 公開日
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed インストール済み
|
||||
App details アプリの詳細
|
||||
Close (Esc) 閉じる (Esc)
|
||||
Features 機能
|
||||
Latest release 最新リリース
|
||||
Version バージョン
|
||||
Platforms 対応プラットフォーム
|
||||
Releases リリース
|
||||
Enlarge picture 画像を拡大
|
||||
Click to enlarge クリックして拡大
|
||||
Previous picture (←) 前の画像 (←)
|
||||
Next picture (→) 次の画像 (→)
|
||||
Loading pictures… 画像を読み込み中…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. 画像を読み込めませんでした。このページを開き直すと再試行します。
|
||||
{name} details {name} の詳細
|
||||
|
||||
# Plural forms for Japanese (one form: Japanese nouns don't inflect for number)
|
||||
@plural {n} app|{n} apps {n} 個のアプリ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 14 and column 44.
|
@@ -262,6 +262,20 @@ column Released Data wydania
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Zainstalowane
|
||||
App details Szczegóły aplikacji
|
||||
Close (Esc) Zamknij (Esc)
|
||||
Features Funkcje
|
||||
Latest release Najnowsze wydanie
|
||||
Version Wersja
|
||||
Platforms Platformy
|
||||
Releases Wydania
|
||||
Enlarge picture Powiększ obraz
|
||||
Click to enlarge Kliknij, aby powiększyć
|
||||
Previous picture (←) Poprzedni obraz (←)
|
||||
Next picture (→) Następny obraz (→)
|
||||
Loading pictures… Wczytywanie obrazów…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Nie udało się wczytać obrazów. Otwórz tę stronę ponownie, aby spróbować jeszcze raz.
|
||||
{name} details {name} – szczegóły
|
||||
|
||||
# Plural forms for Polish (one|few|many)
|
||||
@plural {n} app|{n} apps {n} aplikacja|{n} aplikacje|{n} aplikacji
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 15.
|
@@ -248,6 +248,7 @@ column Released Lançamento
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Instalados
|
||||
{name} details Detalhes de {name}
|
||||
|
||||
# Plural forms for Brazilian Portuguese (one|other)
|
||||
@plural {n} app|{n} apps {n} aplicativo|{n} aplicativos
|
||||
@@ -316,4 +317,17 @@ filter Installed Instalados
|
||||
Visit getartcraft.com Visitar getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft no GitHub
|
||||
Join the ArtCraft Discord Entrar no Discord da ArtCraft
|
||||
App details Detalhes do aplicativo
|
||||
Close (Esc) Fechar (Esc)
|
||||
Features Recursos
|
||||
Latest release Último lançamento
|
||||
Version Versão
|
||||
Platforms Plataformas
|
||||
Releases Lançamentos
|
||||
Enlarge picture Ampliar imagem
|
||||
Click to enlarge Clique para ampliar
|
||||
Previous picture (←) Imagem anterior (←)
|
||||
Next picture (→) Próxima imagem (→)
|
||||
Loading pictures… Carregando imagens…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Não foi possível carregar as imagens. Abra a página novamente para tentar de novo.
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis
|
||||
|
Can't render this file because it contains an unexpected character in line 249 and column 66.
|
@@ -257,6 +257,7 @@ column Released Дата релиза
|
||||
|
||||
# Contextual: the filter tab names a group (the status pill says "Installed")
|
||||
filter Installed Установлены
|
||||
{name} details {name}: подробности
|
||||
|
||||
# Plural forms for Russian (one|few|many)
|
||||
@plural {n} app|{n} apps {n} приложение|{n} приложения|{n} приложений
|
||||
@@ -324,4 +325,17 @@ filter Installed Установлены
|
||||
Visit getartcraft.com Открыть getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft на GitHub
|
||||
Join the ArtCraft Discord Присоединиться к Discord ArtCraft
|
||||
App details О приложении
|
||||
Close (Esc) Закрыть (Esc)
|
||||
Features Возможности
|
||||
Latest release Последний релиз
|
||||
Version Версия
|
||||
Platforms Платформы
|
||||
Releases Релизы
|
||||
Enlarge picture Увеличить изображение
|
||||
Click to enlarge Нажмите, чтобы увеличить
|
||||
Previous picture (←) Предыдущее изображение (←)
|
||||
Next picture (→) Следующее изображение (→)
|
||||
Loading pictures… Загрузка изображений…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку.
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
@@ -124,6 +124,7 @@ fn ellipsis_preserved() {
|
||||
fn complete_catalogs_cover_every_ui_literal() {
|
||||
let sources = [
|
||||
include_str!("../app.rs"),
|
||||
include_str!("../details.rs"),
|
||||
include_str!("../dialogs.rs"),
|
||||
include_str!("../model.rs"),
|
||||
include_str!("../notes.rs"),
|
||||
|
||||
@@ -174,6 +174,7 @@ column Installed 已安装版本
|
||||
column Latest 最新版本
|
||||
column Released 发布日期
|
||||
filter Installed 已安装
|
||||
{name} details {name} 详情
|
||||
@plural {n} min ago|{n} min ago {n} 分钟前
|
||||
@plural {n} h ago|{n} h ago {n} 小时前
|
||||
@plural {n} day ago|{n} days ago {n} 天前
|
||||
@@ -287,6 +288,19 @@ soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
|
||||
RPM package (.rpm) RPM 软件包 (.rpm)
|
||||
Flatpak bundle Flatpak 包
|
||||
Tarball (.tar.gz) 压缩包 (.tar.gz)
|
||||
App details 应用详情
|
||||
Close (Esc) 关闭(Esc)
|
||||
Features 功能特性
|
||||
Latest release 最新发布
|
||||
Version 版本
|
||||
Platforms 平台
|
||||
Releases 版本发布
|
||||
Enlarge picture 放大图片
|
||||
Click to enlarge 点击放大
|
||||
Previous picture (←) 上一张(←)
|
||||
Next picture (→) 下一张(→)
|
||||
Loading pictures… 正在加载图片…
|
||||
Pictures couldn't be loaded. Reopen this page to try again. 无法加载图片。重新打开此页面可重试。
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} 无法打开 {name}:{e}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 3.
|
@@ -19,6 +19,7 @@ macro_rules! tl {
|
||||
mod app;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cjk_font;
|
||||
mod details;
|
||||
mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
|
||||
@@ -145,6 +145,27 @@ pub fn shadow(t: &Tokens) -> egui::epaint::Shadow {
|
||||
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 36 }) }
|
||||
}
|
||||
|
||||
/// The dim over the page behind a drawer; `amount` runs from 0 (none) to 1.
|
||||
pub fn scrim(t: &Tokens, amount: f32) -> Color32 {
|
||||
let alpha = if t.dark { 170.0 } else { 105.0 } * amount.clamp(0.0, 1.0);
|
||||
Color32::from_black_alpha(alpha.round() as u8)
|
||||
}
|
||||
|
||||
/// Behind an enlarged picture: near-black in both appearances, as pictures are judged on black.
|
||||
pub fn backdrop() -> Color32 {
|
||||
Color32::from_black_alpha(232)
|
||||
}
|
||||
|
||||
/// A frame standing in for a picture, over [`backdrop`].
|
||||
pub fn backdrop_panel() -> Color32 {
|
||||
Color32::from_white_alpha(14)
|
||||
}
|
||||
|
||||
/// Ink on [`backdrop`], at `alpha` out of 255.
|
||||
pub fn backdrop_ink(alpha: u8) -> Color32 {
|
||||
Color32::from_white_alpha(alpha)
|
||||
}
|
||||
|
||||
fn visuals(t: &Tokens) -> Visuals {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.bg;
|
||||
|
||||
+51
-16
@@ -1,6 +1,7 @@
|
||||
//! The header, toolbar, footer, the grid of cards and the table, laid out like the apps page on
|
||||
//! getartcraft.com: hairline cells, mono labels and square controls.
|
||||
|
||||
use artcraft_launcher_core::details::AppDetails;
|
||||
use artcraft_launcher_core::platform::pick_asset;
|
||||
use artcraft_launcher_core::protocol::{Command, JobPhase};
|
||||
use artcraft_launcher_core::status::AppStatus;
|
||||
@@ -397,8 +398,19 @@ impl LauncherUi {
|
||||
let wipe = if self.reduce_motion { 0.0 } else { 0.3 };
|
||||
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, wipe);
|
||||
let app_color = rgb(app.color);
|
||||
// The whole card opens the app's page. Added before the card's own links and buttons, so
|
||||
// they sit on top of it and keep their clicks.
|
||||
if AppDetails::by_id(id).is_some() {
|
||||
let open = ui.interact(rect, ui.id().with(("card-open", id)), Sense::click());
|
||||
open.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fmt(tl!("{name} details"), &[("name", app.name)])));
|
||||
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
self.detail = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
let painter = ui.painter_at(rect.expand(16.0));
|
||||
painter.rect(rect, CornerRadius::ZERO, t.bg, t.hairline(), egui::StrokeKind::Inside);
|
||||
// A faint wash in the app's colour fades in under the pointer.
|
||||
let fill = t.bg.lerp_to_gamma(app_color, 0.05 * lift);
|
||||
painter.rect(rect, CornerRadius::ZERO, fill, t.hairline(), egui::StrokeKind::Inside);
|
||||
// On hover, a 2 px rule in the app's colour wipes in from the left along the top.
|
||||
if lift > 0.0 {
|
||||
painter.rect_filled(Rect::from_min_size(rect.min, vec2(rect.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
|
||||
@@ -560,6 +572,20 @@ impl LauncherUi {
|
||||
if i < last {
|
||||
rule(ui);
|
||||
}
|
||||
// Clicking the app's cell opens its page (the content is added after, so it wins any hit).
|
||||
if AppDetails::by_id(id).is_some() {
|
||||
let open = ui.interact(ui.max_rect(), ui.id().with(("row-open", id)), Sense::click());
|
||||
open.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(
|
||||
egui::WidgetType::Button,
|
||||
true,
|
||||
fmt(tl!("{name} details"), &[("name", entry.app.name)]),
|
||||
)
|
||||
});
|
||||
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||||
self.detail = Some(id.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(icon) = crate::icons::app(entry.app.id) {
|
||||
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(38.0, 38.0)));
|
||||
}
|
||||
@@ -650,7 +676,13 @@ impl LauncherUi {
|
||||
}
|
||||
|
||||
/// The main button (or progress) and the … menu, within `width`.
|
||||
fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
|
||||
pub(crate) fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
|
||||
self.actions_in(ui, entry, status, width, true);
|
||||
}
|
||||
|
||||
/// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows
|
||||
/// the website, GitHub and releases links as buttons of its own.
|
||||
pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = entry.app.id.to_string();
|
||||
// One square button (the menu) beside the main one.
|
||||
@@ -733,7 +765,7 @@ impl LauncherUi {
|
||||
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() {
|
||||
self.send(Command::Cancel { app: id.clone() });
|
||||
}
|
||||
self.menu(ui, entry, status);
|
||||
self.menu(ui, entry, status, links_in_menu);
|
||||
return;
|
||||
}
|
||||
match self.primary_action(entry, status) {
|
||||
@@ -771,10 +803,10 @@ impl LauncherUi {
|
||||
ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, main_w));
|
||||
}
|
||||
}
|
||||
self.menu(ui, entry, status);
|
||||
self.menu(ui, entry, status, links_in_menu);
|
||||
}
|
||||
|
||||
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus) {
|
||||
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) {
|
||||
let id = entry.app.id.to_string();
|
||||
let manage = self.caps.manage_apps;
|
||||
let reveal = self.caps.reveal_label;
|
||||
@@ -830,16 +862,19 @@ impl LauncherUi {
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let link = |ui: &mut Ui, label: &str, url: String| {
|
||||
if ui.button(label).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
link(ui, tl!("Website"), entry.app.website());
|
||||
link(ui, tl!("GitHub repository"), entry.app.repo_url());
|
||||
link(ui, tl!("All releases"), entry.app.releases_url());
|
||||
// The drawer shows the links as buttons, so its menu leaves them out (but keeps Hide).
|
||||
if links {
|
||||
ui.separator();
|
||||
let link = |ui: &mut Ui, label: &str, url: String| {
|
||||
if ui.button(label).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
link(ui, tl!("Website"), entry.app.website());
|
||||
link(ui, tl!("GitHub repository"), entry.app.repo_url());
|
||||
link(ui, tl!("All releases"), entry.app.releases_url());
|
||||
}
|
||||
ui.separator();
|
||||
let hidden = self.settings.hidden.contains(entry.app.id);
|
||||
if ui.button(if hidden { tl!("Unhide App") } else { tl!("Hide App") }).clicked() {
|
||||
@@ -869,7 +904,7 @@ fn sort_label(k: SortKey) -> &'static str {
|
||||
}
|
||||
|
||||
/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
|
||||
fn version_line(e: &AppEntry, status: &AppStatus) -> String {
|
||||
pub(crate) fn version_line(e: &AppEntry, status: &AppStatus) -> String {
|
||||
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", date_text(at))).unwrap_or_default();
|
||||
let inst_ver = e.installed.as_ref().and_then(|i| i.version.as_ref().map(ToString::to_string));
|
||||
let latest = e.latest_label();
|
||||
|
||||
@@ -70,6 +70,17 @@ pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Re
|
||||
if elided { resp.on_hover_text(text) } else { resp }
|
||||
}
|
||||
|
||||
/// "01 / Features": a section eyebrow, the index faint.
|
||||
pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
hud(ui, index, t.faint);
|
||||
hud(ui, " / ", t.faint);
|
||||
hud(ui, label, t.muted);
|
||||
});
|
||||
}
|
||||
|
||||
/// A solid chip with a bold mono label: an app's number tab (the site's `AppTab`).
|
||||
pub fn tag(ui: &mut Ui, text: &str, fill: Color32, ink: Color32) -> Response {
|
||||
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, ink);
|
||||
|
||||
+361
-14
@@ -5,7 +5,9 @@
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use artcraft_launcher_core::details::AppDetails;
|
||||
use artcraft_launcher_core::protocol::{
|
||||
Backend, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PausedDownload, Source,
|
||||
};
|
||||
@@ -19,19 +21,66 @@ use egui_kittest::kittest::{NodeT, Queryable};
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
/// How the fake backend answers a request for an app's details pictures, whenever it is polled.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum Pictures {
|
||||
/// Every picture arrives.
|
||||
#[default]
|
||||
Online,
|
||||
/// Every picture fails, as with no connection.
|
||||
Offline,
|
||||
/// Nothing is answered yet: the requests wait until the mode changes.
|
||||
Silent,
|
||||
/// The second picture of each page fails (not published), the others arrive.
|
||||
SecondFails,
|
||||
}
|
||||
|
||||
/// A small JPEG, made here so that no picture is kept in the repository for the tests.
|
||||
fn test_jpeg() -> Vec<u8> {
|
||||
static JPEG: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
JPEG.get_or_init(|| {
|
||||
let (w, h) = (320u32, 200u32);
|
||||
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, 160])).collect();
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).unwrap();
|
||||
out
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
sent: Rc<RefCell<Vec<Command>>>,
|
||||
manage: bool,
|
||||
pictures: Rc<Cell<Pictures>>,
|
||||
/// Apps whose pictures were asked for and not answered yet.
|
||||
asked: Vec<String>,
|
||||
clock: Rc<Cell<i64>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, command: Command) {
|
||||
if let Command::FetchScreenshots { app } = &command {
|
||||
self.asked.push(app.clone());
|
||||
}
|
||||
self.sent.borrow_mut().push(command);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
let mut out = std::mem::take(&mut self.events);
|
||||
if self.pictures.get() != Pictures::Silent {
|
||||
for app in std::mem::take(&mut self.asked) {
|
||||
let Some(page) = AppDetails::by_id(&app) else { continue };
|
||||
for (i, shot) in page.shots.iter().enumerate() {
|
||||
let result = match self.pictures.get() {
|
||||
Pictures::Offline => Err("couldn't reach raw.githubusercontent.com (no connection)".to_string()),
|
||||
Pictures::SecondFails if i == 1 => Err("this picture isn't published".to_string()),
|
||||
_ => Ok(test_jpeg()),
|
||||
};
|
||||
out.push(Event::Screenshot { app: app.clone(), key: shot.key.to_string(), result });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
|
||||
@@ -123,7 +172,19 @@ fn harness_with(
|
||||
manage: bool,
|
||||
scale: f32,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
harness_with_title_bar(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native)
|
||||
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default())
|
||||
}
|
||||
|
||||
/// Like `harness_with`, with the details pictures answered as `pictures` says (changeable during the test).
|
||||
fn harness_pictures(
|
||||
events: Vec<Event>,
|
||||
view: ViewMode,
|
||||
theme: egui::Theme,
|
||||
manage: bool,
|
||||
scale: f32,
|
||||
pictures: Rc<Cell<Pictures>>,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures)
|
||||
}
|
||||
|
||||
fn harness_with_title_bar(
|
||||
@@ -133,6 +194,18 @@ fn harness_with_title_bar(
|
||||
manage: bool,
|
||||
scale: f32,
|
||||
bar: artcraft_launcher_ui_egui::TitleBar,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
harness_full(events, view, theme, manage, scale, bar, Rc::default())
|
||||
}
|
||||
|
||||
fn harness_full(
|
||||
events: Vec<Event>,
|
||||
view: ViewMode,
|
||||
theme: egui::Theme,
|
||||
manage: bool,
|
||||
scale: f32,
|
||||
bar: artcraft_launcher_ui_egui::TitleBar,
|
||||
pictures: Rc<Cell<Pictures>>,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
struct HeaderHost(artcraft_launcher_ui_egui::TitleBar);
|
||||
impl Host for HeaderHost {
|
||||
@@ -145,7 +218,7 @@ fn harness_with_title_bar(
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let sent2 = sent.clone();
|
||||
let mut backend = Some(Fake { events, sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let settings = Settings { view, notify: false, ..Settings::default() };
|
||||
// No `.wgpu()`: it gives every test its own GPU device, and those segfault on parallel test threads (see above).
|
||||
// The default renderer creates one only when a test renders.
|
||||
@@ -181,7 +254,8 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
#[test]
|
||||
fn keeps_working_with_no_window_then_draws_in_a_new_one() {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let backend = Fake { events: events(), sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) };
|
||||
let backend =
|
||||
Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) };
|
||||
let windowless = egui::Context::default();
|
||||
let mut ui = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, ..Settings::default() });
|
||||
// No window: the backend's news is still taken in.
|
||||
@@ -623,7 +697,8 @@ fn links_open_the_right_pages() {
|
||||
fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let clock = Rc::new(Cell::new(NOW));
|
||||
let mut backend = Some(Fake { events: events(), sent: sent.clone(), manage: true, clock: clock.clone() });
|
||||
let mut backend =
|
||||
Some(Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: clock.clone() });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -686,8 +761,10 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
|
||||
}
|
||||
let quit = Rc::new(Cell::new(false));
|
||||
let mut host = QuitHost(quit.clone());
|
||||
let mut backend =
|
||||
Some(Scripted { inner: Fake { events, sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) }, later: shared_events.clone() });
|
||||
let mut backend = Some(Scripted {
|
||||
inner: Fake { events, sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) },
|
||||
later: shared_events.clone(),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -766,7 +843,7 @@ fn feed_announcements_and_alerts_show_until_dismissed() {
|
||||
announcements: vec![notice("a1", "CADCraft 1.0 is out", Some("https://getartcraft.com/news"))],
|
||||
alerts: vec![notice("x1", "Security update", Some("https://getartcraft.com/security"))],
|
||||
});
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -813,7 +890,8 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let sent2 = sent.clone();
|
||||
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend =
|
||||
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -841,7 +919,8 @@ fn automatic_updates_are_disabled_by_default() {
|
||||
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let sent2 = sent.clone();
|
||||
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend =
|
||||
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -868,7 +947,7 @@ fn the_footer_names_the_services_behind_what_is_on_screen() {
|
||||
let mut evs = events();
|
||||
evs.extend(extra);
|
||||
let sent = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -911,7 +990,14 @@ fn custom_title_bar_has_window_buttons() {
|
||||
artcraft_launcher_ui_egui::TitleBar::Custom
|
||||
}
|
||||
}
|
||||
let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake {
|
||||
events: events(),
|
||||
sent: Rc::new(RefCell::new(Vec::new())),
|
||||
manage: true,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -936,12 +1022,272 @@ fn custom_title_bar_has_window_buttons() {
|
||||
assert!(closes, "the close button asks the window to close (the shell decides whether that hides it to the tray)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_card_opens_its_details_drawer() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
// Every app has a page: a card of each generation opens one.
|
||||
h.get_by_label("VectorCraft details");
|
||||
h.get_by_label("WordCraft details");
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
snapshot(&mut h, "details-dark");
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
h.get_by_label_contains("Curves and type layers · 1/4");
|
||||
// The links the page offers as buttons of their own.
|
||||
h.get_by_label("Website ↗");
|
||||
h.get_by_label("GitHub ↗");
|
||||
h.get_by_label("Releases ↗");
|
||||
// The picture arrows wrap around, and the thumbnails jump.
|
||||
h.get_by_label("Previous picture (←)").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Selections and masked adjustments · 4/4");
|
||||
h.get_by_label("Next picture (→)").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Curves and type layers · 1/4");
|
||||
// Esc closes it and the page is gone once it has slid out.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
assert!(h.query_by_label_contains("The image editor you already know").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_drawer_in_the_light_theme() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Light, true);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
snapshot(&mut h, "details-light");
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
}
|
||||
|
||||
fn fetches(sent: &Rc<RefCell<Vec<Command>>>, app: &str) -> usize {
|
||||
sent.borrow().iter().filter(|c| matches!(c, Command::FetchScreenshots { app: a } if a == app)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_page_asks_for_its_pictures_once_and_only_while_it_lacks_some() {
|
||||
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
assert_eq!(fetches(&sent, "photocraft"), 0, "nothing is fetched before a page is opened");
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
assert_eq!(fetches(&sent, "photocraft"), 1);
|
||||
h.get_by_label("Enlarge picture");
|
||||
// Closed and opened again: the pictures are in hand, so nothing is asked.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
assert_eq!(fetches(&sent, "photocraft"), 1);
|
||||
h.get_by_label("Enlarge picture");
|
||||
// Another page asks for its own, and a page without pictures asks for nothing.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
h.get_by_label("VectorCraft details").click();
|
||||
h.run_steps(10);
|
||||
assert_eq!(fetches(&sent, "vectorcraft"), 1);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
h.get_by_label("PdfCraft details").click();
|
||||
h.run_steps(10);
|
||||
assert_eq!(fetches(&sent, "pdfcraft"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_stands_in_for_the_pictures_until_they_arrive() {
|
||||
let pictures = Rc::new(Cell::new(Pictures::Silent));
|
||||
let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures.clone());
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
snapshot(&mut h, "details-loading-dark");
|
||||
h.get_by_label("Loading pictures…");
|
||||
assert!(h.query_by_label("Enlarge picture").is_none(), "there is nothing to enlarge yet");
|
||||
// The rest of the page is there, and the arrows work on the frames.
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
h.get_by_label("Next picture (→)").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Levels with a live histogram · 2/4");
|
||||
// Asking again meanwhile is not done: the answer is on its way.
|
||||
assert_eq!(fetches(&sent, "photocraft"), 1);
|
||||
pictures.set(Pictures::Online);
|
||||
h.run_steps(6);
|
||||
assert!(h.query_by_label("Loading pictures…").is_none());
|
||||
h.get_by_label("Enlarge picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_the_frame_says_so_and_opening_the_page_again_retries() {
|
||||
let pictures = Rc::new(Cell::new(Pictures::Offline));
|
||||
let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Light, true, 1.0, pictures.clone());
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
snapshot(&mut h, "details-offline-light");
|
||||
h.get_by_label("Pictures couldn't be loaded. Reopen this page to try again.");
|
||||
assert!(h.query_by_label("Loading pictures…").is_none());
|
||||
assert!(h.query_by_label("Enlarge picture").is_none());
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
// Back online: the page is closed and opened again, which asks again.
|
||||
pictures.set(Pictures::Online);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
assert_eq!(fetches(&sent, "photocraft"), 2);
|
||||
h.get_by_label("Enlarge picture");
|
||||
assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enlarged_view_shows_a_frame_for_a_picture_still_missing() {
|
||||
let pictures = Rc::new(Cell::new(Pictures::SecondFails));
|
||||
let (mut h, _sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
h.get_by_label("Enlarge picture").click();
|
||||
h.run_steps(3);
|
||||
// The second picture is missing: flipping to it keeps the view open, with a frame; the others still show.
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(3);
|
||||
// Once in the drawer behind (it shows the same picture) and once in the enlarged view.
|
||||
assert_eq!(h.query_all_by_label("Pictures couldn't be loaded. Reopen this page to try again.").count(), 2);
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2, "the enlarged view is still open");
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none(), "the third picture is fine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_without_pictures_has_no_gallery() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
// PdfCraft's screenshots still show its old name (PrintCraft), so its page has none for now.
|
||||
h.get_by_label("PdfCraft details").click();
|
||||
h.run_steps(10);
|
||||
snapshot(&mut h, "details-no-pictures-dark");
|
||||
h.get_by_label_contains("The open-source PDF workbench.");
|
||||
h.get_by_label_contains("Search as you type");
|
||||
assert!(h.query_by_label("Enlarge picture").is_none());
|
||||
assert!(h.query_by_label("Next picture (→)").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lead_picture_can_be_enlarged() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
// The drawer has one "Close (Esc)"; the enlarged view adds its own.
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
|
||||
h.get_by_label("Enlarge picture").click();
|
||||
h.run_steps(4);
|
||||
snapshot(&mut h, "details-zoom-dark");
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2);
|
||||
// The keyboard flips through the pictures there too.
|
||||
h.key_press(egui::Key::ArrowRight);
|
||||
h.run_steps(3);
|
||||
h.get_by_label_contains("Levels with a live histogram · 2/4");
|
||||
// Esc closes the enlarged picture first, and the drawer only the second time.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
assert!(h.query_by_label_contains("The image editor you already know").is_none());
|
||||
}
|
||||
|
||||
/// Every page, whatever it holds: no pictures (PdfCraft), one (DesignCraft) or several. The arrow keys are
|
||||
/// pressed more often than there are pictures, both ways, to wrap around the list and to do nothing on a
|
||||
/// page that has one or none; the enlarged view is opened wherever there is a picture to enlarge.
|
||||
#[test]
|
||||
fn every_page_opens_and_pages_through_its_pictures() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
// Tall enough to show all twelve cards at once.
|
||||
h.set_size(egui::vec2(1180.0, 3000.0));
|
||||
h.run_steps(8);
|
||||
for d in &artcraft_launcher_core::details::DETAILS {
|
||||
let name = SUITE.iter().find(|a| a.id == d.id).map(|a| a.name).unwrap_or(d.id);
|
||||
h.get_by_label(&format!("{name} details")).click();
|
||||
h.run_steps(8);
|
||||
h.get_by_label_contains(d.headline);
|
||||
let presses = d.shots.len() + 2;
|
||||
for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] {
|
||||
for _ in 0..presses {
|
||||
h.key_press(key);
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
// The picture can be enlarged exactly when the page has one.
|
||||
assert_eq!(h.query_by_label("Enlarge picture").is_some(), !d.shots.is_empty(), "{}", d.id);
|
||||
if !d.shots.is_empty() {
|
||||
h.get_by_label("Enlarge picture").click();
|
||||
h.run_steps(3);
|
||||
for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] {
|
||||
for _ in 0..presses {
|
||||
h.key_press(key);
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
// A full lap in each direction brings it back to the first picture. Its caption is in the drawer
|
||||
// and in the enlarged view, and has a counter only when there are several pictures.
|
||||
let first = &d.shots[0];
|
||||
let caption = if d.shots.len() > 1 { format!("{} · 1/{}", first.caption, d.shots.len()) } else { first.caption.to_string() };
|
||||
assert!(h.query_all_by_label_contains(&caption).count() >= 1, "{}: {caption}", d.id);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(10);
|
||||
assert!(h.query_by_label_contains(d.headline).is_none(), "{} did not close", d.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawer and the enlarged picture at sizes far below what the desktop window allows (its minimum is
|
||||
/// 720 x 500, but the web build has no such floor) and far above, resized while they are open.
|
||||
#[test]
|
||||
fn the_drawer_and_the_enlarged_picture_survive_any_window_size() {
|
||||
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("PhotoCraft details").click();
|
||||
h.run_steps(10);
|
||||
let sizes = [(1180.0, 900.0), (720.0, 500.0), (480.0, 320.0), (320.0, 200.0), (120.0, 90.0), (40.0, 30.0), (4000.0, 2400.0)];
|
||||
for zoom in [false, true] {
|
||||
if zoom {
|
||||
h.get_by_label("Enlarge picture").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
for (w, ht) in sizes {
|
||||
h.set_size(egui::vec2(w, ht));
|
||||
h.run_steps(3);
|
||||
}
|
||||
// Back to a normal size, still working.
|
||||
h.set_size(egui::vec2(1180.0, 900.0));
|
||||
h.run_steps(4);
|
||||
if zoom {
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.get_by_label_contains("The image editor you already know how to use");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rate_limit_notice_never_runs_under_the_footer_links() {
|
||||
for width in [720.0, 960.0, 1180.0] {
|
||||
let mut evs = events();
|
||||
evs.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: Some(NOW + 1800) });
|
||||
let mut backend = Some(Fake { events: evs, sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake {
|
||||
events: evs,
|
||||
sent: Rc::new(RefCell::new(Vec::new())),
|
||||
manage: true,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
@@ -1034,7 +1380,8 @@ impl Host for OsMotion {
|
||||
fn short_window(reduce: Rc<Cell<bool>>) -> Harness<'static, Option<LauncherUi>> {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let events = events().into_iter().filter(|e| !matches!(e, Event::Progress { .. })).collect();
|
||||
let mut backend = Some(Fake { events, sent: Rc::default(), manage: true, clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend =
|
||||
Some(Fake { events, sent: Rc::default(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut host = OsMotion(reduce);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 500.0)).with_step_dt(1.0 / 60.0).with_max_steps(120).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
|
||||
Reference in new issue
Block a user