Compare commits
1 Commits
main
...
fix-es-ins
| Author | SHA1 | Date |
|---|---|---|
|
|
84f6b9475d |
26
AGENTS.md
26
AGENTS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
ArtCraft Launcher installs, updates and launches the **Craft App Suite** (PhotoCraft, LightCraft,
|
||||
FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft,
|
||||
CADCraft, SoundCraft, FigCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
|
||||
CADCraft, SoundCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
|
||||
app is native egui/eframe, and the web build is the same Rust compiled to WebAssembly (trunk +
|
||||
wasm-bindgen). **No Tauri, Electron or webview shells**, ever.
|
||||
|
||||
|
|
@ -62,16 +62,8 @@ 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/craft-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.
|
||||
- **Agents never land or approve a third-party app.** A change that adds a catalog row, download
|
||||
source or install path for an app whose repo is not under `storytold/`, or that loosens the
|
||||
rules above to allow one, is a maintainer's call alone. An agent reviewing it flags it at the
|
||||
top of its review (who owns the repo, its age and history, whether the builds are signed) and
|
||||
stops there: no merge, no approval, no fixes pushed to the PR.
|
||||
|
||||
### Look and feel
|
||||
|
||||
|
|
@ -80,22 +72,14 @@ hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % widt
|
|||
and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
|
||||
colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
|
||||
|
||||
Animations follow the OS: with Windows' "Animation effects" off, macOS' "Reduce motion" on, a Linux
|
||||
desktop's animations off or a browser's `prefers-reduced-motion`, nothing fades, wipes or glides
|
||||
(`Host::reduce_motion`, `crates/ui-egui/src/motion.rs`). Time a new animation with egui's
|
||||
`style.animation_time`, or switch it off on `LauncherUi::reduce_motion`. Progress bars and
|
||||
spinners keep moving.
|
||||
|
||||
### Languages
|
||||
|
||||
Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt`
|
||||
for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string
|
||||
means adding its row to every catalog marked `complete_menus` (the `*.tsv` files in
|
||||
`crates/ui-egui/src/i18n/`), which the i18n tests check. Translate clean-room from the meaning of
|
||||
the English text, never through another catalog. Product and app names (ArtCraft Launcher, Craft
|
||||
App Suite, PhotoCraft…) stay in English.
|
||||
A CJK language is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop
|
||||
only; English where there is none): never bundle a CJK font, they are 10 MB and more.
|
||||
means adding its row to every catalog marked `complete_menus` (`ru.tsv`, `zh-CN.tsv`, `de.tsv`),
|
||||
which the i18n tests check. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English.
|
||||
Chinese is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop only;
|
||||
English where there is none): never bundle a CJK font, they are 10 MB and more.
|
||||
|
||||
### Other rules
|
||||
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ next to them.
|
|||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in blue (getartcraft.com's favicon) | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
|
||||
| `packaging/macos/dmg/` (all files) | macOS DMG window background and layout | ArtCraft Launcher contributors, with the ArtCraft mark | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md); the mark is `docs/brand/artcraft-mark-black.svg`, linked from `background.svg` and rendered unmodified into `background.tiff` | MIT OR Apache-2.0, like the code; the ArtCraft mark in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)). The TIFF carries LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
| `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/craft-launcher-assets](https://github.com/storytold/craft-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) |
|
||||
|
|
@ -42,7 +40,6 @@ licensed like those apps. Refresh them from the source path, never redraw them.
|
|||
| `assets/app-icons/gridcraft.png` | GridCraft app icon (app icon), 128 px | ArtCraft Team and the GridCraft contributors | [`storytold/gridcraft`](https://github.com/storytold/gridcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png`, copied unmodified | MIT OR Apache-2.0 (GridCraft's [`LICENSE-MIT`](https://github.com/storytold/gridcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/gridcraft/blob/main/LICENSE-APACHE)) |
|
||||
| `assets/app-icons/cadcraft.png` | CADCraft app icon (app icon), 128 px | ArtCraft Team and the CADCraft contributors | [`storytold/cadcraft`](https://github.com/storytold/cadcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png`, copied unmodified | MIT OR Apache-2.0 (CADCraft's [`LICENSE-MIT`](https://github.com/storytold/cadcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/cadcraft/blob/main/LICENSE-APACHE)) |
|
||||
| `assets/app-icons/soundcraft.png` | SoundCraft app icon (nightingale (placeholder)), 128 px | ArtCraft Team and the SoundCraft contributors | [`storytold/soundcraft`](https://github.com/storytold/soundcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png`, copied unmodified | MIT OR Apache-2.0 (SoundCraft's [`LICENSE-MIT`](https://github.com/storytold/soundcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/soundcraft/blob/main/LICENSE-APACHE)) |
|
||||
| `assets/app-icons/figcraft.png` | FigCraft app icon (chameleon), 128 px | ArtCraft Team and the FigCraft contributors | [`storytold/figcraft`](https://github.com/storytold/figcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png`, copied unmodified | MIT OR Apache-2.0 (FigCraft's [`LICENSE-MIT`](https://github.com/storytold/figcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/figcraft/blob/main/LICENSE-APACHE)) |
|
||||
|
||||
## Documentation and README
|
||||
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"artcraft-launcher-native",
|
||||
|
|
@ -293,14 +293,12 @@ dependencies = [
|
|||
"ron",
|
||||
"serde",
|
||||
"tray-icon",
|
||||
"winreg",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-core"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
|
|
@ -309,7 +307,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-native"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"dirs 6.0.0",
|
||||
|
|
@ -327,7 +325,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-ui-egui"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"egui",
|
||||
|
|
@ -343,7 +341,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "artcraft-launcher-web"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"artcraft-launcher-core",
|
||||
"artcraft-launcher-ui-egui",
|
||||
|
|
@ -2285,8 +2283,6 @@ dependencies = [
|
|||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5803,7 +5799,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ resolver = "3"
|
|||
members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.1"
|
||||
version = "0.3.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -12,7 +12,7 @@
|
|||
<p align="center">
|
||||
<b>One place to install, update and open every app in the Craft App Suite, rebuilt in pure Rust.</b><br>
|
||||
PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft,<br>
|
||||
DeckCraft, GridCraft, CADCraft, SoundCraft and FigCraft: the right build for your computer, kept up to date.
|
||||
DeckCraft, GridCraft, CADCraft and SoundCraft: the right build for your computer, kept up to date.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -84,24 +84,21 @@ The web version shows the same catalog in the browser, with the latest download
|
|||
|
||||
## Interface languages
|
||||
|
||||
The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French
|
||||
(Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian
|
||||
(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the
|
||||
system or browser language; **Settings → Language** picks one and switches at once, and the choice
|
||||
is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`,
|
||||
`de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones
|
||||
(`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese,
|
||||
Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…)
|
||||
Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified
|
||||
Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select
|
||||
Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the
|
||||
desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address.
|
||||
The interface is in English, Brazilian Portuguese (Português (Brasil)), French (Français), German
|
||||
(Deutsch), Russian (Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the
|
||||
default, follows the system or browser language; **Settings → Language** picks one and switches at
|
||||
once, and the choice is kept between runs. German locales (`de-DE`, `de-AT`, `de-CH`,
|
||||
`de_DE.UTF-8`…) select German, French ones (`fr-FR`, `fr-CA`…) French, Spanish ones (`es-ES`,
|
||||
`es-MX`, `es-419`…) Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian
|
||||
Portuguese. Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as
|
||||
`zh_CN.UTF-8`) select Chinese; Traditional Chinese ones don't. To try a language without changing
|
||||
the setting, start the desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the
|
||||
web address.
|
||||
|
||||
The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS,
|
||||
Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and
|
||||
FreeBSD). Where the system has no font for one, and in the web version, the interface stays in
|
||||
English and Settings says why. Product names and release notes stay as published; text without a
|
||||
translation falls back to English.
|
||||
Chinese is drawn with the system's own Chinese font (PingFang on macOS, Microsoft YaHei on
|
||||
Windows, Noto Sans CJK or WenQuanYi on Linux and FreeBSD). Where there is none, and in the web
|
||||
version, the interface stays in English and Settings says why. Product names and release notes
|
||||
stay as published; text without a translation falls back to English.
|
||||
|
||||
## Where apps are installed
|
||||
|
||||
|
|
@ -247,7 +244,6 @@ stand on its own.
|
|||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
|||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
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};
|
||||
|
|
@ -96,14 +95,8 @@ pub fn start() {
|
|||
});
|
||||
}
|
||||
|
||||
/// The browser: nothing of the desktop's, but it knows whether the visitor asked for fewer animations.
|
||||
struct WebHost;
|
||||
|
||||
impl Host for WebHost {
|
||||
fn reduce_motion(&self) -> bool {
|
||||
web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()).is_some_and(|query| query.matches())
|
||||
}
|
||||
}
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
struct WebApp {
|
||||
ui: LauncherUi,
|
||||
|
|
@ -111,15 +104,11 @@ struct WebApp {
|
|||
|
||||
impl eframe::App for WebApp {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.ui.logic(ctx, &mut WebHost);
|
||||
self.ui.logic(ctx, &mut NoHost);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.ui.ui(ui, &mut WebHost);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
self.ui.raw_input(ctx, raw_input);
|
||||
self.ui.ui(ui, &mut NoHost);
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
|
|
@ -133,43 +122,11 @@ 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, 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);
|
||||
});
|
||||
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false }
|
||||
}
|
||||
|
||||
fn check(&self) {
|
||||
|
|
@ -208,49 +165,6 @@ 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 {
|
||||
|
|
@ -312,7 +226,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -31,31 +31,17 @@ notify-rust = "4"
|
|||
ron = "0.12.2"
|
||||
serde = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
zbus = "5"
|
||||
|
||||
# The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus
|
||||
# directly, so no GTK or libappindicator is needed.
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
|
||||
tray-icon = "0.25"
|
||||
|
||||
# macOS only: which desktop (Space) the window shows on (spaces.rs) and the "Reduce motion"
|
||||
# setting (motion.rs). Safe AppKit calls only, and already in Cargo.lock through winit and
|
||||
# tray-icon; pinned exactly to those versions.
|
||||
# macOS only: which desktop (Space) the window shows on (spaces.rs). Safe AppKit calls only, and
|
||||
# already in Cargo.lock through winit and tray-icon; pinned exactly to those versions.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "=0.6.5"
|
||||
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
|
||||
|
||||
# Windows only: the "Animation effects" setting (motion.rs). Already in Cargo.lock through
|
||||
# artcraft-launcher-native.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
||||
# Linux and the BSDs: the desktop's animations setting, from its settings portal (motion.rs).
|
||||
# Already in Cargo.lock through tray-icon and notify-rust.
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies]
|
||||
tray-icon = { version = "0.25", default-features = false, features = ["ksni"] }
|
||||
|
|
|
|||
|
|
@ -6,12 +6,9 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod logging;
|
||||
mod motion;
|
||||
mod settings_file;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod spaces;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod system_theme;
|
||||
mod tray;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -77,18 +74,9 @@ fn main() -> ExitCode {
|
|||
let _ = wake.set(Box::new(move || waker.wake()));
|
||||
}
|
||||
|
||||
// The desktop's animation setting comes over D-Bus: ask now, so the answer is in by the first frame.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let _ = motion::reduced();
|
||||
|
||||
let backend_waker = waker.clone();
|
||||
let settings_path = settings_file::path(APP_ID);
|
||||
let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone());
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let theme_waker = waker.clone();
|
||||
launcher.system_theme = system_theme::Monitor::start(move || theme_waker.wake());
|
||||
}
|
||||
// Started hidden at login where a window can't hide: start in the tray without mapping one,
|
||||
// so no window flashes up. Without a tray to come back from, the window opens after all.
|
||||
let mut window = true;
|
||||
|
|
@ -161,19 +149,12 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
|
|||
/// Adrenalin installer leaves the integrated GPU on the OEM driver) it crashes inside
|
||||
/// `amdvlk64.dll` before the first frame. `WGPU_BACKEND` still overrides the default, and then
|
||||
/// wgpu picks the adapter itself. Direct3D 12 shaders compile with FXC ([`pin_dx12_compiler`]).
|
||||
///
|
||||
/// Direct3D 12 presents through DirectComposition rather than straight to the window. The
|
||||
/// borderless window's drop shadow moves its client area down a pixel, and a swapchain made from
|
||||
/// the window is then stretched over it vertically by the compositor, blurring every horizontal
|
||||
/// edge and line of text. `WGPU_DX12_PRESENTATION_SYSTEM` still overrides it.
|
||||
#[cfg(windows)]
|
||||
fn wgpu_options() -> eframe::WgpuConfiguration {
|
||||
use eframe::wgpu::{Backends, Dx12Compiler, Dx12SwapchainKind, PowerPreference};
|
||||
use eframe::wgpu::{Backends, Dx12Compiler, PowerPreference};
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
pin_dx12_compiler(&mut config, Dx12Compiler::from_env());
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
setup.instance_descriptor.backend_options.dx12.presentation_system =
|
||||
Dx12SwapchainKind::from_env().unwrap_or(Dx12SwapchainKind::DxgiFromVisual);
|
||||
if let Some(backends) = Backends::from_env() {
|
||||
setup.instance_descriptor.backends = backends;
|
||||
} else {
|
||||
|
|
@ -225,38 +206,7 @@ fn adapter_rank(backend: eframe::wgpu::Backend, device_type: eframe::wgpu::Devic
|
|||
(backend != Backend::Dx12, device)
|
||||
}
|
||||
|
||||
/// wgpu's adapter on Linux and the BSDs: the GPU the desktop runs on. egui asks for the
|
||||
/// high-performance GPU, which on a hybrid laptop in PRIME on-demand mode is the discrete one while
|
||||
/// the display server runs on the integrated one: every frame then crosses between the GPUs, and
|
||||
/// while the window is resized it shows stale or garbled frames and can stop drawing (issue #69).
|
||||
/// With no preference wgpu takes the first adapter Vulkan lists, and Mesa's device-select layer
|
||||
/// lists the display server's GPU first (or the one `DRI_PRIME` picks). Without that layer the
|
||||
/// order follows the ICD files, where lavapipe (`lvp_icd`) can sort before the real driver, so a
|
||||
/// software rasterizer only wins when nothing else can draw. `WGPU_POWER_PREF` (`low`, `high` or
|
||||
/// `none`) still overrides it, and then wgpu picks the adapter itself.
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
fn wgpu_options() -> eframe::WgpuConfiguration {
|
||||
use eframe::wgpu::{DeviceType, PowerPreference};
|
||||
let mut config = eframe::WgpuConfiguration::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
|
||||
if let Some(preference) = PowerPreference::from_env() {
|
||||
setup.power_preference = preference;
|
||||
} else {
|
||||
setup.power_preference = PowerPreference::None;
|
||||
setup.native_adapter_selector = Some(Arc::new(|adapters, surface| {
|
||||
adapters
|
||||
.iter()
|
||||
.filter(|a| surface.is_none_or(|s| a.is_surface_supported(s)))
|
||||
.min_by_key(|a| a.get_info().device_type == DeviceType::Cpu)
|
||||
.cloned()
|
||||
.ok_or_else(|| "no graphics adapter can draw to the window".to_owned())
|
||||
}));
|
||||
}
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(not(windows))]
|
||||
fn wgpu_options() -> eframe::WgpuConfiguration {
|
||||
eframe::WgpuConfiguration::default()
|
||||
}
|
||||
|
|
@ -432,10 +382,6 @@ impl Host for DesktopHost {
|
|||
login::set_enabled(on)
|
||||
}
|
||||
|
||||
fn reduce_motion(&self) -> bool {
|
||||
motion::reduced()
|
||||
}
|
||||
|
||||
fn quit(&mut self) {
|
||||
self.quit = true;
|
||||
}
|
||||
|
|
@ -444,8 +390,6 @@ impl Host for DesktopHost {
|
|||
/// Everything that outlives a window: the UI state and its backend, the tray icon, and the work
|
||||
/// that goes on with or without a window.
|
||||
struct Launcher {
|
||||
#[cfg(target_os = "linux")]
|
||||
system_theme: system_theme::Monitor,
|
||||
ui: LauncherUi,
|
||||
tray: Option<tray::Tray>,
|
||||
host: DesktopHost,
|
||||
|
|
@ -473,8 +417,6 @@ impl Launcher {
|
|||
let _ = asked.send(info.delay);
|
||||
});
|
||||
Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
system_theme: system_theme::Monitor::default(),
|
||||
ui: LauncherUi::new(&windowless, backend, Settings::default()),
|
||||
tray: None,
|
||||
host: DesktopHost { tray: false, quit: false },
|
||||
|
|
@ -706,12 +648,6 @@ impl eframe::App for Shell<'_> {
|
|||
self.launcher.ui.ui(ui, &mut self.launcher.host);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
#[cfg(target_os = "linux")]
|
||||
self.launcher.system_theme.apply(raw_input);
|
||||
self.launcher.ui.raw_input(ctx, raw_input);
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
eframe::set_value(storage, SETTINGS_KEY, &self.launcher.ui.settings);
|
||||
}
|
||||
|
|
@ -1008,17 +944,4 @@ mod tests {
|
|||
assert!(adapter_rank(Backend::Dx12, discrete, false) < adapter_rank(Backend::Dx12, integrated, false));
|
||||
assert!(adapter_rank(Backend::Dx12, integrated, true) < adapter_rank(Backend::Dx12, discrete, true));
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
#[test]
|
||||
fn linux_draws_on_the_desktops_gpu_unless_told_otherwise() {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
let config = wgpu_options();
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &config.wgpu_setup else {
|
||||
panic!("the launcher creates its own wgpu instance");
|
||||
};
|
||||
// egui would ask for the discrete GPU: on a PRIME laptop that's not the desktop's (issue #69).
|
||||
let expected = PowerPreference::from_env().unwrap_or(PowerPreference::None);
|
||||
assert_eq!(setup.power_preference, expected);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
//! Whether the OS asks for fewer animations: Windows' "Animation effects" switched off, macOS'
|
||||
//! "Reduce motion", a Linux or BSD desktop's animations setting. The UI asks about once a second
|
||||
//! while it draws (`Host::reduce_motion`), so a change shows without a restart.
|
||||
|
||||
/// Does the OS ask for fewer animations? Animations stay on where it can't be told.
|
||||
#[cfg(windows)]
|
||||
pub fn reduced() -> bool {
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::HKEY_CURRENT_USER;
|
||||
RegKey::predef(HKEY_CURRENT_USER)
|
||||
.open_subkey(r"Control Panel\Desktop")
|
||||
.and_then(|desktop| desktop.get_raw_value("UserPreferencesMask"))
|
||||
.is_ok_and(|mask| animations_off(&mask.bytes))
|
||||
}
|
||||
|
||||
/// Windows keeps `SPI_GETCLIENTAREAANIMATION` (the switch in Settings, and what browsers follow
|
||||
/// for `prefers-reduced-motion`) as bit 33 of `UserPreferencesMask`. Reading the registry stands
|
||||
/// in for `SystemParametersInfoW`, which needs `unsafe`. A mask too short for the bit says nothing.
|
||||
#[cfg(any(windows, test))]
|
||||
fn animations_off(mask: &[u8]) -> bool {
|
||||
mask.get(4).is_some_and(|byte| byte & 0b10 == 0)
|
||||
}
|
||||
|
||||
/// Does the OS ask for fewer animations?
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn reduced() -> bool {
|
||||
objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()
|
||||
}
|
||||
|
||||
/// Does the desktop ask for fewer animations? What its settings portal last said: the first call
|
||||
/// starts a thread that asks it and then follows its changes, as an answer over D-Bus can take a
|
||||
/// while and the UI must not wait for one. Animations stay on until it has answered, and where
|
||||
/// there is no portal.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub fn reduced() -> bool {
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::Ordering;
|
||||
static WATCH: Once = Once::new();
|
||||
WATCH.call_once(|| {
|
||||
std::thread::spawn(|| {
|
||||
if let Err(e) = portal::watch() {
|
||||
log::debug!("the desktop's animation setting can't be read: {e}");
|
||||
}
|
||||
});
|
||||
});
|
||||
portal::REDUCED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The XDG desktop portal's settings, which a sandboxed Flatpak can read as well.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod portal {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
pub static REDUCED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Keeps [`REDUCED`] to what the portal says, for as long as the session bus is there.
|
||||
pub fn watch() -> zbus::Result<()> {
|
||||
let bus = Connection::session()?;
|
||||
let settings = Proxy::new(&bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
|
||||
let changes = settings.receive_signal("SettingChanged")?;
|
||||
REDUCED.store(reduced(&settings), Ordering::Relaxed);
|
||||
for _ in changes {
|
||||
REDUCED.store(reduced(&settings), Ordering::Relaxed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Any one of the settings desktops keep this in asking for fewer animations is enough: the
|
||||
/// portal's own `reduced-motion`, GNOME's `enable-animations` (which desktops built on GTK
|
||||
/// share), and KDE's animation speed, where 0 is "instant".
|
||||
fn reduced(settings: &Proxy<'_>) -> bool {
|
||||
let read = |namespace: &str, key: &str| {
|
||||
// `ReadOne` is the newer call; older portals only have `Read`.
|
||||
["ReadOne", "Read"].iter().find_map(|method| settings.call::<_, _, OwnedValue>(*method, &(namespace, key)).ok())
|
||||
};
|
||||
read("org.freedesktop.appearance", "reduced-motion").is_some_and(|v| matches!(plain(&v), Value::U32(1)))
|
||||
|| read("org.gnome.desktop.interface", "enable-animations").is_some_and(|v| matches!(plain(&v), Value::Bool(false)))
|
||||
|| read("org.kde.kdeglobals.KDE", "AnimationDurationFactor").is_some_and(|v| match plain(&v) {
|
||||
Value::Str(factor) => factor.trim().parse::<f64>().is_ok_and(|f| f == 0.0),
|
||||
Value::F64(factor) => *factor == 0.0,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value inside the variants the portal wraps it in (`Read` wraps it twice).
|
||||
fn plain<'v>(value: &'v Value<'v>) -> &'v Value<'v> {
|
||||
match value {
|
||||
Value::Value(inner) => plain(inner),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_animation_setting_is_bit_33_of_the_mask() {
|
||||
// Windows' default mask, then one from a machine with "Animation effects" switched off.
|
||||
assert!(!animations_off(&[0x9E, 0x1E, 0x07, 0x80, 0x12, 0x00, 0x00, 0x00]));
|
||||
assert!(animations_off(&[0x90, 0x12, 0x03, 0x80, 0x10, 0x00, 0x00, 0x00]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_mask_leaves_animations_on() {
|
||||
assert!(!animations_off(&[]));
|
||||
assert!(!animations_off(&[0x90, 0x12, 0x03, 0x80]));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
//! The desktop appearance portal supplies the theme that winit cannot read on Wayland.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use zbus::blocking::{Proxy, connection::Builder};
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
const APPEARANCE: &str = "org.freedesktop.appearance";
|
||||
const COLOR_SCHEME: &str = "color-scheme";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Monitor(Arc<AtomicU8>);
|
||||
|
||||
impl Monitor {
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
let monitor = Self::default();
|
||||
let state = Arc::downgrade(&monitor.0);
|
||||
if let Err(e) = std::thread::Builder::new().name("system-theme".into()).spawn(move || {
|
||||
let publish = |scheme| {
|
||||
if let Some(state) = state.upgrade()
|
||||
&& state.swap(scheme, Ordering::Relaxed) != scheme
|
||||
{
|
||||
log::debug!("desktop appearance: {:?}", theme(scheme));
|
||||
wake();
|
||||
}
|
||||
};
|
||||
// Retry after the portal goes away, backing off to every 5 minutes where there's none.
|
||||
let mut retry = Duration::from_secs(5);
|
||||
while state.strong_count() > 0 {
|
||||
match watch(&publish) {
|
||||
Ok(()) => retry = Duration::from_secs(5),
|
||||
Err(e) => log::debug!("desktop appearance portal: {e}"),
|
||||
}
|
||||
publish(0);
|
||||
std::thread::sleep(retry);
|
||||
retry = (retry * 2).min(Duration::from_secs(300));
|
||||
}
|
||||
}) {
|
||||
log::warn!("couldn't monitor the system theme: {e}");
|
||||
}
|
||||
monitor
|
||||
}
|
||||
|
||||
pub fn apply(&self, input: &mut egui::RawInput) {
|
||||
if let Some(theme) = theme(self.0.load(Ordering::Relaxed)) {
|
||||
input.system_theme = Some(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn theme(scheme: u8) -> Option<egui::Theme> {
|
||||
match scheme {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme(value: OwnedValue) -> u8 {
|
||||
// Read returns an extra variant wrapper; ReadOne and SettingChanged do not.
|
||||
let value = match Value::from(value) {
|
||||
Value::Value(value) => *value,
|
||||
value => value,
|
||||
};
|
||||
match u32::try_from(value) {
|
||||
Ok(1) => 1,
|
||||
Ok(2) => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn watch(publish: &impl Fn(u8)) -> zbus::Result<()> {
|
||||
let connection = Builder::session()?.method_timeout(Duration::from_secs(3)).build()?;
|
||||
let proxy = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
|
||||
// Subscribe before reading so a theme switch during startup is not missed.
|
||||
let changes = proxy.receive_signal_with_args("SettingChanged", &[(0, APPEARANCE), (1, COLOR_SCHEME)])?;
|
||||
let initial: OwnedValue = proxy.call("ReadOne", &(APPEARANCE, COLOR_SCHEME)).or_else(|_| proxy.call("Read", &(APPEARANCE, COLOR_SCHEME)))?;
|
||||
publish(scheme(initial));
|
||||
for message in changes {
|
||||
let (namespace, key, value): (String, String, OwnedValue) = message.body().deserialize()?;
|
||||
if namespace == APPEARANCE && key == COLOR_SCHEME {
|
||||
publish(scheme(value));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a session bus and a desktop portal with an explicit color preference"]
|
||||
fn running_desktop_portal_reaches_egui() {
|
||||
let (send, receive) = std::sync::mpsc::channel();
|
||||
let monitor = Monitor::start(move || {
|
||||
let _ = send.send(());
|
||||
});
|
||||
assert!(receive.recv_timeout(Duration::from_secs(10)).is_ok());
|
||||
let mut input = egui::RawInput::default();
|
||||
monitor.apply(&mut input);
|
||||
assert!(input.system_theme.is_some());
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_theme(egui::ThemePreference::System);
|
||||
ctx.begin_pass(input.clone());
|
||||
assert_eq!(Some(ctx.theme()), input.system_theme);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_values_include_no_preference_and_unknown_values() {
|
||||
for (value, expected) in [(0, None), (1, Some(egui::Theme::Dark)), (2, Some(egui::Theme::Light)), (3, None), (u32::MAX, None)] {
|
||||
assert_eq!(theme(scheme(OwnedValue::from(value))), expected);
|
||||
}
|
||||
assert_eq!(scheme(OwnedValue::from(true)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_unwraps_the_extra_variant() {
|
||||
let value = Value::Value(Box::new(Value::U32(2)));
|
||||
assert_eq!(value.try_to_owned().map(scheme).ok(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_changes_preserve_explicit_choices_and_unknown_falls_back_to_winit() {
|
||||
let monitor = Monitor::default();
|
||||
let ctx = egui::Context::default();
|
||||
for preference in [egui::ThemePreference::System, egui::ThemePreference::Light, egui::ThemePreference::Dark] {
|
||||
ctx.set_theme(preference);
|
||||
for scheme in [1, 2, 0] {
|
||||
monitor.0.store(scheme, Ordering::Relaxed);
|
||||
let mut input = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
|
||||
monitor.apply(&mut input);
|
||||
let expected = match preference {
|
||||
egui::ThemePreference::Light => egui::Theme::Light,
|
||||
egui::ThemePreference::Dark => egui::Theme::Dark,
|
||||
egui::ThemePreference::System => theme(scheme).unwrap_or(egui::Theme::Dark),
|
||||
};
|
||||
ctx.begin_pass(input);
|
||||
assert_eq!(ctx.theme(), expected);
|
||||
ctx.end_pass().textures_delta.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
//! The Craft App Suite: the thirteen apps the launcher knows about.
|
||||
//! The Craft App Suite: the twelve apps the launcher knows about.
|
||||
//!
|
||||
//! The list is data so a later version can fetch it from an API; [`SUITE`] is the built-in copy.
|
||||
//!
|
||||
|
|
@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
|||
pub enum AppGroup {
|
||||
/// The seven original apps.
|
||||
Classic,
|
||||
/// The six apps added after them.
|
||||
/// The five apps added after them.
|
||||
New,
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +94,8 @@ const fn app(
|
|||
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[], former_names: &[] }
|
||||
}
|
||||
|
||||
/// The Craft App Suite, in display order: the seven classic apps, then the six new ones.
|
||||
pub const SUITE: [CraftApp; 13] = [
|
||||
/// The Craft App Suite, in display order: the seven classic apps, then the five new ones.
|
||||
pub const SUITE: [CraftApp; 12] = [
|
||||
app(
|
||||
"photocraft",
|
||||
"PhotoCraft",
|
||||
|
|
@ -196,14 +196,6 @@ pub const SUITE: [CraftApp; 13] = [
|
|||
[[0x1f, 0xa5, 0xc9], [0x0e, 0x72, 0x90], [0x5f, 0xd0, 0xee]],
|
||||
"storytold/soundcraft",
|
||||
),
|
||||
app(
|
||||
"figcraft",
|
||||
"FigCraft",
|
||||
"Interface design and prototyping",
|
||||
AppGroup::New,
|
||||
[[0x02, 0xda, 0x96], [0x04, 0x7a, 0x56], [0x4f, 0xe8, 0xb6]],
|
||||
"storytold/figcraft",
|
||||
),
|
||||
];
|
||||
|
||||
/// ArtCraft's website.
|
||||
|
|
@ -247,9 +239,9 @@ mod tests {
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suite_has_seven_classic_and_six_new_apps_with_unique_ids() {
|
||||
fn suite_has_seven_classic_and_five_new_apps_with_unique_ids() {
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::Classic).count(), 7);
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 6);
|
||||
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 5);
|
||||
let mut ids: Vec<_> = SUITE.iter().map(|a| a.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
|
|
|
|||
|
|
@ -1,483 +0,0 @@
|
|||
//! 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/craft-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; 13] = [
|
||||
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,
|
||||
},
|
||||
AppDetails {
|
||||
id: "figcraft",
|
||||
headline: "Interface design and prototyping, in pure Rust.",
|
||||
about: "Design screens, components and prototypes in a native app that opens instantly and works offline, \
|
||||
with no account and no telemetry. Early alpha.",
|
||||
features: &[
|
||||
feature(
|
||||
"Auto layout",
|
||||
"Rows, columns, wrap and grids with padding, gaps and Hug, Fill or Fixed sizing, plus constraints and layout grids.",
|
||||
),
|
||||
feature(
|
||||
"Components",
|
||||
"Instances with overrides, variants and properties, styles, and variables with modes bound to fills, strokes and effects.",
|
||||
),
|
||||
feature("Vectors", "Real vector networks, the pen and pencil, the shape builder, editable booleans and variable width strokes."),
|
||||
feature(
|
||||
"Prototypes",
|
||||
"Connections, triggers and animated transitions, variables with conditionals, flows and a presentation view with device frames.",
|
||||
),
|
||||
feature(
|
||||
"Hand off",
|
||||
"Inspect mode with measurements, annotations, ready-for-dev status, and code for CSS, HTML, React, SwiftUI and Compose.",
|
||||
),
|
||||
feature(
|
||||
"Your files",
|
||||
"Plain .figcraft files on your own disk. SVG, PNG, JPEG, WebP and PDF in and out, and .fig files open, best effort.",
|
||||
),
|
||||
],
|
||||
shots: &[],
|
||||
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/craft-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,7 +7,6 @@
|
|||
#![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;
|
||||
|
|
|
|||
|
|
@ -263,9 +263,6 @@ mod tests {
|
|||
{
|
||||
assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");
|
||||
}
|
||||
// FigCraft also ships its collaboration server; that tarball is not the app.
|
||||
assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);
|
||||
assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -43,12 +43,6 @@ 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),
|
||||
}
|
||||
|
|
@ -193,13 +187,6 @@ 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,8 +77,6 @@ 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 {
|
||||
|
|
@ -133,7 +131,6 @@ 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() };
|
||||
|
|
@ -861,36 +858,6 @@ 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 {
|
||||
|
|
@ -939,7 +906,6 @@ 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);
|
||||
|
|
@ -1040,7 +1006,6 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ pub fn meta(app: &CraftApp) -> (&'static str, Option<&'static str>) {
|
|||
"gridcraft" => ("Office;Spreadsheet;", None),
|
||||
"cadcraft" => ("Graphics;Engineering;VectorGraphics;", None),
|
||||
"soundcraft" => ("AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;", None),
|
||||
"figcraft" => ("Graphics;2DGraphics;VectorGraphics;X-Design;", Some("figcraft")),
|
||||
_ => ("Graphics;", None),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,27 +160,6 @@ 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)))?;
|
||||
|
|
@ -702,25 +681,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -746,7 +746,6 @@ pub(crate) mod linux {
|
|||
"gridcraft" => include_bytes!("../../../assets/app-icons/gridcraft.png"),
|
||||
"cadcraft" => include_bytes!("../../../assets/app-icons/cadcraft.png"),
|
||||
"soundcraft" => include_bytes!("../../../assets/app-icons/soundcraft.png"),
|
||||
"figcraft" => include_bytes!("../../../assets/app-icons/figcraft.png"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ 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,12 +46,6 @@ 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
//! 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", "jpeg"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
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", "jpeg"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ use artcraft_launcher_core::{Platform, SUITE};
|
|||
|
||||
use crate::i18n::set_current;
|
||||
use crate::model::AppEntry;
|
||||
use crate::motion;
|
||||
use crate::settings::{Filter, Settings, SortKey, ViewMode};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
|
|
@ -53,11 +52,6 @@ pub trait Host {
|
|||
fn set_login_item(&mut self, _on: bool) -> Result<(), String> {
|
||||
Err("not supported here".into())
|
||||
}
|
||||
/// The OS asks for fewer animations (Windows' "Animation effects" switched off, macOS'
|
||||
/// "Reduce motion"). Asked about once a second while the window draws, so it must be quick.
|
||||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Quit the launcher for real (not just hide to the tray).
|
||||
fn quit(&mut self) {}
|
||||
}
|
||||
|
|
@ -110,30 +104,14 @@ 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
|
||||
/// it again at.
|
||||
pub(crate) reduce_motion: bool,
|
||||
motion_recheck: f64,
|
||||
/// What the context's styles were last set for.
|
||||
applied_motion: Option<bool>,
|
||||
/// Fonts registered with `set_fonts` only exist from the next frame on; drawing with the named
|
||||
/// weights before then would abort.
|
||||
fonts_ready: bool,
|
||||
/// The CJK language whose system font is installed, if any.
|
||||
/// Set when such a language comes into use, then kept for the session.
|
||||
pub(crate) cjk_font: Option<crate::i18n::Lang>,
|
||||
/// Whether the installed fonts include the system's Chinese font. Added the first time Chinese
|
||||
/// is the language in use, then kept for the session.
|
||||
cjk_fonts: bool,
|
||||
sent_options: Option<BackendOptions>,
|
||||
/// Unix seconds of the last look at what's installed (startup counts).
|
||||
last_detect: i64,
|
||||
|
|
@ -144,12 +122,12 @@ pub struct LauncherUi {
|
|||
pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// The language to draw in: the preference, or English where it needs a CJK font the system
|
||||
/// The language to draw in: the preference, or English where it needs a Chinese font the system
|
||||
/// doesn't have (Settings says so), rather than boxes for every character. The only place the
|
||||
/// CJK font gets loaded: when such a language is selected, or Auto resolves to it.
|
||||
/// Chinese font gets loaded: when Chinese is selected, or Auto resolves to it.
|
||||
pub(crate) fn effective_lang(pref: &str) -> crate::i18n::Lang {
|
||||
let lang = crate::i18n::Lang::from_pref(pref);
|
||||
if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { crate::i18n::Lang::EN } else { lang }
|
||||
if lang.needs_cjk_font() && !theme::cjk_font_available() { crate::i18n::Lang::EN } else { lang }
|
||||
}
|
||||
|
||||
/// How often to look again at what's installed, to notice apps installed or removed outside
|
||||
|
|
@ -160,9 +138,8 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
|
|||
|
||||
impl LauncherUi {
|
||||
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
|
||||
let lang = effective_lang(&settings.language);
|
||||
let cjk_font = lang.needs_cjk_font().then_some(lang);
|
||||
theme::install_fonts(ctx, cjk_font);
|
||||
let cjk_fonts = effective_lang(&settings.language).needs_cjk_font();
|
||||
theme::install_fonts(ctx, cjk_fonts);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
let caps = backend.capabilities();
|
||||
let platform = backend.platform();
|
||||
|
|
@ -183,18 +160,10 @@ 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,
|
||||
motion_recheck: 0.0,
|
||||
applied_motion: None,
|
||||
fonts_ready: false,
|
||||
cjk_font,
|
||||
cjk_fonts,
|
||||
sent_options: None,
|
||||
last_detect: 0,
|
||||
was_focused: true,
|
||||
|
|
@ -202,31 +171,19 @@ impl LauncherUi {
|
|||
}
|
||||
}
|
||||
|
||||
/// Moves the UI to another window's context: fonts, image loaders, the theme and the animation
|
||||
/// timings belong to a context, and toasts and the next look at the OS's animation setting are
|
||||
/// timed on its clock.
|
||||
/// Moves the UI to another window's context: fonts, image loaders and the theme belong to a
|
||||
/// context, and toasts are timed on its clock.
|
||||
pub fn attach(&mut self, ctx: &egui::Context) {
|
||||
theme::install_fonts(ctx, self.cjk_font);
|
||||
theme::install_fonts(ctx, self.cjk_fonts);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
self.fonts_ready = false;
|
||||
self.applied_theme = None;
|
||||
self.applied_motion = None;
|
||||
self.motion_recheck = 0.0;
|
||||
self.toasts.clear();
|
||||
}
|
||||
|
||||
/// Input on its way to egui (the shell's `raw_input_hook`): with fewer animations asked for,
|
||||
/// a mouse wheel scrolls at once instead of gliding.
|
||||
pub fn raw_input(&self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
if self.reduce_motion {
|
||||
motion::unsmooth_wheel(ctx, raw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Installed apps with an update waiting (the tray shows the count). Hidden apps are left out,
|
||||
/// so the badge matches the Updates list.
|
||||
/// Installed apps with an update waiting (the tray shows the count).
|
||||
pub fn update_count(&self) -> usize {
|
||||
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates)).count()
|
||||
self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count()
|
||||
}
|
||||
|
||||
/// Something is downloading or installing, the launcher's own update included: quitting now
|
||||
|
|
@ -325,10 +282,6 @@ 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;
|
||||
|
|
@ -423,7 +376,7 @@ impl LauncherUi {
|
|||
fn announce_updates(&mut self, host: &mut dyn Host) {
|
||||
let mut fresh = Vec::new();
|
||||
for e in &self.entries {
|
||||
if !self.in_filter(e, Filter::Updates) {
|
||||
if e.status(self.platform, self.caps.manage_apps) != AppStatus::UpdateAvailable {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = e.latest_label() else { continue };
|
||||
|
|
@ -460,7 +413,7 @@ impl LauncherUi {
|
|||
let to_update: Vec<(String, String)> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)
|
||||
.filter(|e| e.status(self.platform, true) == AppStatus::UpdateAvailable && e.job.is_none() && !e.ready)
|
||||
.filter_map(|e| {
|
||||
let v = e.latest_label()?;
|
||||
if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
|
||||
|
|
@ -491,10 +444,10 @@ impl LauncherUi {
|
|||
// Set the current language for this frame based on settings.
|
||||
let lang = effective_lang(&self.settings.language);
|
||||
set_current(lang);
|
||||
// `effective_lang` only picks a CJK language once its font has loaded.
|
||||
if lang.needs_cjk_font() && self.cjk_font != Some(lang) {
|
||||
theme::install_fonts(&ctx, Some(lang));
|
||||
self.cjk_font = Some(lang);
|
||||
// `effective_lang` only picks Chinese once its font has loaded.
|
||||
if lang.needs_cjk_font() && !self.cjk_fonts {
|
||||
theme::install_fonts(&ctx, true);
|
||||
self.cjk_fonts = true;
|
||||
self.fonts_ready = false;
|
||||
}
|
||||
if !self.fonts_ready {
|
||||
|
|
@ -506,15 +459,6 @@ impl LauncherUi {
|
|||
theme::apply(&ctx, self.settings.theme);
|
||||
self.applied_theme = Some(self.settings.theme);
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now >= self.motion_recheck {
|
||||
self.reduce_motion = host.reduce_motion();
|
||||
self.motion_recheck = now + motion::RECHECK_EVERY;
|
||||
}
|
||||
if self.applied_motion != Some(self.reduce_motion) {
|
||||
motion::apply(&ctx, self.reduce_motion);
|
||||
self.applied_motion = Some(self.reduce_motion);
|
||||
}
|
||||
let tokens = Tokens::get(&ctx);
|
||||
if self.entries.iter().any(|e| e.job.is_some()) || self.checking {
|
||||
ctx.request_repaint_after(Duration::from_millis(50));
|
||||
|
|
@ -557,7 +501,6 @@ 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 {
|
||||
|
|
@ -573,8 +516,14 @@ impl LauncherUi {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| {
|
||||
self.in_filter(e, self.settings.filter)
|
||||
&& (needle.is_empty() || e.app.name.to_lowercase().contains(&needle) || e.app.tagline.to_lowercase().contains(&needle))
|
||||
let s = e.status(self.platform, self.caps.manage_apps);
|
||||
let keep = match self.settings.filter {
|
||||
Filter::All => true,
|
||||
Filter::Installed => s.is_installed(),
|
||||
Filter::Updates => s == AppStatus::UpdateAvailable,
|
||||
Filter::NotInstalled => !s.is_installed(),
|
||||
};
|
||||
keep && (needle.is_empty() || e.app.name.to_lowercase().contains(&needle) || e.app.tagline.to_lowercase().contains(&needle))
|
||||
})
|
||||
.collect();
|
||||
let platform = self.platform;
|
||||
|
|
@ -641,20 +590,18 @@ impl LauncherUi {
|
|||
}
|
||||
|
||||
pub(crate) fn count(&self, filter: Filter) -> usize {
|
||||
self.entries.iter().filter(|e| self.in_filter(e, filter)).count()
|
||||
}
|
||||
|
||||
/// Whether `entry` belongs in `filter`. Hidden apps stay out of every list except Hidden.
|
||||
pub(crate) fn in_filter(&self, entry: &AppEntry, filter: Filter) -> bool {
|
||||
let hidden = self.settings.hidden.contains(entry.app.id);
|
||||
let status = entry.status(self.platform, self.caps.manage_apps);
|
||||
match filter {
|
||||
Filter::Hidden => hidden,
|
||||
Filter::All => !hidden,
|
||||
Filter::Installed => !hidden && status.is_installed(),
|
||||
Filter::Updates => !hidden && status == AppStatus::UpdateAvailable,
|
||||
Filter::NotInstalled => !hidden && !status.is_installed(),
|
||||
}
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
let s = e.status(self.platform, self.caps.manage_apps);
|
||||
match filter {
|
||||
Filter::All => true,
|
||||
Filter::Installed => s.is_installed(),
|
||||
Filter::Updates => s == AppStatus::UpdateAvailable,
|
||||
Filter::NotInstalled => !s.is_installed(),
|
||||
}
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn draw_toasts(&mut self, ctx: &egui::Context) {
|
||||
|
|
|
|||
|
|
@ -1,86 +1,43 @@
|
|||
//! A CJK font from the operating system, for the CJK interface languages. The bundled faces have
|
||||
//! no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop build
|
||||
//! borrows the one every system that can show them already has. Desktop only.
|
||||
//! A Chinese font from the operating system, for the Simplified Chinese interface. The bundled
|
||||
//! faces have no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop
|
||||
//! build borrows the one every system that can show Chinese already has. Desktop only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui::epaint::text::VariationCoords;
|
||||
use egui::{FontData, FontTweak};
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{FontRef, MetadataProvider, string::StringId};
|
||||
|
||||
use crate::i18n::Script;
|
||||
|
||||
/// Larger files aren't read: PingFang, the largest candidate, is about 80 MB.
|
||||
const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024;
|
||||
/// Faces looked at in one collection file (PingFang has 24).
|
||||
const MAX_FACES: u32 = 64;
|
||||
|
||||
/// The bundled body font (Inter), whose baseline a fallback face is nudged onto.
|
||||
const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
|
||||
/// `(ascent, ascent + |descent| + leading) / units_per_em` of face `index` in `data`.
|
||||
///
|
||||
/// egui puts a fallback glyph's baseline at that face's own ascent and only centres the two line
|
||||
/// boxes (`galley_from_rows`), so a face whose metrics differ from the body font's rides off the
|
||||
/// baseline: a face with a half-em line gap, for one, sat about a quarter em high. [`load`]
|
||||
/// cancels the difference with a `FontTweak`.
|
||||
fn face_metrics(data: &[u8], index: u32) -> (f32, f32) {
|
||||
let Ok(font) = FontRef::from_index(data, index) else { return (0.0, 0.0) };
|
||||
let metrics = font.metrics(Size::unscaled(), LocationRef::default());
|
||||
let upem = f32::from(metrics.units_per_em);
|
||||
if upem <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
(metrics.ascent / upem, (metrics.ascent - metrics.descent + metrics.leading) / upem)
|
||||
}
|
||||
|
||||
/// The script's name, for the log lines.
|
||||
fn script_name(script: Script) -> &'static str {
|
||||
match script {
|
||||
Script::Chinese => "Chinese",
|
||||
Script::Japanese => "Japanese",
|
||||
}
|
||||
}
|
||||
|
||||
/// The families looked for, for the "none found" log line.
|
||||
fn script_families(script: Script) -> &'static str {
|
||||
match script {
|
||||
Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC and WenQuanYi",
|
||||
Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",
|
||||
}
|
||||
}
|
||||
|
||||
/// A font file that may be installed, and the family of its face for this script.
|
||||
/// A font file that may be installed, and the family of its Simplified Chinese face.
|
||||
struct Candidate {
|
||||
path: PathBuf,
|
||||
family: &'static str,
|
||||
}
|
||||
|
||||
/// The system's font for `script`, or `None` (logged) when it has none this knows about.
|
||||
/// The system's Simplified Chinese font, or `None` (logged) when it has none this knows about.
|
||||
/// Reads the file (once per process: the caller caches the answer).
|
||||
pub(crate) fn load(script: Script) -> Option<FontData> {
|
||||
for Candidate { path, family } in candidates(script) {
|
||||
pub(crate) fn load() -> Option<FontData> {
|
||||
for Candidate { path, family } in candidates() {
|
||||
let Some(bytes) = read(&path) else { continue };
|
||||
let Some(index) = face_index(&bytes, family) else {
|
||||
log::debug!("no {family} face in {}", path.display());
|
||||
continue;
|
||||
};
|
||||
log::info!("{} text uses {family} from {} (face {index})", script_name(script), path.display());
|
||||
// Regular weight, in case the face is variable (Noto Sans CJK VF), and a nudge onto the
|
||||
// body font's baseline (see `face_metrics`).
|
||||
let (body_ascent, body_height) = face_metrics(BODY_FONT, 0);
|
||||
let (face_ascent, face_height) = face_metrics(&bytes, index);
|
||||
let tweak = FontTweak {
|
||||
coords: VariationCoords::new([(*b"wght", 400.0)]),
|
||||
y_offset_factor: (body_ascent - face_ascent) + 0.5 * (face_height - body_height),
|
||||
..Default::default()
|
||||
};
|
||||
log::info!("Chinese text uses {family} from {} (face {index})", path.display());
|
||||
// Regular weight, in case the face is variable (Noto Sans CJK VF).
|
||||
let tweak = FontTweak { coords: VariationCoords::new([(*b"wght", 400.0)]), ..Default::default() };
|
||||
let mut font = FontData::from_owned(bytes).tweak(tweak);
|
||||
font.index = index;
|
||||
return Some(font);
|
||||
}
|
||||
log::warn!("no {} font found on this system (looked for {})", script_name(script), script_families(script));
|
||||
log::warn!(
|
||||
"no Chinese font found on this system (looked for PingFang, Hiragino Sans GB, Heiti, Microsoft YaHei, SimSun, Noto Sans CJK and WenQuanYi)"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -118,16 +75,8 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
|
|||
first
|
||||
}
|
||||
|
||||
/// Where each system keeps a font for `script`, best first.
|
||||
fn candidates(script: Script) -> Vec<Candidate> {
|
||||
match script {
|
||||
Script::Chinese => chinese_candidates(),
|
||||
Script::Japanese => japanese_candidates(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each system keeps a Simplified Chinese font, best first.
|
||||
fn chinese_candidates() -> Vec<Candidate> {
|
||||
fn candidates() -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -180,81 +129,6 @@ fn chinese_candidates() -> Vec<Candidate> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Where each system keeps a Japanese font, best first.
|
||||
fn japanese_candidates() -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Hiragino Sans (its file keeps the Japanese name); it may be a downloaded font asset.
|
||||
for file in ["ヒラギノ角ゴシック W3.ttc", "ヒラギノ角ゴシック W4.ttc", "ヒラギノ角ゴシック W6.ttc"] {
|
||||
add(Path::new("/System/Library/Fonts").join(file), "Hiragino Sans");
|
||||
for path in mac_font_assets(file) {
|
||||
add(path, "Hiragino Sans");
|
||||
}
|
||||
}
|
||||
add("/System/Library/Fonts/ヒラギノ丸ゴ ProN W4.ttc".into(), "Hiragino Maru Gothic ProN");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
|
||||
add(root.join("Fonts").join("YuGothR.ttc"), "Yu Gothic");
|
||||
add(root.join("Fonts").join("meiryo.ttc"), "Meiryo");
|
||||
add(root.join("Fonts").join("msgothic.ttc"), "MS Gothic");
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports.
|
||||
const NOTO_DIRS: [&str; 8] = [
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/noto-cjk",
|
||||
"/usr/share/fonts/google-noto-cjk",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-fonts",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts",
|
||||
"/usr/share/fonts/noto",
|
||||
"/usr/local/share/fonts/noto",
|
||||
"/usr/local/share/fonts/noto-cjk",
|
||||
];
|
||||
for file in ["NotoSansCJK-Regular.ttc", "NotoSansCJKjp-Regular.otf", "NotoSansCJK-VF.ttc"] {
|
||||
for dir in NOTO_DIRS {
|
||||
add(Path::new(dir).join(file), "Noto Sans CJK JP");
|
||||
}
|
||||
}
|
||||
// Standalone Noto Sans JP, and the classic Japanese faces (ipafont-gothic, Takao, VL).
|
||||
const JP_DIRS: [&str; 8] = [
|
||||
"/usr/share/fonts/opentype/noto",
|
||||
"/usr/share/fonts/truetype/noto",
|
||||
"/usr/share/fonts/noto",
|
||||
"/usr/share/fonts/truetype/ipafont-gothic",
|
||||
"/usr/share/fonts/truetype/ipafont",
|
||||
"/usr/share/fonts/truetype/takao-gothic",
|
||||
"/usr/share/fonts/truetype/vlgothic",
|
||||
"/usr/local/share/fonts",
|
||||
];
|
||||
for file in ["NotoSansJP-Regular.otf", "NotoSansJP-VF.ttf"] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), "Noto Sans JP");
|
||||
}
|
||||
}
|
||||
for (file, family) in [("ipag.ttf", "IPAGothic"), ("ipagp.ttf", "IPAPGothic")] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
for (file, family) in [("TakaoGothic.ttf", "TakaoGothic"), ("TakaoPGothic.ttf", "TakaoPGothic")] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
for (file, family) in [("VL-Gothic-Regular.ttf", "VL Gothic"), ("VL-PGothic-Regular.ttf", "VL PGothic")] {
|
||||
for dir in JP_DIRS {
|
||||
add(Path::new(dir).join(file), family);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `file` in macOS's downloaded font assets:
|
||||
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`.
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
@ -289,49 +163,6 @@ mod tests {
|
|||
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
|
||||
}
|
||||
|
||||
/// The baseline nudge reads a face's ascent and height; the reference is Inter's.
|
||||
#[test]
|
||||
fn face_metrics_reads_font_metrics() {
|
||||
let (ascent, height) = face_metrics(BODY_FONT, 0);
|
||||
assert!((ascent - 0.9688).abs() < 0.005, "{ascent}");
|
||||
assert!((height - 1.21).abs() < 0.005, "{height}");
|
||||
assert_eq!(face_metrics(b"not a font", 0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
/// A system CJK face sits on the Latin baseline, thanks to the nudge in [`load`]. Without a
|
||||
/// CJK font (CI, the web) there is nothing to check.
|
||||
#[test]
|
||||
fn cjk_sits_on_the_latin_baseline() {
|
||||
use egui::{Color32, FontFamily, FontId};
|
||||
for info in crate::i18n::LANGUAGES.iter().filter(|l| l.script.is_some()) {
|
||||
let Some(lang) = crate::i18n::Lang::from_code(info.code) else { continue };
|
||||
let Some(font) = crate::theme::cjk_font(lang) else { continue };
|
||||
let y_offset_factor = font.tweak.y_offset_factor;
|
||||
// A character the language's own catalog uses, so the sample fits its script: the face
|
||||
// must draw it, on the Latin baseline.
|
||||
let Some(sample) =
|
||||
info.source.lines().filter_map(|line| line.split('\t').nth(2)).flat_map(str::chars).find(|c| !c.is_ascii() && c.is_alphabetic())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, Some(lang));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let size = 20.0;
|
||||
let font_id = FontId::new(size, FontFamily::Proportional);
|
||||
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(format!("A{sample}"), font_id.clone(), Color32::WHITE));
|
||||
assert!(ctx.fonts_mut(|f| f.has_glyphs(&font_id, &sample.to_string())), "{}: no glyph for {sample:?}", lang.code());
|
||||
let glyphs = &galley.rows[0].row.glyphs;
|
||||
let pos = |c: char| glyphs.iter().find(|g| g.chr == c).map(|g| g.pos.y);
|
||||
// `pos.y` is the baseline before the nudge (which moves the ink, not `pos`).
|
||||
if let (Some(latin), Some(cjk)) = (pos('A'), pos(sample)) {
|
||||
let rendered = cjk + y_offset_factor * size;
|
||||
assert!((rendered - latin).abs() < 1.0, "{}: latin {latin}, cjk {rendered}, nudge {y_offset_factor}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_index_finds_families_in_real_fonts() {
|
||||
// The bundled Latin faces stand in for system fonts the test machine may not have.
|
||||
|
|
@ -343,12 +174,10 @@ mod tests {
|
|||
/// Whatever this machine has loads without panicking, and a found face really is the family's.
|
||||
#[test]
|
||||
fn system_font_loads_when_present() {
|
||||
for script in Script::ALL {
|
||||
if let Some(font) = load(script) {
|
||||
let family = candidates(script).into_iter().find(|c| c.path.is_file()).map(|c| c.family);
|
||||
assert!(family.is_some());
|
||||
assert!(FontRef::from_index(&font.font, font.index).is_ok());
|
||||
}
|
||||
if let Some(font) = load() {
|
||||
let family = candidates().into_iter().find(|c| c.path.is_file()).map(|c| c.family);
|
||||
assert!(family.is_some());
|
||||
assert!(FontRef::from_index(&font.font, font.index).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,475 +0,0 @@
|
|||
//! 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()
|
||||
}
|
||||
|
|
@ -178,76 +178,28 @@ impl LauncherUi {
|
|||
}
|
||||
|
||||
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
|
||||
// Leave room for the frame (48 px) and a 16 px gutter on each side.
|
||||
let viewport = ui.ctx().content_rect();
|
||||
ui.set_width(520.0_f32.min((viewport.width() - 80.0).max(0.0)));
|
||||
// The area remembers its previous size. Reset the available height so the scroll area
|
||||
// measures against the viewport immediately, including on the first frame after a resize.
|
||||
ui.set_max_height((viewport.height() - 80.0).max(0.0));
|
||||
ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() };
|
||||
let title_top = ui.cursor().top();
|
||||
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
|
||||
let title_height = ui.cursor().top() - title_top;
|
||||
let quit_width = widgets::button_width(ui, tl!("Quit ArtCraft Launcher"), 0.0);
|
||||
let done_width = widgets::button_width(ui, tl!("Done"), 90.0);
|
||||
let stack_actions = self.caps.manage_apps && quit_width + done_width + ui.spacing().item_spacing.x > ui.available_width();
|
||||
let footer_height = if stack_actions { 2.0 * theme::CONTROL_H + ui.spacing().item_spacing.y } else { theme::CONTROL_H };
|
||||
let body_height = (viewport.height() - 80.0 - title_height - footer_height - 24.0 - ui.spacing().item_spacing.y).max(0.0);
|
||||
egui::ScrollArea::vertical().id_salt("settings-body").max_height(body_height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
self.settings_contents(ui, host);
|
||||
});
|
||||
|
||||
ui.add_space(24.0);
|
||||
let mut close = false;
|
||||
let quit = |ui: &mut egui::Ui, host: &mut dyn Host| {
|
||||
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
|
||||
host.quit();
|
||||
}
|
||||
};
|
||||
if stack_actions {
|
||||
quit(ui, host);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
|
||||
});
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
quit(ui, host);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
|
||||
});
|
||||
});
|
||||
}
|
||||
close
|
||||
}
|
||||
|
||||
fn settings_contents(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Wide enough for the community buttons and every social icon on one row.
|
||||
ui.set_width(520.0);
|
||||
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
|
||||
let section = |ui: &mut egui::Ui, s: &str| {
|
||||
ui.add_space(14.0);
|
||||
widgets::hud(ui, s, t.faint);
|
||||
ui.add_space(6.0);
|
||||
};
|
||||
let row = |ui: &mut egui::Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
|
||||
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
|
||||
let row = |ui: &mut egui::Ui, label: &str, add: &mut dyn FnMut(&mut egui::Ui)| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
add(ui);
|
||||
} else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
|
||||
});
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
|
||||
});
|
||||
};
|
||||
|
||||
let mut lang_code = self.settings.language.clone();
|
||||
let installed = self.cjk_font;
|
||||
row(ui, tl!("Language"), 180.0, &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(180.0).show_ui(ui, |ui| {
|
||||
row(ui, tl!("Language"), &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).width(180.0).show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
|
||||
for lang in crate::i18n::Lang::all() {
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
|
||||
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -255,17 +207,16 @@ impl LauncherUi {
|
|||
self.settings.language = lang_code;
|
||||
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
|
||||
}
|
||||
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
|
||||
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
|
||||
if crate::i18n::Lang::from_pref(&self.settings.language).needs_cjk_font() && !theme::cjk_font_available() {
|
||||
// In English on purpose: the language asked for can't be drawn.
|
||||
let note = "No font for this language was found on this system, so the interface stays in English. \
|
||||
Installing one (for example Noto Sans CJK) and restarting fixes it.";
|
||||
let note = "No Chinese font was found on this system, so the interface stays in English. \
|
||||
Installing one (Noto Sans CJK, or WenQuanYi on Linux) and restarting fixes it.";
|
||||
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
|
||||
}
|
||||
|
||||
section(ui, tl!("Updates"));
|
||||
let mut every = self.settings.check_every;
|
||||
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
|
||||
row(ui, tl!("Check for updates"), &mut |ui| {
|
||||
egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| {
|
||||
for e in CheckEvery::ALL {
|
||||
ui.selectable_value(&mut every, e, e.label());
|
||||
|
|
@ -294,7 +245,7 @@ impl LauncherUi {
|
|||
None
|
||||
};
|
||||
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
|
||||
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
|
||||
row(ui, tl!("Install new apps as"), &mut |ui| {
|
||||
let default_label = match default_kind {
|
||||
Some(k) => crate::i18n::t(k.label()),
|
||||
None => tl!("System package (recommended)"),
|
||||
|
|
@ -339,7 +290,7 @@ impl LauncherUi {
|
|||
section(ui, tl!("Community"));
|
||||
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
|
||||
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL));
|
||||
|
|
@ -350,10 +301,7 @@ impl LauncherUi {
|
|||
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
|
||||
}
|
||||
// Wrap the social links as a group so their icons stay together.
|
||||
let count = artcraft_launcher_core::catalog::SOCIAL.len() as f32;
|
||||
let width = count * (16.0 + 8.0) + (count - 1.0).max(0.0) * 4.0;
|
||||
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
widgets::social_icons(ui, 16.0);
|
||||
});
|
||||
|
|
@ -365,5 +313,17 @@ impl LauncherUi {
|
|||
widgets::cells(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, Cell { label: c.label(), tip: c.label(), count: None })));
|
||||
});
|
||||
self.settings.theme = theme_choice;
|
||||
|
||||
ui.add_space(24.0);
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
|
||||
host.quit();
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
|
||||
});
|
||||
});
|
||||
close
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//! - `@id` context = keyed by command id (`tr_id`).
|
||||
//! - `@plural` context = plural forms: the English `one|other` key, and the language's forms
|
||||
//! separated by `|` (one form for a language whose nouns don't inflect for number, up to
|
||||
//! three: `one|few|many` for Russian, Ukrainian and Polish).
|
||||
//! three: `one|few|many` for Russian).
|
||||
//! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text
|
||||
//! (`message`, `msg`).
|
||||
//! - Any other context = disambiguation for `tr_ctx`.
|
||||
|
|
|
|||
|
|
@ -1,314 +0,0 @@
|
|||
# Czech (cs) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t). @id = command-id keyed. @plural = one|few|other.
|
||||
# @msg = status/error message template. Other context = disambiguation for tr_ctx.
|
||||
# AI-assisted Czech translation, translated from the meaning of the English source strings.
|
||||
# Product and app names, shortcuts, units, file formats, placeholders and trailing ellipses are kept.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders Open-source nástroje pro kreativce, tvůrce a vývojáře
|
||||
Settings Nastavení
|
||||
Check for updates Zkontrolovat aktualizace
|
||||
Grid Mřížka
|
||||
Table Tabulka
|
||||
Show apps as cards Zobrazit aplikace jako karty
|
||||
Show apps as a table Zobrazit aplikace v tabulce
|
||||
All Vše
|
||||
Updates Aktualizace
|
||||
Not installed Nenainstalované
|
||||
Search apps Hledat aplikace
|
||||
Sort Seřadit
|
||||
Suite order Pořadí v sadě
|
||||
Name Název
|
||||
Inspired by Inspirace
|
||||
Status Stav
|
||||
Installed version Nainstalovaná verze
|
||||
Latest version Nejnovější verze
|
||||
Newest release Nejnovější vydání
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website Web
|
||||
Classic apps Klasické aplikace
|
||||
Classic Klasické
|
||||
New apps Nové aplikace
|
||||
NEW NOVÉ
|
||||
Install Nainstalovat
|
||||
Update to {v} Aktualizovat na {v}
|
||||
Open Otevřít
|
||||
Download for {os} Stáhnout pro {os}
|
||||
Download Stáhnout
|
||||
Coming soon Již brzy
|
||||
Checking… Kontrola…
|
||||
Not available Není k dispozici
|
||||
Offline Bez připojení
|
||||
Downloading {done} of {total} Stahování {done} z {total}
|
||||
Downloading… Stahování…
|
||||
Reconnecting… Obnovování připojení…
|
||||
Verifying… Ověřování…
|
||||
Installing, then restarting… Instalace a restart…
|
||||
Installing… Instalace…
|
||||
Removing… Odebírání…
|
||||
Pause download Pozastavit stahování
|
||||
Cancel download Zrušit stahování
|
||||
Resume download Pokračovat ve stahování
|
||||
Discard download Zahodit stahování
|
||||
Release notes Poznámky k vydání
|
||||
Show in Finder Zobrazit ve Finderu
|
||||
Show in Explorer Zobrazit v Průzkumníku
|
||||
Show in Files Zobrazit v Souborech
|
||||
Reinstall Znovu nainstalovat
|
||||
Uninstall… Odinstalovat…
|
||||
All releases Všechna vydání
|
||||
ArtCraft Launcher {version} is available. Je k dispozici ArtCraft Launcher {version}.
|
||||
Dismiss Skrýt
|
||||
Update and restart Aktualizovat a restartovat
|
||||
A new version is available. Would you like to relaunch with the updated version? Je k dispozici nová verze. Chcete aplikaci znovu spustit s aktualizovanou verzí?
|
||||
Later Později
|
||||
What's new Co je nového
|
||||
No notes were published with this release. K tomuto vydání nebyly zveřejněny žádné poznámky.
|
||||
Released {date} Vydáno {date}
|
||||
Open on GitHub Otevřít na GitHubu
|
||||
Close Zavřít
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask Pouze na vyžádání
|
||||
Automatically download and install updates Automaticky stahovat a instalovat aktualizace
|
||||
Notify me when an installed app has an update Upozornit, když má nainstalovaná aplikace aktualizaci
|
||||
Include pre-releases (betas and release candidates) Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
|
||||
Installing on Linux Instalace v Linuxu
|
||||
Install new apps as Instalovat nové aplikace jako
|
||||
System package (recommended) Systémový balíček (doporučeno)
|
||||
Updates always use the format an app was installed with. Aktualizace vždy použijí formát, ve kterém byla aplikace nainstalována.
|
||||
Running in the background Běh na pozadí
|
||||
Keep running in the tray when the window is closed Po zavření okna ponechat spuštěné v oznamovací oblasti
|
||||
Show the window on the current desktop Zobrazit okno na aktuální ploše
|
||||
Start ArtCraft Launcher when I log in Spouštět ArtCraft Launcher po přihlášení
|
||||
Community Komunita
|
||||
Questions, ideas and news from the ArtCraft team Dotazy, nápady a novinky od týmu ArtCraft
|
||||
Appearance Vzhled
|
||||
Quit ArtCraft Launcher Ukončit ArtCraft Launcher
|
||||
Done Hotovo
|
||||
Uninstall {name}? Odinstalovat {name}?
|
||||
The app will be moved to the Trash. Your documents and settings stay. Aplikace bude přesunuta do Koše. Vaše dokumenty a nastavení zůstanou zachovány.
|
||||
Windows will remove it like any installed program. Your documents and settings stay. Windows ji odebere jako každý nainstalovaný program. Vaše dokumenty a nastavení zůstanou zachovány.
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Odebere ji správce balíčků systému a může požádat o heslo. Vaše dokumenty a nastavení zůstanou zachovány.
|
||||
The app will be removed. Your documents and settings stay. Aplikace bude odebrána. Vaše dokumenty a nastavení zůstanou zachovány.
|
||||
Uninstall Odinstalovat
|
||||
Cancel Zrušit
|
||||
Checked {ago} Zkontrolováno {ago}
|
||||
just now právě teď
|
||||
yesterday včera
|
||||
{month} {day}, {year} {day}. {month} {year}
|
||||
Automatically Automaticky
|
||||
Every Craft app Všechny aplikace Craft
|
||||
Apps on this computer Aplikace v tomto počítači
|
||||
Installed apps with a newer version Nainstalované aplikace s novější verzí
|
||||
Apps you can install Aplikace, které lze nainstalovat
|
||||
Hide App Skrýt aplikaci
|
||||
Unhide App Zobrazit aplikaci
|
||||
Hidden Apps Skryté aplikace
|
||||
Apps you hid Aplikace, které jste skryli
|
||||
Checked {ago} · using {sources} Zkontrolováno {ago} · zdroje: {sources}
|
||||
{a} and {b} {a} a {b}
|
||||
Join the Discord Připojit se na Discord
|
||||
Learn more Další informace
|
||||
Minimize Minimalizovat
|
||||
Maximize Maximalizovat
|
||||
Restore Obnovit
|
||||
New Nové
|
||||
OK OK
|
||||
Open link Otevřít odkaz
|
||||
Update Aktualizovat
|
||||
{name} icon Ikona aplikace {name}
|
||||
Loading ArtCraft Launcher… Načítání aplikace ArtCraft Launcher…
|
||||
Cancel update Zrušit aktualizaci
|
||||
Stop waiting; the update can be started again later Přestat čekat; aktualizaci lze spustit znovu později
|
||||
Download again Stáhnout znovu
|
||||
Install cancelled Instalace zrušena
|
||||
Install cancelled. The download is kept for the next try. Instalace zrušena. Stažený soubor zůstane připravený pro další pokus.
|
||||
Update cancelled Aktualizace zrušena
|
||||
Uninstall cancelled Odinstalace zrušena
|
||||
Retry install Zkusit instalaci znovu
|
||||
Retry update to {version} Zkusit aktualizaci na {version} znovu
|
||||
The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
|
||||
Waiting for another app… Čekání na jinou aplikaci…
|
||||
Waiting for other installs… Čekání na dokončení ostatních instalací…
|
||||
Installer (.msi) Instalační program (.msi)
|
||||
Portable (.zip) Přenosná verze (.zip)
|
||||
Disk image (.dmg) Obraz disku (.dmg)
|
||||
Debian package (.deb) Balíček Debian (.deb)
|
||||
RPM package (.rpm) Balíček RPM (.rpm)
|
||||
Flatpak bundle Balíček Flatpak
|
||||
Tarball (.tar.gz) Archiv tar (.tar.gz)
|
||||
photocraft Image editing Úpravy obrázků
|
||||
vectorcraft Vector illustration Vektorová ilustrace
|
||||
filmcraft Video editing, color and sound Střih videa, barvy a zvuk
|
||||
lightcraft Photo library and raw development Katalog fotografií a vyvolávání RAW
|
||||
pdfcraft Reading, organizing and protecting PDFs Čtení, organizace a ochrana souborů PDF
|
||||
effectcraft Motion graphics and visual effects Pohyblivá grafika a vizuální efekty
|
||||
designcraft Page layout and publishing Sazba stránek a publikování
|
||||
wordcraft Writing and document design Psaní a návrh dokumentů
|
||||
deckcraft Presentations and slide shows Prezentace a promítání snímků
|
||||
gridcraft Spreadsheets and data Tabulky a data
|
||||
cadcraft Computer-aided design and drafting Počítačem podporované navrhování a kreslení
|
||||
soundcraft Recording, editing and mixing audio Nahrávání, úpravy a mixování zvuku
|
||||
figcraft Interface design and prototyping Návrh rozhraní a prototypování
|
||||
month Jan 1.
|
||||
month Feb 2.
|
||||
month Mar 3.
|
||||
month Apr 4.
|
||||
month May 5.
|
||||
month Jun 6.
|
||||
month Jul 7.
|
||||
month Aug 8.
|
||||
month Sep 9.
|
||||
month Oct 10.
|
||||
month Nov 11.
|
||||
month Dec 12.
|
||||
Not checked yet Zatím nezkontrolováno
|
||||
GitHub's hourly limit was reached; full details return in {min} min Byl dosažen hodinový limit GitHubu; úplné informace budou znovu dostupné za {min} min
|
||||
GitHub limit hit; back in {min} min Dosažen limit GitHubu; obnovení za {min} min
|
||||
Installing for {platform} Instalace pro {platform}
|
||||
Downloads for {platform} Stažení pro {platform}
|
||||
No downloads for this system Pro tento systém nejsou k dispozici žádné soubory ke stažení
|
||||
Join the ArtCraft community on Discord Připojit se ke komunitě ArtCraft na Discordu
|
||||
ArtCraft on {name} ArtCraft na {name}
|
||||
Couldn't open {name}: {e} {name} nelze otevřít: {e}
|
||||
Download cancelled Stahování zrušeno
|
||||
Available to install K dispozici k instalaci
|
||||
Available K dispozici
|
||||
Installed · up to date Nainstalováno · aktuální
|
||||
Update available Je k dispozici aktualizace
|
||||
Installed Nainstalováno
|
||||
installed nainstalováno
|
||||
Not available here Zde není k dispozici
|
||||
Updating… Aktualizuje se…
|
||||
Update paused Aktualizace pozastavena
|
||||
Download paused Stahování pozastaveno
|
||||
{version} installed → {latest} released Nainstalována verze {version} → vydána {latest}
|
||||
Version {version} Verze {version}
|
||||
{installed} installed · latest {latest} Nainstalována verze {installed} · nejnovější {latest}
|
||||
Installed · latest {latest} Nainstalováno · nejnovější {latest}
|
||||
Version {version} installed Nainstalována verze {version}
|
||||
Version {latest} Verze {latest}
|
||||
No release published yet Zatím nebylo zveřejněno žádné vydání
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App Aplikace
|
||||
Latest Nejnovější
|
||||
Released Vydáno
|
||||
Auto Automaticky
|
||||
Language Jazyk
|
||||
Ascending Vzestupně
|
||||
Descending Sestupně
|
||||
Click to reverse the order Kliknutím obrátíte pořadí
|
||||
Click to sort by this column Kliknutím seřadíte podle tohoto sloupce
|
||||
Like {name} Obdoba {name}
|
||||
{done} of {total} {done} z {total}
|
||||
Downloading Stahuje se
|
||||
Verifying checksum… Ověřování kontrolního součtu…
|
||||
Resume Pokračovat
|
||||
{done} of {total} downloaded Staženo {done} z {total}
|
||||
{done} downloaded Staženo {done}
|
||||
Checking for updates… Kontrola aktualizací…
|
||||
Downloads the new version, replaces this one and opens it again Stáhne novou verzi, nahradí tuto verzi a znovu ji otevře
|
||||
Couldn't check for updates: {f} Aktualizace nelze zkontrolovat: {f}
|
||||
Website (coming soon) Web (již brzy)
|
||||
GitHub repository Repozitář na GitHubu
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app Aktualizovat 1 aplikaci
|
||||
Update all ({count}) Aktualizovat vše ({count})
|
||||
⊞ Grid ⊞ Mřížka
|
||||
☰ Table ☰ Tabulka
|
||||
🔍 Search apps 🔍 Hledat aplikace
|
||||
{name} {version} {name} {version}
|
||||
Match system Podle systému
|
||||
Dark Tmavý
|
||||
Light Světlý
|
||||
Full changelog Úplný přehled změn
|
||||
link odkaz
|
||||
No apps match Žádné aplikace neodpovídají hledání
|
||||
Open ArtCraft Launcher to update. Aktualizujte otevřením aplikace ArtCraft Launcher.
|
||||
{name} {v} is available Je k dispozici {name} {v}
|
||||
{count} updates are available Počet dostupných aktualizací: {count}
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Tyto poznámky se nepodařilo načíst (byl dosažen limit požadavků GitHubu). Najdete je na stránce vydání.
|
||||
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Je-li tato volba zapnutá, otevření launcheru z řádku nabídek zobrazí jeho okno na aktuální ploše. Je-li vypnutá, macOS přepne na plochu, na které se okno nacházelo.
|
||||
Couldn't change the login item: {e} Položku po přihlášení nelze změnit: {e}
|
||||
column App Aplikace
|
||||
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
|
||||
@plural {n} day ago|{n} days ago před {n} dnem|před {n} dny|před {n} dny
|
||||
@msg Couldn't open {name}: {e} {name} nelze otevřít: {e}
|
||||
@msg Download cancelled Stahování zrušeno
|
||||
@msg {name} updated to {version} Aplikace {name} byla aktualizována na verzi {version}
|
||||
@msg {name} installed: {version} Aplikace {name} byla nainstalována ve verzi {version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla aktualizována na verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla nainstalována ve verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
|
||||
@msg {name} was removed Aplikace {name} byla odebrána
|
||||
@msg {name} is running. Quit it, then try again. Aplikace {name} je spuštěná. Ukončete ji a zkuste to znovu.
|
||||
@id app.install Nainstalovat
|
||||
@id app.update Aktualizovat na {v}
|
||||
@id app.open Otevřít
|
||||
@id app.download Stáhnout pro {os}
|
||||
@id app.reveal Zobrazit v umístění
|
||||
@id app.reinstall Znovu nainstalovat
|
||||
@id app.uninstall Odinstalovat…
|
||||
@id app.notes Poznámky k vydání
|
||||
@id app.website Web
|
||||
@id app.github Repozitář na GitHubu
|
||||
@id app.releases Všechna vydání
|
||||
@id launcher.update Aktualizovat a restartovat
|
||||
@id launcher.download Stáhnout
|
||||
@id launcher.notes Co je nového
|
||||
@id launcher.dismiss Zavřít
|
||||
@id settings.open Nastavení
|
||||
@id settings.quit Ukončit ArtCraft Launcher
|
||||
@id view.grid Mřížka
|
||||
@id view.table Tabulka
|
||||
@id filter.all Vše
|
||||
@id filter.installed Nainstalované
|
||||
@id filter.updates Aktualizace
|
||||
@id filter.notinstalled Nenainstalované
|
||||
@id sort.suite Pořadí v sadě
|
||||
@id sort.name Název
|
||||
@id sort.inspired Inspirace
|
||||
@id sort.status Stav
|
||||
@id sort.installed Nainstalovaná verze
|
||||
@id sort.latest Nejnovější verze
|
||||
@id sort.released Nejnovější vydání
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website Web
|
||||
@id update.check Zkontrolovat aktualizace
|
||||
@id update.check_auto Automaticky kontrolovat aktualizace
|
||||
@id update.notify Upozornit, když má nainstalovaná aplikace aktualizaci
|
||||
@id update.prereleases Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
|
||||
@id linux.preferred_kind Instalovat nové aplikace jako
|
||||
@id tray.close_to_tray Po zavření okna ponechat spuštěné v oznamovací oblasti
|
||||
@id tray.show_on_current_desktop Zobrazit okno na aktuální ploše
|
||||
@id tray.login_item Spouštět ArtCraft Launcher po přihlášení
|
||||
@id appearance.theme Vzhled
|
||||
Show ArtCraft Launcher Zobrazit ArtCraft Launcher
|
||||
Check for Updates Zkontrolovat aktualizace
|
||||
Visit getartcraft.com Navštívit getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft na GitHubu
|
||||
Join the ArtCraft Discord Připojit se na ArtCraft Discord
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
|
||||
|
Can't render this file because it has a wrong number of fields in line 8.
|
|
|
@ -14,7 +14,7 @@
|
|||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
|
@ -118,10 +118,6 @@
|
|||
Apps on this computer Apps auf diesem Computer
|
||||
Installed apps with a newer version Installierte Apps mit neuerer Version
|
||||
Apps you can install Apps, die du installieren kannst
|
||||
Hide App App ausblenden
|
||||
Unhide App App einblenden
|
||||
Hidden Apps Ausgeblendete Apps
|
||||
Apps you hid Apps, die du ausgeblendet hast
|
||||
Checked {ago} · using {sources} Zuletzt geprüft: {ago} · über {sources}
|
||||
{a} and {b} {a} und {b}
|
||||
Join the Discord Dem Discord beitreten
|
||||
|
|
@ -170,7 +166,6 @@ deckcraft Presentations and slide shows Präsentationen und Diashows
|
|||
gridcraft Spreadsheets and data Tabellenkalkulation und Daten
|
||||
cadcraft Computer-aided design and drafting Computergestütztes Konstruieren und Zeichnen
|
||||
soundcraft Recording, editing and mixing audio Audio aufnehmen, bearbeiten und mischen
|
||||
figcraft Interface design and prototyping Interface-Design und Prototyping
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan Jan.
|
||||
|
|
@ -261,7 +256,6 @@ 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
|
||||
|
|
@ -330,17 +324,4 @@ 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.
|
|
|
@ -14,7 +14,7 @@
|
|||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft,
|
||||
# DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
|
||||
# SoundCraft, FigCraft), "GitHub", "Discord", "Finder", "makers".
|
||||
# SoundCraft), "GitHub", "Discord", "Finder", "makers".
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders must appear in the translation:
|
||||
# {ago}, {a}, {b}, {count}, {date}, {day}, {done}, {e}, {f}, {installed}, {latest}, {min},
|
||||
# {month}, {name}, {n}, {os}, {platform}, {sources}, {total}, {version}, {v}, {year}.
|
||||
|
|
@ -117,10 +117,6 @@
|
|||
Apps on this computer Aplicaciones en este equipo
|
||||
Installed apps with a newer version Aplicaciones instaladas con una versión más reciente
|
||||
Apps you can install Aplicaciones que puedes instalar
|
||||
Hide App Ocultar aplicación
|
||||
Unhide App Mostrar aplicación
|
||||
Hidden Apps Aplicaciones ocultas
|
||||
Apps you hid Aplicaciones que ocultaste
|
||||
Checked {ago} · using {sources} Comprobado {ago} · con {sources}
|
||||
{a} and {b} {a} y {b}
|
||||
Join the Discord Únete al Discord
|
||||
|
|
@ -165,7 +161,6 @@ deckcraft Presentations and slide shows Presentaciones y diapositivas
|
|||
gridcraft Spreadsheets and data Hojas de cálculo y datos
|
||||
cadcraft Computer-aided design and drafting CAD: diseño y dibujo técnico
|
||||
soundcraft Recording, editing and mixing audio Grabación, edición y mezcla de audio
|
||||
figcraft Interface design and prototyping Diseño de interfaces y prototipos
|
||||
month Jan ene.
|
||||
month Feb feb.
|
||||
month Mar mar.
|
||||
|
|
@ -251,20 +246,6 @@ 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.
|
|
|
@ -12,7 +12,7 @@
|
|||
# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
|
||||
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
|
||||
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub",
|
||||
# "Discord".
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
|
@ -116,10 +116,6 @@
|
|||
Apps on this computer Applications sur cet ordinateur
|
||||
Installed apps with a newer version Applications installées avec une version plus récente
|
||||
Apps you can install Applications que vous pouvez installer
|
||||
Hide App Masquer l'application
|
||||
Unhide App Afficher l'application
|
||||
Hidden Apps Applications masquées
|
||||
Apps you hid Applications que vous avez masquées
|
||||
Checked {ago} · using {sources} Vérifié {ago} · via {sources}
|
||||
{a} and {b} {a} et {b}
|
||||
Join the Discord Rejoindre le Discord
|
||||
|
|
@ -168,7 +164,6 @@ deckcraft Presentations and slide shows Présentations et diaporamas
|
|||
gridcraft Spreadsheets and data Tableurs et données
|
||||
cadcraft Computer-aided design and drafting Conception et dessin assistés par ordinateur
|
||||
soundcraft Recording, editing and mixing audio Enregistrement, édition et mixage audio
|
||||
figcraft Interface design and prototyping Design d'interfaces et prototypage
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan janv.
|
||||
|
|
@ -259,7 +254,6 @@ 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
|
||||
|
|
@ -328,17 +322,4 @@ 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.
|
|
|
@ -1,347 +0,0 @@
|
|||
# Hungarian (hu) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t).
|
||||
# @id = command-id keyed (tr_id).
|
||||
# @plural = plural forms: one|other (English and Hungarian).
|
||||
# @msg = status/error message template (message, msg).
|
||||
# Other context = disambiguation for tr_ctx.
|
||||
#
|
||||
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Hungarian,
|
||||
# using the terms Hungarian-speaking users of creative software know. The user is addressed with
|
||||
# the informal "te", as in most current creative and community software.
|
||||
#
|
||||
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders Nyílt forráskódú eszközök alkotóknak, barkácsolóknak és fejlesztőknek
|
||||
Settings Beállítások
|
||||
Check for updates Frissítések keresése
|
||||
Grid Rács
|
||||
Table Táblázat
|
||||
Show apps as cards Alkalmazások megjelenítése kártyákként
|
||||
Show apps as a table Alkalmazások megjelenítése táblázatként
|
||||
All Összes
|
||||
Updates Frissítések
|
||||
Not installed Nincs telepítve
|
||||
Search apps Alkalmazások keresése
|
||||
Sort Rendezés
|
||||
Suite order Alapértelmezett sorrend
|
||||
Name Név
|
||||
Inspired by Mintául szolgált
|
||||
Status Állapot
|
||||
Installed version Telepített verzió
|
||||
Latest version Legújabb verzió
|
||||
Newest release Legújabb kiadás
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website Webhely
|
||||
Classic apps Eredeti alkalmazások
|
||||
New apps Új alkalmazások
|
||||
NEW ÚJ
|
||||
Install Telepítés
|
||||
Update to {v} Frissítés: {v}
|
||||
Open Megnyitás
|
||||
Download for {os} Letöltés {os} rendszerre
|
||||
Download Letöltés
|
||||
Coming soon Hamarosan
|
||||
Checking… Ellenőrzés…
|
||||
Not available Nem elérhető
|
||||
Offline Nincs kapcsolat
|
||||
Downloading {done} of {total} Letöltés: {done} / {total}
|
||||
Downloading… Letöltés…
|
||||
Reconnecting… Újracsatlakozás…
|
||||
Verifying… Fájl ellenőrzése…
|
||||
Installing, then restarting… Telepítés, majd újraindítás…
|
||||
Installing… Telepítés…
|
||||
Removing… Eltávolítás…
|
||||
Pause download Letöltés szüneteltetése
|
||||
Cancel download Letöltés megszakítása
|
||||
Resume download Letöltés folytatása
|
||||
Discard download Letöltött fájl törlése
|
||||
Release notes Kiadási információk
|
||||
Show in Finder Megjelenítés a Finderben
|
||||
Show in Explorer Megjelenítés a Fájlkezelőben
|
||||
Show in Files Megjelenítés a fájlkezelőben
|
||||
Reinstall Újratelepítés
|
||||
Uninstall… Eltávolítás…
|
||||
All releases Minden kiadás
|
||||
ArtCraft Launcher {version} is available. Elérhető az ArtCraft Launcher új verziója: {version}.
|
||||
Dismiss Elrejtés
|
||||
Update and restart Frissítés és újraindítás
|
||||
A new version is available. Would you like to relaunch with the updated version? Új verzió érhető el. Újraindítod az alkalmazást a frissítéshez?
|
||||
Later Később
|
||||
What's new Újdonságok
|
||||
No notes were published with this release. Ehhez a kiadáshoz nem tettek közzé kiadási információkat.
|
||||
Released {date} Megjelent: {date}
|
||||
Open on GitHub Megnyitás a GitHubon
|
||||
Close Bezárás
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask Csak kézi kereséskor
|
||||
Automatically download and install updates Frissítések automatikus letöltése és telepítése
|
||||
Notify me when an installed app has an update Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
|
||||
Include pre-releases (betas and release candidates) Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
|
||||
Installing on Linux Telepítés Linuxon
|
||||
Install new apps as Új alkalmazások csomagformátuma
|
||||
System package (recommended) Rendszercsomag (ajánlott)
|
||||
Updates always use the format an app was installed with. A frissítések mindig az eredeti telepítés csomagformátumát használják.
|
||||
Running in the background Futtatás a háttérben
|
||||
Keep running in the tray when the window is closed Az ablak bezárásakor kerüljön a tálcára
|
||||
Show the window on the current desktop Ablak megjelenítése az aktuális asztalon
|
||||
Start ArtCraft Launcher when I log in ArtCraft Launcher indítása bejelentkezéskor
|
||||
Community Közösség
|
||||
Questions, ideas and news from the ArtCraft team Kérdések, ötletek és hírek az ArtCraft csapatától
|
||||
Appearance Megjelenés
|
||||
Quit ArtCraft Launcher Kilépés az ArtCraft Launcherből
|
||||
Done Kész
|
||||
Uninstall {name}? {name} eltávolítása?
|
||||
The app will be moved to the Trash. Your documents and settings stay. Az alkalmazás a Kukába kerül. A dokumentumaid és beállításaid megmaradnak.
|
||||
Windows will remove it like any installed program. Your documents and settings stay. A Windows úgy távolítja el az alkalmazást, mint bármely más telepített programot. A dokumentumaid és beállításaid megmaradnak.
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Az alkalmazást a rendszer csomagkezelője távolítja el. Ehhez szükség lehet a jelszavadra. A dokumentumaid és beállításaid megmaradnak.
|
||||
The app will be removed. Your documents and settings stay. Eltávolítjuk az alkalmazást. A dokumentumaid és beállításaid megmaradnak.
|
||||
Uninstall Eltávolítás
|
||||
Cancel Mégse
|
||||
Checked {ago} Ellenőrizve: {ago}
|
||||
just now épp most
|
||||
yesterday tegnap
|
||||
{month} {day}, {year} {year}. {month} {day}.
|
||||
|
||||
Automatically Automatikusan
|
||||
Every Craft app Minden Craft-alkalmazás
|
||||
Apps on this computer Alkalmazások ezen a számítógépen
|
||||
Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el
|
||||
Apps you can install Telepíthető alkalmazások
|
||||
Hide App Alkalmazás elrejtése
|
||||
Unhide App Alkalmazás megjelenítése
|
||||
Hidden Apps Rejtett alkalmazások
|
||||
Apps you hid Az általad elrejtett alkalmazások
|
||||
Checked {ago} · using {sources} Ellenőrizve: {ago} · {sources}
|
||||
{a} and {b} {a} és {b}
|
||||
Join the Discord Csatlakozás a Discordhoz
|
||||
Learn more További információ
|
||||
Minimize Kis méret
|
||||
Maximize Teljes méret
|
||||
Restore Előző méret
|
||||
New Új
|
||||
OK OK
|
||||
Open link Hivatkozás megnyitása
|
||||
Update Frissítés
|
||||
{name} icon {name} ikonja
|
||||
Loading ArtCraft Launcher… ArtCraft Launcher betöltése…
|
||||
Cancel update Frissítés megszakítása
|
||||
Stop waiting; the update can be started again later Megszakítja a várakozást. A frissítést később újra elindíthatod.
|
||||
Download again Újbóli letöltés
|
||||
Install cancelled Telepítés megszakítva
|
||||
Install cancelled. The download is kept for the next try. Telepítés megszakítva. A letöltött fájl megmarad a következő próbálkozáshoz.
|
||||
Update cancelled Frissítés megszakítva
|
||||
Uninstall cancelled Eltávolítás megszakítva
|
||||
Retry install Telepítés újrapróbálása
|
||||
Retry update to {version} Frissítés újrapróbálása: {version}
|
||||
The download is still here. It's checked again, then installed. A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük.
|
||||
Waiting for another app… Várakozás egy másik alkalmazásra…
|
||||
Waiting for other installs… Várakozás a többi telepítésre…
|
||||
|
||||
# Package formats offered for download (PackageKind::label)
|
||||
Installer (.msi) Telepítő (.msi)
|
||||
Portable (.zip) Hordozható (.zip)
|
||||
Disk image (.dmg) Lemezkép (.dmg)
|
||||
Debian package (.deb) Debian-csomag (.deb)
|
||||
RPM package (.rpm) RPM-csomag (.rpm)
|
||||
Flatpak bundle Flatpak-csomag
|
||||
Tarball (.tar.gz) Tömörített archívum (.tar.gz)
|
||||
|
||||
# Contextual: each app's tagline, keyed by app id
|
||||
photocraft Image editing Képszerkesztés
|
||||
vectorcraft Vector illustration Vektoros illusztráció
|
||||
filmcraft Video editing, color and sound Videószerkesztés, színkorrekció és hang
|
||||
lightcraft Photo library and raw development Fotók rendszerezése és RAW-előhívás
|
||||
pdfcraft Reading, organizing and protecting PDFs PDF-ek olvasása, rendezése és védelme
|
||||
effectcraft Motion graphics and visual effects Mozgógrafika és vizuális effektek
|
||||
designcraft Page layout and publishing Oldaltervezés és kiadványszerkesztés
|
||||
wordcraft Writing and document design Írás és dokumentumtervezés
|
||||
deckcraft Presentations and slide shows Prezentációk és diavetítések
|
||||
gridcraft Spreadsheets and data Táblázatok és adatok
|
||||
cadcraft Computer-aided design and drafting Számítógépes tervezés és műszaki rajz
|
||||
soundcraft Recording, editing and mixing audio Hangrögzítés, hangszerkesztés és keverés
|
||||
figcraft Interface design and prototyping Felülettervezés és prototípus-készítés
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan jan.
|
||||
month Feb febr.
|
||||
month Mar márc.
|
||||
month Apr ápr.
|
||||
month May máj.
|
||||
month Jun jún.
|
||||
month Jul júl.
|
||||
month Aug aug.
|
||||
month Sep szept.
|
||||
month Oct okt.
|
||||
month Nov nov.
|
||||
month Dec dec.
|
||||
Not checked yet Még nem történt ellenőrzés
|
||||
GitHub's hourly limit was reached; full details return in {min} min Elértük a GitHub óránkénti lekérdezési korlátját; a részletes adatok {min} perc múlva lesznek újra elérhetők
|
||||
GitHub limit hit; back in {min} min GitHub-korlát elérve; újrapróbálás {min} perc múlva
|
||||
Installing for {platform} Telepítés {platform} rendszerre
|
||||
Downloads for {platform} Letöltések {platform} rendszerre
|
||||
No downloads for this system Nincsenek letöltések ehhez a rendszerhez
|
||||
Join the ArtCraft community on Discord Csatlakozz az ArtCraft közösségéhez a Discordon
|
||||
ArtCraft on {name} ArtCraft – {name}
|
||||
Couldn't open {name}: {e} {name} nem nyitható meg: {e}
|
||||
Download cancelled Letöltés megszakítva
|
||||
Available to install Telepíthető
|
||||
Available Elérhető
|
||||
Installed · up to date Telepítve · naprakész
|
||||
Update available Frissítés elérhető
|
||||
Installed Telepítve
|
||||
installed telepítve
|
||||
Not available here Ezen a rendszeren nem érhető el
|
||||
Updating… Frissítés…
|
||||
Update paused Frissítés szüneteltetve
|
||||
Download paused Letöltés szüneteltetve
|
||||
{version} installed → {latest} released Telepítve: {version} → megjelent: {latest}
|
||||
Version {version} Verzió: {version}
|
||||
{installed} installed · latest {latest} Telepítve: {installed} · legújabb: {latest}
|
||||
Installed · latest {latest} Telepítve · legújabb: {latest}
|
||||
Version {version} installed Telepített verzió: {version}
|
||||
Version {latest} Verzió: {latest}
|
||||
No release published yet Még nem jelent meg kiadás
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App Alkalmazás
|
||||
Latest Legújabb
|
||||
Released Megjelent
|
||||
Auto Automatikus
|
||||
Language Nyelv
|
||||
Ascending Növekvő
|
||||
Descending Csökkenő
|
||||
Click to reverse the order Kattints a sorrend megfordításához
|
||||
Click to sort by this column Kattints az oszlop szerinti rendezéshez
|
||||
Like {name} Mintául szolgált: {name}
|
||||
{done} of {total} {done} / {total}
|
||||
Downloading Letöltés folyamatban
|
||||
Verifying checksum… Ellenőrzőösszeg ellenőrzése…
|
||||
Resume Folytatás
|
||||
{done} of {total} downloaded {done} / {total} letöltve
|
||||
{done} downloaded {done} letöltve
|
||||
Checking for updates… Frissítések keresése…
|
||||
Downloads the new version, replaces this one and opens it again Letölti és telepíti az új verziót, majd újraindítja az alkalmazást
|
||||
Couldn't check for updates: {f} Nem sikerült frissítéseket keresni: {f}
|
||||
Website (coming soon) Webhely (hamarosan)
|
||||
GitHub repository GitHub-tároló
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app 1 alkalmazás frissítése
|
||||
Update all ({count}) Összes frissítése ({count})
|
||||
⊞ Grid ⊞ Rács
|
||||
☰ Table ☰ Táblázat
|
||||
🔍 Search apps 🔍 Alkalmazások keresése
|
||||
{name} {version} {name} {version}
|
||||
Match system Rendszerbeállítás szerint
|
||||
Dark Sötét
|
||||
Light Világos
|
||||
Full changelog Teljes változásnapló
|
||||
link hivatkozás
|
||||
No apps match Nincs találat
|
||||
Open ArtCraft Launcher to update. Nyisd meg az ArtCraft Launchert a frissítéshez.
|
||||
{name} {v} is available Elérhető: {name} {v}
|
||||
{count} updates are available {count} frissítés elérhető
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. A kiadási információkat nem sikerült betölteni, mert elértük a GitHub lekérdezési korlátját. A kiadás oldalán megtalálod őket.
|
||||
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Ha be van kapcsolva, a menüsorból megnyitott ArtCraft Launcher ablaka az aktuális asztalon jelenik meg. Ha ki van kapcsolva, a macOS arra az asztalra vált, ahol az ablak található.
|
||||
Couldn't change the login item: {e} Az automatikus indítás beállítását nem sikerült módosítani: {e}
|
||||
|
||||
# Contextual: table column headers
|
||||
column App Alkalmazás
|
||||
column Installed Telepített
|
||||
column Latest Legújabb
|
||||
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
|
||||
@plural {n} min ago|{n} min ago {n} perce|{n} perce
|
||||
@plural {n} h ago|{n} h ago {n} órája|{n} órája
|
||||
@plural {n} day ago|{n} days ago {n} napja|{n} napja
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} {name} nem nyitható meg: {e}
|
||||
@msg Download cancelled Letöltés megszakítva
|
||||
@msg {name} updated to {version} {name} frissítve. Verzió: {version}
|
||||
@msg {name} installed: {version} {name} telepítve: {version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} frissítve. Verzió: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} telepítve: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
|
||||
@msg {name} was removed {name} eltávolítva
|
||||
@msg {name} is running. Quit it, then try again. {name} fut. Zárd be, majd próbáld újra.
|
||||
|
||||
# Command IDs for menu items (@id)
|
||||
# Format: @id<TAB>command_id<TAB>translation
|
||||
@id app.install Telepítés
|
||||
@id app.update Frissítés: {v}
|
||||
@id app.open Megnyitás
|
||||
@id app.download Letöltés {os} rendszerre
|
||||
@id app.reveal Megjelenítés a Finderben
|
||||
@id app.reinstall Újratelepítés
|
||||
@id app.uninstall Eltávolítás…
|
||||
@id app.notes Kiadási információk
|
||||
@id app.website Webhely
|
||||
@id app.github GitHub-tároló
|
||||
@id app.releases Minden kiadás
|
||||
@id launcher.update Frissítés és újraindítás
|
||||
@id launcher.download Letöltés
|
||||
@id launcher.notes Újdonságok
|
||||
@id launcher.dismiss Elrejtés
|
||||
@id settings.open Beállítások
|
||||
@id settings.quit Kilépés az ArtCraft Launcherből
|
||||
@id view.grid Rács
|
||||
@id view.table Táblázat
|
||||
@id filter.all Összes
|
||||
@id filter.installed Telepítve
|
||||
@id filter.updates Frissítések
|
||||
@id filter.notinstalled Nincs telepítve
|
||||
@id sort.suite Alapértelmezett sorrend
|
||||
@id sort.name Név
|
||||
@id sort.inspired Mintául szolgált
|
||||
@id sort.status Állapot
|
||||
@id sort.installed Telepített verzió
|
||||
@id sort.latest Legújabb verzió
|
||||
@id sort.released Legújabb kiadás
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website Webhely
|
||||
@id update.check Frissítések keresése
|
||||
@id update.check_auto Frissítések automatikus keresése
|
||||
@id update.notify Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
|
||||
@id update.prereleases Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
|
||||
@id linux.preferred_kind Új alkalmazások csomagformátuma
|
||||
@id tray.close_to_tray Az ablak bezárásakor kerüljön a tálcára
|
||||
@id tray.show_on_current_desktop Ablak megjelenítése az aktuális asztalon
|
||||
@id tray.login_item ArtCraft Launcher indítása bejelentkezéskor
|
||||
@id appearance.theme Megjelenés
|
||||
|
||||
# Tray (menu bar) menu and tooltip
|
||||
Show ArtCraft Launcher ArtCraft Launcher megjelenítése
|
||||
Check for Updates Frissítések keresése
|
||||
Visit getartcraft.com getartcraft.com megnyitása
|
||||
ArtCraft on GitHub ArtCraft a GitHubon
|
||||
Join the ArtCraft Discord Csatlakozás az ArtCraft Discord-közösségéhez
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 16.
|
|
|
@ -1,349 +0,0 @@
|
|||
# Japanese (ja) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t).
|
||||
# @id = command-id keyed (tr_id).
|
||||
# @plural = plural forms: one|other in English, one form for Japanese.
|
||||
# @msg = status/error message template (message, msg).
|
||||
# Other context = disambiguation for tr_ctx.
|
||||
#
|
||||
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Japanese,
|
||||
# using the terms Japanese-speaking users of creative software know. The user is addressed with
|
||||
# the neutral polite form (です・ます), as in most current Japanese software.
|
||||
#
|
||||
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
# Japanese has one plural form. Shown with the system's Japanese font (see cjk_font.rs); where the
|
||||
# system has none, the interface stays in English.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders クリエイター、メイカー、ビルダーのためのオープンソースツール
|
||||
Settings 設定
|
||||
Check for updates 更新を確認
|
||||
Grid グリッド
|
||||
Table テーブル
|
||||
Show apps as cards アプリをカードで表示
|
||||
Show apps as a table アプリをテーブルで表示
|
||||
All すべて
|
||||
Updates 更新
|
||||
Not installed 未インストール
|
||||
Search apps アプリを検索
|
||||
Sort 並べ替え
|
||||
Suite order スイート順
|
||||
Name 名前
|
||||
Inspired by 参考アプリ
|
||||
Status ステータス
|
||||
Installed version インストール済みバージョン
|
||||
Latest version 最新バージョン
|
||||
Newest release 最新リリース
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website ウェブサイト
|
||||
Classic apps クラシックアプリ
|
||||
New apps 新着アプリ
|
||||
NEW 新着
|
||||
Install インストール
|
||||
Update to {v} {v} に更新
|
||||
Open 開く
|
||||
Download for {os} {os} 版をダウンロード
|
||||
Download ダウンロード
|
||||
Coming soon 近日公開
|
||||
Checking… 確認中…
|
||||
Not available 利用不可
|
||||
Offline オフライン
|
||||
Downloading {done} of {total} ダウンロード中 {done} / {total}
|
||||
Downloading… ダウンロード中…
|
||||
Reconnecting… 再接続中…
|
||||
Verifying… 検証中…
|
||||
Installing, then restarting… インストール完了後再起動します…
|
||||
Installing… インストール中…
|
||||
Removing… アンインストール中…
|
||||
Pause download ダウンロードを一時停止
|
||||
Cancel download ダウンロードをキャンセル
|
||||
Resume download ダウンロードを再開
|
||||
Discard download ダウンロードを破棄
|
||||
Release notes リリースノート
|
||||
Show in Finder Finder で表示
|
||||
Show in Explorer エクスプローラーで表示
|
||||
Show in Files ファイルマネージャーで表示
|
||||
Reinstall 再インストール
|
||||
Uninstall… アンインストール…
|
||||
All releases すべてのリリース
|
||||
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} が利用可能です。
|
||||
Dismiss 閉じる
|
||||
Update and restart 更新して再起動
|
||||
A new version is available. Would you like to relaunch with the updated version? 新しいバージョンがあります。更新後のバージョンで再起動しますか?
|
||||
Later 後で
|
||||
What's new 新着情報
|
||||
No notes were published with this release. このリリースにはリリースノートが公開されていません。
|
||||
Released {date} {date} 公開
|
||||
Open on GitHub GitHub で開く
|
||||
Close 閉じる
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask 手動で確認したときのみ
|
||||
Automatically download and install updates 更新を自動でダウンロードしてインストール
|
||||
Notify me when an installed app has an update インストール済みアプリに更新があるときに通知する
|
||||
Include pre-releases (betas and release candidates) プレリリース(ベータ版とリリース候補)を含める
|
||||
Installing on Linux Linux へのインストール
|
||||
Install new apps as 新しいアプリのインストール形式
|
||||
System package (recommended) システムパッケージ(推奨)
|
||||
Updates always use the format an app was installed with. 更新は常に、アプリをインストールしたときの形式で行われます。
|
||||
Running in the background バックグラウンドで実行
|
||||
Keep running in the tray when the window is closed ウィンドウを閉じてもトレイで実行し続ける
|
||||
Show the window on the current desktop 現在のデスクトップにウィンドウを表示
|
||||
Start ArtCraft Launcher when I log in ログイン時に ArtCraft Launcher を起動
|
||||
Community コミュニティ
|
||||
Questions, ideas and news from the ArtCraft team ArtCraft チームからの質問、アイデア、ニュース
|
||||
Appearance 外観
|
||||
Quit ArtCraft Launcher ArtCraft Launcher を終了
|
||||
Done 完了
|
||||
Uninstall {name}? {name} をアンインストールしますか?
|
||||
The app will be moved to the Trash. Your documents and settings stay. アプリはゴミ箱に移動されます。ドキュメントと設定はそのまま残ります。
|
||||
Windows will remove it like any installed program. Your documents and settings stay. Windows は他のインストール済みプログラムと同様にアンインストールします。ドキュメントと設定はそのまま残ります。
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. システムのパッケージマネージャーがアンインストールします。パスワードの入力を求められる場合があります。ドキュメントと設定はそのまま残ります。
|
||||
The app will be removed. Your documents and settings stay. アプリはアンインストールされます。ドキュメントと設定はそのまま残ります。
|
||||
Uninstall アンインストール
|
||||
Cancel キャンセル
|
||||
Checked {ago} 最終確認: {ago}
|
||||
just now たった今
|
||||
yesterday 昨日
|
||||
{month} {day}, {year} {year}年{month}{day}日
|
||||
|
||||
Automatically 自動
|
||||
Every Craft app すべての Craft アプリ
|
||||
Apps on this computer このコンピューターのアプリ
|
||||
Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ
|
||||
Apps you can install インストールできるアプリ
|
||||
Hide App アプリを非表示
|
||||
Unhide App アプリを再表示
|
||||
Hidden Apps 非表示のアプリ
|
||||
Apps you hid 非表示にしたアプリ
|
||||
Checked {ago} · using {sources} 最終確認: {ago} · 取得元: {sources}
|
||||
{a} and {b} {a} と {b}
|
||||
Join the Discord Discord に参加
|
||||
Learn more 詳細を見る
|
||||
Minimize 最小化
|
||||
Maximize 最大化
|
||||
Restore 元に戻す
|
||||
New 新着
|
||||
OK OK
|
||||
Open link リンクを開く
|
||||
Update 更新
|
||||
{name} icon {name} のアイコン
|
||||
Loading ArtCraft Launcher… ArtCraft Launcher を読み込み中…
|
||||
Cancel update 更新をキャンセル
|
||||
Stop waiting; the update can be started again later 待機を中止します。更新は後で再開できます
|
||||
Download again 再ダウンロード
|
||||
Install cancelled インストールをキャンセルしました
|
||||
Install cancelled. The download is kept for the next try. インストールをキャンセルしました。ダウンロードしたファイルは次回のために保持されます。
|
||||
Update cancelled 更新をキャンセルしました
|
||||
Uninstall cancelled アンインストールをキャンセルしました
|
||||
Retry install インストールを再試行
|
||||
Retry update to {version} {version} への更新を再試行
|
||||
The download is still here. It's checked again, then installed. ダウンロードしたファイルはそのまま残っています。もう一度検証してからインストールします。
|
||||
Waiting for another app… 他のアプリを待機中…
|
||||
Waiting for other installs… 他のインストールを待機中…
|
||||
|
||||
# Package formats offered for download (PackageKind::label)
|
||||
Installer (.msi) インストーラー (.msi)
|
||||
Portable (.zip) ポータブル版 (.zip)
|
||||
Disk image (.dmg) ディスクイメージ (.dmg)
|
||||
Debian package (.deb) Debian パッケージ (.deb)
|
||||
RPM package (.rpm) RPM パッケージ (.rpm)
|
||||
Flatpak bundle Flatpak バンドル
|
||||
Tarball (.tar.gz) tarball (.tar.gz)
|
||||
|
||||
# Contextual: each app's tagline, keyed by app id
|
||||
photocraft Image editing 画像編集
|
||||
vectorcraft Vector illustration ベクターイラスト
|
||||
filmcraft Video editing, color and sound 動画編集、カラー、サウンド
|
||||
lightcraft Photo library and raw development フォトライブラリと RAW 現像
|
||||
pdfcraft Reading, organizing and protecting PDFs PDF の閲覧、整理、保護
|
||||
effectcraft Motion graphics and visual effects モーショングラフィックスと視覚効果
|
||||
designcraft Page layout and publishing ページレイアウトと出版
|
||||
wordcraft Writing and document design 文書作成とデザイン
|
||||
deckcraft Presentations and slide shows プレゼンテーションとスライドショー
|
||||
gridcraft Spreadsheets and data 表計算とデータ
|
||||
cadcraft Computer-aided design and drafting コンピューター支援設計と製図
|
||||
soundcraft Recording, editing and mixing audio オーディオの録音、編集、ミックス
|
||||
figcraft Interface design and prototyping インターフェースデザインとプロトタイピング
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan 1月
|
||||
month Feb 2月
|
||||
month Mar 3月
|
||||
month Apr 4月
|
||||
month May 5月
|
||||
month Jun 6月
|
||||
month Jul 7月
|
||||
month Aug 8月
|
||||
month Sep 9月
|
||||
month Oct 10月
|
||||
month Nov 11月
|
||||
month Dec 12月
|
||||
Not checked yet まだ確認していません
|
||||
GitHub's hourly limit was reached; full details return in {min} min GitHub の時間あたりの上限に達しました。{min} 分後に詳細が表示されます
|
||||
GitHub limit hit; back in {min} min GitHub の上限に達しました。{min} 分後に復旧します
|
||||
Installing for {platform} {platform} にインストール
|
||||
Downloads for {platform} {platform} 用のダウンロード
|
||||
No downloads for this system このシステム用のダウンロードはありません
|
||||
Join the ArtCraft community on Discord Discord で ArtCraft コミュニティに参加
|
||||
ArtCraft on {name} {name} の ArtCraft
|
||||
Couldn't open {name}: {e} {name} を開けませんでした: {e}
|
||||
Download cancelled ダウンロードをキャンセルしました
|
||||
Available to install インストール可能
|
||||
Available 利用可能
|
||||
Installed · up to date インストール済み · 最新
|
||||
Update available 更新あり
|
||||
Installed インストール済み
|
||||
installed インストール済み
|
||||
Not available here このプラットフォームでは利用不可
|
||||
Updating… 更新中…
|
||||
Update paused 更新を一時停止
|
||||
Download paused ダウンロードを一時停止
|
||||
{version} installed → {latest} released {version} インストール済み → {latest} 公開
|
||||
Version {version} バージョン {version}
|
||||
{installed} installed · latest {latest} {installed} インストール済み · 最新 {latest}
|
||||
Installed · latest {latest} インストール済み · 最新 {latest}
|
||||
Version {version} installed バージョン {version} インストール済み
|
||||
Version {latest} バージョン {latest}
|
||||
No release published yet まだリリースがありません
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App アプリ
|
||||
Latest 最新
|
||||
Released 公開日
|
||||
Auto 自動
|
||||
Language 言語
|
||||
Ascending 昇順
|
||||
Descending 降順
|
||||
Click to reverse the order クリックして順序を反転
|
||||
Click to sort by this column クリックしてこの列で並べ替え
|
||||
Like {name} {name} と同様
|
||||
{done} of {total} {done} / {total}
|
||||
Downloading ダウンロード中
|
||||
Verifying checksum… チェックサムを検証中…
|
||||
Resume 再開
|
||||
{done} of {total} downloaded {done} / {total} ダウンロード済み
|
||||
{done} downloaded {done} ダウンロード済み
|
||||
Checking for updates… 更新を確認中…
|
||||
Downloads the new version, replaces this one and opens it again 新しいバージョンをダウンロードし、現在のバージョンと置き換えて再び開きます
|
||||
Couldn't check for updates: {f} 更新を確認できませんでした: {f}
|
||||
Website (coming soon) ウェブサイト(近日公開)
|
||||
GitHub repository GitHub リポジトリ
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app 1 個のアプリを更新
|
||||
Update all ({count}) すべて更新({count})
|
||||
⊞ Grid ⊞ グリッド
|
||||
☰ Table ☰ テーブル
|
||||
🔍 Search apps 🔍 アプリを検索
|
||||
{name} {version} {name} {version}
|
||||
Match system システムに合わせる
|
||||
Dark ダーク
|
||||
Light ライト
|
||||
Full changelog 完全な変更履歴
|
||||
link リンク
|
||||
No apps match 一致するアプリがありません
|
||||
Open ArtCraft Launcher to update. 更新するには ArtCraft Launcher を開いてください。
|
||||
{name} {v} is available {name} {v} が利用可能です
|
||||
{count} updates are available {count} 件の更新があります
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. このリリースノートは取得できませんでした(GitHub のリクエスト上限に達しました)。リリースページにあります。
|
||||
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. オンにすると、メニューバーからランチャーを開いたときにウィンドウが現在のデスクトップに表示されます。オフにすると、macOS はウィンドウがあったデスクトップに切り替えます。
|
||||
Couldn't change the login item: {e} ログイン項目を変更できませんでした: {e}
|
||||
|
||||
# Contextual: table column headers
|
||||
column App アプリ
|
||||
column Installed インストール済みバージョン
|
||||
column Latest 最新バージョン
|
||||
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} 個のアプリ
|
||||
@plural {n} min ago|{n} min ago {n} 分前
|
||||
@plural {n} h ago|{n} h ago {n} 時間前
|
||||
@plural {n} day ago|{n} days ago {n} 日前
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} {name} を開けませんでした: {e}
|
||||
@msg Download cancelled ダウンロードをキャンセルしました
|
||||
@msg {name} updated to {version} {name} を {version} に更新しました
|
||||
@msg {name} installed: {version} {name} をインストールしました: {version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} を {version} に更新しました(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} をインストールしました: {version}(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
|
||||
@msg {name} was removed {name} をアンインストールしました
|
||||
@msg {name} is running. Quit it, then try again. {name} が実行中です。終了してから再試行してください。
|
||||
|
||||
# Command IDs for menu items (@id)
|
||||
# Format: @id<TAB>command_id<TAB>translation
|
||||
@id app.install インストール
|
||||
@id app.update {v} に更新
|
||||
@id app.open 開く
|
||||
@id app.download {os} 版をダウンロード
|
||||
@id app.reveal Finder で表示
|
||||
@id app.reinstall 再インストール
|
||||
@id app.uninstall アンインストール…
|
||||
@id app.notes リリースノート
|
||||
@id app.website ウェブサイト
|
||||
@id app.github GitHub リポジトリ
|
||||
@id app.releases すべてのリリース
|
||||
@id launcher.update 更新して再起動
|
||||
@id launcher.download ダウンロード
|
||||
@id launcher.notes 新着情報
|
||||
@id launcher.dismiss 閉じる
|
||||
@id settings.open 設定
|
||||
@id settings.quit ArtCraft Launcher を終了
|
||||
@id view.grid グリッド
|
||||
@id view.table テーブル
|
||||
@id filter.all すべて
|
||||
@id filter.installed インストール済み
|
||||
@id filter.updates 更新
|
||||
@id filter.notinstalled 未インストール
|
||||
@id sort.suite スイート順
|
||||
@id sort.name 名前
|
||||
@id sort.inspired 参考アプリ
|
||||
@id sort.status ステータス
|
||||
@id sort.installed インストール済みバージョン
|
||||
@id sort.latest 最新バージョン
|
||||
@id sort.released 最新リリース
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website ウェブサイト
|
||||
@id update.check 更新を確認
|
||||
@id update.check_auto 自動で更新を確認
|
||||
@id update.notify インストール済みアプリに更新があるときに通知する
|
||||
@id update.prereleases プレリリース(ベータ版とリリース候補)を含める
|
||||
@id linux.preferred_kind 新しいアプリのインストール形式
|
||||
@id tray.close_to_tray ウィンドウを閉じてもトレイで実行し続ける
|
||||
@id tray.show_on_current_desktop 現在のデスクトップにウィンドウを表示
|
||||
@id tray.login_item ログイン時に ArtCraft Launcher を起動
|
||||
@id appearance.theme 外観
|
||||
|
||||
# Tray (menu bar) menu and tooltip
|
||||
Show ArtCraft Launcher ArtCraft Launcher を表示
|
||||
Check for Updates 更新を確認
|
||||
Visit getartcraft.com getartcraft.com を開く
|
||||
ArtCraft on GitHub GitHub の ArtCraft
|
||||
Join the ArtCraft Discord ArtCraft Discord に参加
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 件の更新があります
|
||||
|
Can't render this file because it contains an unexpected character in line 14 and column 44.
|
|
|
@ -5,38 +5,21 @@ use std::sync::OnceLock;
|
|||
use super::catalog::Catalog;
|
||||
use super::detect::system_lang;
|
||||
|
||||
/// The writing system a language is drawn in, and so the system font it needs. Languages that
|
||||
/// share one can share a face; Simplified and Traditional Chinese do not (they want different
|
||||
/// faces), so each script is its own. The bundled faces have no CJK glyphs, so a language with a
|
||||
/// script borrows a system font for it (`crate::theme::cjk_font`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
Chinese,
|
||||
Japanese,
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// Every script a language can be written in.
|
||||
pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];
|
||||
}
|
||||
|
||||
/// One supported UI language.
|
||||
pub struct LangInfo {
|
||||
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).
|
||||
/// Also the `interfaceLanguage` value.
|
||||
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`). Also the `interfaceLanguage` value.
|
||||
pub code: &'static str,
|
||||
/// The language's name in itself, shown in the Preferences dropdown.
|
||||
pub name: &'static str,
|
||||
/// The language's name in English, shown instead where its own script can't be drawn.
|
||||
pub english_name: &'static str,
|
||||
/// The writing system this language is drawn in, or `None` if it needs no system CJK font.
|
||||
pub script: Option<Script>,
|
||||
/// Does its script need a CJK font? The bundled faces have none, so it comes from the
|
||||
/// system (`theme::cjk_font`).
|
||||
pub cjk: bool,
|
||||
/// Catalog file contents (empty for the built-in English).
|
||||
pub source: &'static str,
|
||||
/// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;
|
||||
/// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only
|
||||
/// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect
|
||||
/// for number: always 0).
|
||||
/// Plural form index for a count (English, German and Spanish: 0 = one, 1 = other; Russian: 0 = one,
|
||||
/// 1 = few, 2 = many; French: 0 = one for 0 and 1; Chinese: always 0).
|
||||
pub plural: fn(u64) -> usize,
|
||||
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
|
||||
pub plural_forms: usize,
|
||||
|
|
@ -54,7 +37,7 @@ fn plural_zero_one(n: u64) -> usize {
|
|||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// A language whose nouns don't inflect for number: one form for every count.
|
||||
/// Languages whose nouns don't inflect for number (Chinese): one form for every count.
|
||||
fn plural_none(_: u64) -> usize {
|
||||
0
|
||||
}
|
||||
|
|
@ -72,34 +55,13 @@ pub(super) fn plural_east_slavic(n: u64) -> usize {
|
|||
}
|
||||
}
|
||||
|
||||
/// Czech integer counts: 1 is `one`, 2–4 are `few`, everything else is `other`.
|
||||
pub(super) fn plural_czech(n: u64) -> usize {
|
||||
match n {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish integer counts: only 1 itself is `one` (21 is "21 aplikacji"), 2–4 in the last digit
|
||||
/// (but not 12–14) `few`, everything else `many`.
|
||||
pub(super) fn plural_polish(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
0
|
||||
} else if matches!(n % 10, 2..=4) && !matches!(n % 100, 12..=14) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 11] = [
|
||||
pub static LANGUAGES: [LangInfo; 7] = [
|
||||
LangInfo {
|
||||
code: "en",
|
||||
name: "English",
|
||||
english_name: "English",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: "",
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
|
|
@ -110,7 +72,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "ru",
|
||||
name: "Русский",
|
||||
english_name: "Russian",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: include_str!("ru.tsv"),
|
||||
plural: plural_east_slavic,
|
||||
plural_forms: 3,
|
||||
|
|
@ -121,7 +83,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "zh-CN",
|
||||
name: "简体中文",
|
||||
english_name: "Simplified Chinese",
|
||||
script: Some(Script::Chinese),
|
||||
cjk: true,
|
||||
source: include_str!("zh-CN.tsv"),
|
||||
plural: plural_none,
|
||||
plural_forms: 1,
|
||||
|
|
@ -132,7 +94,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "de",
|
||||
name: "Deutsch",
|
||||
english_name: "German",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: include_str!("de.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
|
|
@ -143,7 +105,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "fr",
|
||||
name: "Français",
|
||||
english_name: "French",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: include_str!("fr.tsv"),
|
||||
plural: plural_zero_one,
|
||||
plural_forms: 2,
|
||||
|
|
@ -154,7 +116,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "pt-BR",
|
||||
name: "Português (Brasil)",
|
||||
english_name: "Portuguese (Brazil)",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: include_str!("pt-BR.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
|
|
@ -165,57 +127,13 @@ pub static LANGUAGES: [LangInfo; 11] = [
|
|||
code: "es",
|
||||
name: "Español",
|
||||
english_name: "Spanish",
|
||||
script: None,
|
||||
cjk: false,
|
||||
source: include_str!("es.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "cs",
|
||||
name: "Čeština",
|
||||
english_name: "Czech",
|
||||
script: None,
|
||||
source: include_str!("cs.tsv"),
|
||||
plural: plural_czech,
|
||||
plural_forms: 3,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
english_name: "Polish",
|
||||
script: None,
|
||||
source: include_str!("pl.tsv"),
|
||||
plural: plural_polish,
|
||||
plural_forms: 3,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "hu",
|
||||
name: "Magyar",
|
||||
english_name: "Hungarian",
|
||||
script: None,
|
||||
source: include_str!("hu.tsv"),
|
||||
plural: plural_one_other,
|
||||
plural_forms: 2,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo {
|
||||
code: "ja",
|
||||
name: "日本語",
|
||||
english_name: "Japanese",
|
||||
script: Some(Script::Japanese),
|
||||
source: include_str!("ja.tsv"),
|
||||
plural: plural_none,
|
||||
plural_forms: 1,
|
||||
complete_menus: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
|
|
@ -250,10 +168,6 @@ impl Lang {
|
|||
pub const FR: Lang = Lang(&LANGUAGES[4]);
|
||||
pub const PT_BR: Lang = Lang(&LANGUAGES[5]);
|
||||
pub const ES: Lang = Lang(&LANGUAGES[6]);
|
||||
pub const CS: Lang = Lang(&LANGUAGES[7]);
|
||||
pub const PL: Lang = Lang(&LANGUAGES[8]);
|
||||
pub const HU: Lang = Lang(&LANGUAGES[9]);
|
||||
pub const JA: Lang = Lang(&LANGUAGES[10]);
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.0.code
|
||||
|
|
@ -286,12 +200,7 @@ impl Lang {
|
|||
|
||||
/// Does drawing this language need a CJK font from the system?
|
||||
pub fn needs_cjk_font(self) -> bool {
|
||||
self.0.script.is_some()
|
||||
}
|
||||
|
||||
/// The writing system this language is drawn in, if it needs a system CJK font.
|
||||
pub fn script(self) -> Option<Script> {
|
||||
self.0.script
|
||||
self.0.cjk
|
||||
}
|
||||
|
||||
/// Does this language's catalog claim to cover every `tl!` literal?
|
||||
|
|
|
|||
|
|
@ -4,12 +4,7 @@
|
|||
//! # Adding a language
|
||||
//! 1. Add `xx.tsv` next to `ru.tsv` (copy its header; translate from the *meaning* of the
|
||||
//! English text, clean-room).
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). For a complete
|
||||
//! catalog, cover every UI literal and plural key; the tests enforce that claim. A translation
|
||||
//! that isn't finished yet starts with `complete_menus: false`: it still shows in
|
||||
//! Preferences, strings without a row fall back to English, and the completeness tests
|
||||
//! skip it until every row is done. A CJK language also sets `script` and takes its system
|
||||
//! font from `crate::cjk_font`.
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
|
||||
//!
|
||||
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests
|
||||
//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the
|
||||
|
|
@ -35,5 +30,5 @@ mod translate;
|
|||
mod tests;
|
||||
|
||||
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag};
|
||||
pub use lang::{LANGUAGES, Lang, LangInfo, lang_from_tag};
|
||||
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};
|
||||
|
|
|
|||
|
|
@ -1,348 +0,0 @@
|
|||
# Polish (pl) catalog for ArtCraft Launcher.
|
||||
# Format: context<TAB>source<TAB>translation
|
||||
# Empty context = plain UI string (tl!, t).
|
||||
# @id = command-id keyed (tr_id).
|
||||
# @plural = plural forms: one|other (English) or one|few|many (Polish).
|
||||
# @msg = status/error message template (message, msg).
|
||||
# Other context = disambiguation for tr_ctx.
|
||||
#
|
||||
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Polish,
|
||||
# using the terms Polish users of creative and system software know. The user is addressed
|
||||
# informally ("twoje", "cię"), as in Polish system interfaces.
|
||||
#
|
||||
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
|
||||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
|
||||
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
|
||||
# "Discord".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
||||
Launcher Launcher
|
||||
Craft App Suite Craft App Suite
|
||||
Open Source Tools for Creatives, Makers, and Builders Narzędzia open source dla artystów, twórców i konstruktorów
|
||||
Settings Ustawienia
|
||||
Check for updates Sprawdź dostępność aktualizacji
|
||||
Grid Siatka
|
||||
Table Tabela
|
||||
Show apps as cards Wyświetlaj listę jako karty
|
||||
Show apps as a table Wyświetlaj listę jako tabelę
|
||||
All Wszystkie
|
||||
Updates Aktualizacje
|
||||
Not installed Niezainstalowane
|
||||
Search apps Wyszukaj aplikacje
|
||||
Sort Sortowanie
|
||||
Suite order Domyślna kolejność
|
||||
Name Nazwa
|
||||
Inspired by Wzorowane na
|
||||
Status Status
|
||||
Installed version Zainstalowana wersja
|
||||
Latest version Najnowsza wersja
|
||||
Newest release Najnowsze wydanie
|
||||
Discord Discord
|
||||
GitHub GitHub
|
||||
Website Strona
|
||||
Classic apps Klasyczne aplikacje
|
||||
New apps Nowe aplikacje
|
||||
Classic Klasyk
|
||||
NEW NOWE
|
||||
Install Instaluj
|
||||
Update to {v} Zaktualizuj do {v}
|
||||
Open Otwórz
|
||||
Download for {os} Pobierz dla {os}
|
||||
Download Pobierz
|
||||
Coming soon Wkrótce
|
||||
Checking… Sprawdzanie…
|
||||
Not available Niedostępna
|
||||
Offline Offline
|
||||
Downloading {done} of {total} Pobrano {done} z {total}
|
||||
Downloading… Pobieranie…
|
||||
Reconnecting… Wznawianie połączenia…
|
||||
Verifying… Weryfikowanie…
|
||||
Installing, then restarting… Instalowanie a następnie ponowne uruchomienie…
|
||||
Installing… Instalowanie…
|
||||
Removing… Usuwanie…
|
||||
Pause download Wstrzymaj pobieranie
|
||||
Cancel download Anuluj pobieranie
|
||||
Resume download Wznów pobieranie
|
||||
Discard download Usuń pobrane
|
||||
Release notes Informacje o wydaniu
|
||||
Show in Finder Pokaż w Finderze
|
||||
Show in Explorer Pokaż w Eksploratorze plików
|
||||
Show in Files Pokaż w menedżerze plików
|
||||
Reinstall Przeinstaluj
|
||||
Uninstall… Odinstaluj…
|
||||
All releases Wszystkie wydania
|
||||
ArtCraft Launcher {version} is available. Dostępna jest nowa wersja ArtCraft Launcher ({version}).
|
||||
Dismiss Zamknij
|
||||
Update and restart Zaktualizuj i zrestartuj
|
||||
A new version is available. Would you like to relaunch with the updated version? Nowa wersja jest dostępna. Czy chcesz uruchomić ponownie ze zaktualizowaną wersją
|
||||
Later Później
|
||||
What's new Co nowego
|
||||
No notes were published with this release. Dla tego wydania nie opublikowano żadnych notatek.
|
||||
Released {date} Wydano {date}
|
||||
Open on GitHub Otwórz na GitHubie
|
||||
Close Zamknij
|
||||
ArtCraft Launcher {version} ArtCraft Launcher {version}
|
||||
Only when I ask Tylko kiedy zapytam
|
||||
Automatically download and install updates Automatycznie pobieraj i instaluj aktualizacje
|
||||
Notify me when an installed app has an update Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
|
||||
Include pre-releases (betas and release candidates) Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
|
||||
Installing on Linux Instalowanie w systemie Linux
|
||||
Install new apps as Instaluj nowe aplikacje jako
|
||||
System package (recommended) Pakiet systemowy (zalecany)
|
||||
Updates always use the format an app was installed with. Aktualizacje zawsze używają formatu, w jakim pierwotnie zainstalowano aplikację.
|
||||
Running in the background Działanie w tle
|
||||
Keep running in the tray when the window is closed Po zamknięciu okna działaj w zasobniku systemowym
|
||||
Show the window on the current desktop Pokaż okno na bieżącym pulpicie
|
||||
Start ArtCraft Launcher when I log in Uruchom ArtCraft Launcher po zalogowaniu
|
||||
Community Społeczność
|
||||
Questions, ideas and news from the ArtCraft team Pytania, pomysły i nowości od zespołu ArtCraft
|
||||
Appearance Motyw
|
||||
Quit ArtCraft Launcher Wyjdź z ArtCraft Launcher
|
||||
Done Gotowe
|
||||
Uninstall {name}? Odinstalować {name}?
|
||||
The app will be moved to the Trash. Your documents and settings stay. Aplikacja zostanie przeniesiona do kosza. Twoje dokumenty i ustawienia pozostaną.
|
||||
Windows will remove it like any installed program. Your documents and settings stay. Windows usunie to jak normalny zainstalowany program. Twoje dokumenty i ustawienia pozostaną.
|
||||
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Twój systemowy menedżer pakietów usunie aplikację, może też zapytać cię o hasło. Twoje dokumenty i ustawienia pozostaną.
|
||||
The app will be removed. Your documents and settings stay. Ta aplikacja zostanie usunięta. Twoje dokumenty i ustawienia pozostaną.
|
||||
Uninstall Odinstaluj
|
||||
Cancel Anuluj
|
||||
Checked {ago} Ostatnio sprawdzono {ago}
|
||||
just now przed chwilą
|
||||
yesterday wczoraj
|
||||
{month} {day}, {year} {day} {month} {year}
|
||||
|
||||
Automatically Automatycznie
|
||||
Every Craft app Wszystkie aplikacje Craft
|
||||
Apps on this computer Aplikacje na tym komputerze
|
||||
Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją
|
||||
Apps you can install Aplikacje, które możesz zainstalować
|
||||
Hide App Ukryj aplikację
|
||||
Unhide App Pokaż aplikację
|
||||
Hidden Apps Ukryte aplikacje
|
||||
Apps you hid Aplikacje ukryte przez ciebie
|
||||
Checked {ago} · using {sources} Ostatnio sprawdzono {ago} · przy użyciu {sources}
|
||||
{a} and {b} {a} i {b}
|
||||
Join the Discord Dołącz na Discorda
|
||||
Learn more Więcej
|
||||
Minimize Minimalizuj
|
||||
Maximize Maksymalizuj
|
||||
Restore Przywróć
|
||||
New Nowe
|
||||
OK OK
|
||||
Open link Otwórz link
|
||||
Update Aktualizacja
|
||||
{name} icon Ikona {name}
|
||||
Loading ArtCraft Launcher… Ładowanie ArtCraft Launcher…
|
||||
Cancel update Anuluj aktualizację
|
||||
Stop waiting; the update can be started again later Przerwij oczekiwanie; aktualizację można uruchomić ponownie później
|
||||
Download again Pobierz ponownie
|
||||
Install cancelled Anulowano instalację
|
||||
Install cancelled. The download is kept for the next try. Anulowano instalację. Pobrany plik zostanie zachowany na następną próbę.
|
||||
Update cancelled Anulowano aktualizację
|
||||
Uninstall cancelled Anulowano odinstalowanie
|
||||
Retry install Ponów instalację
|
||||
Retry update to {version} Ponów aktualizację do {version}
|
||||
The download is still here. It's checked again, then installed. Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany.
|
||||
Waiting for another app… Oczekiwanie na inną aplikację…
|
||||
Waiting for other installs… Oczekiwanie na inne instalacje…
|
||||
|
||||
# Package formats offered for download (PackageKind::label)
|
||||
Installer (.msi) Instalator (.msi)
|
||||
Portable (.zip) Wersja przenośna (.zip)
|
||||
Disk image (.dmg) Obraz dysku (.dmg)
|
||||
Debian package (.deb) Pakiet Debiana (.deb)
|
||||
RPM package (.rpm) Pakiet RPM (.rpm)
|
||||
Flatpak bundle Pakiet Flatpak
|
||||
Tarball (.tar.gz) Archiwum (.tar.gz)
|
||||
|
||||
# Contextual: each app's tagline, keyed by app id
|
||||
photocraft Image editing Edycja zdjęć
|
||||
vectorcraft Vector illustration Ilustracja wektorowa
|
||||
filmcraft Video editing, color and sound Montaż wideo, kolor i dźwięk
|
||||
lightcraft Photo library and raw development Biblioteka zdjęć i obróbka RAW
|
||||
pdfcraft Reading, organizing and protecting PDFs Czytanie, porządkowanie i zabezpieczanie plików PDF
|
||||
effectcraft Motion graphics and visual effects Grafika ruchoma i efekty wizualne
|
||||
designcraft Page layout and publishing Skład stron i publikowanie
|
||||
wordcraft Writing and document design Pisanie i projektowanie dokumentów
|
||||
deckcraft Presentations and slide shows Prezentacje i pokazy slajdów
|
||||
gridcraft Spreadsheets and data Arkusze kalkulacyjne i dane
|
||||
cadcraft Computer-aided design and drafting Projektowanie i kreślenie wspomagane komputerowo
|
||||
soundcraft Recording, editing and mixing audio Nagrywanie, edycja i miksowanie dźwięku
|
||||
figcraft Interface design and prototyping Projektowanie interfejsów i prototypowanie
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan sty
|
||||
month Feb lut
|
||||
month Mar mar
|
||||
month Apr kwi
|
||||
month May maj
|
||||
month Jun cze
|
||||
month Jul lip
|
||||
month Aug sie
|
||||
month Sep wrz
|
||||
month Oct paź
|
||||
month Nov lis
|
||||
month Dec gru
|
||||
Not checked yet Jeszcze nie sprawdzono
|
||||
GitHub's hourly limit was reached; full details return in {min} min Osiągnięto godzinowy limit GitHuba; pełne informacje wrócą za {min} min
|
||||
GitHub limit hit; back in {min} min Limit GitHuba osiągnięty; powrót za {min} min
|
||||
Installing for {platform} Instalowanie dla {platform}
|
||||
Downloads for {platform} Do pobrania dla {platform}
|
||||
No downloads for this system Brak pobrań dla tego systemu
|
||||
Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie
|
||||
ArtCraft on {name} ArtCraft w serwisie {name}
|
||||
Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
|
||||
Download cancelled Anulowano pobieranie
|
||||
Available to install Dostępna do instalacji
|
||||
Available Dostępna
|
||||
Installed · up to date Zainstalowana · aktualna
|
||||
Update available Dostępna aktualizacja
|
||||
Installed Zainstalowana
|
||||
installed zainstalowana
|
||||
Not available here Niedostępna w tym systemie
|
||||
Updating… Aktualizowanie…
|
||||
Update paused Aktualizacja wstrzymana
|
||||
Download paused Pobieranie wstrzymane
|
||||
{version} installed → {latest} released {version} zainstalowana → {latest} wydana
|
||||
Version {version} Wersja {version}
|
||||
{installed} installed · latest {latest} {installed} zainstalowana · najnowsza {latest}
|
||||
Installed · latest {latest} Zainstalowana · najnowsza {latest}
|
||||
Version {version} installed Wersja {version} zainstalowana
|
||||
Version {latest} Wersja {latest}
|
||||
No release published yet Nie opublikowano jeszcze wydania
|
||||
ArtCraft Launcher ArtCraft Launcher
|
||||
App Aplikacja
|
||||
Latest Najnowsza
|
||||
Released Wydano
|
||||
Auto Automatycznie
|
||||
Language Język
|
||||
Ascending Rosnąco
|
||||
Descending Malejąco
|
||||
Click to reverse the order Kliknij, aby odwrócić kolejność
|
||||
Click to sort by this column Kliknij, aby posortować według tej kolumny
|
||||
Like {name} Wzorowane na {name}
|
||||
{done} of {total} {done} z {total}
|
||||
Downloading Pobieranie
|
||||
Verifying checksum… Weryfikowanie sumy kontrolnej…
|
||||
Resume Wznów
|
||||
{done} of {total} downloaded Pobrano {done} z {total}
|
||||
{done} downloaded Pobrano {done}
|
||||
Checking for updates… Sprawdzanie aktualizacji…
|
||||
Downloads the new version, replaces this one and opens it again Pobiera nową wersję, zastępuje obecną i uruchamia ją ponownie.
|
||||
Couldn't check for updates: {f} Nie udało się sprawdzić aktualizacji: {f}
|
||||
Website (coming soon) Strona (wkrótce)
|
||||
GitHub repository Repozytorium na GitHubie
|
||||
ArtCraft ArtCraft
|
||||
Update 1 app Zaktualizuj 1 aplikację
|
||||
Update all ({count}) Zaktualizuj wszystkie ({count})
|
||||
⊞ Grid ⊞ Siatka
|
||||
☰ Table ☰ Tabela
|
||||
🔍 Search apps 🔍 Wyszukaj aplikacje
|
||||
{name} {version} {name} {version}
|
||||
Match system Zgodnie z systemem
|
||||
Dark Ciemny
|
||||
Light Jasny
|
||||
Full changelog Pełna lista zmian
|
||||
link link
|
||||
No apps match Brak pasujących aplikacji
|
||||
Open ArtCraft Launcher to update. Otwórz ArtCraft Launcher, aby zaktualizować aplikacje.
|
||||
{name} {v} is available Dostępna jest nowa wersja {name} {v}
|
||||
{count} updates are available Dostępne aktualizacje: {count}
|
||||
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Nie udało się pobrać tych notatek (osiągnięto limit zapytań GitHuba). Znajdziesz je na stronie wydania.
|
||||
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Gdy włączone, otwarcie launchera z paska menu przenosi jego okno na bieżący pulpit. Gdy wyłączone, macOS przełącza cię na pulpit, na którym było okno.
|
||||
Couldn't change the login item: {e} Nie udało się zmienić elementu logowania: {e}
|
||||
|
||||
# Contextual: table column headers
|
||||
column App Aplikacja
|
||||
column Installed Zainstalowana
|
||||
column Latest Najnowsza
|
||||
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
|
||||
@plural {n} min ago|{n} min ago {n} min temu|{n} min temu|{n} min temu
|
||||
@plural {n} h ago|{n} h ago {n} godz. temu|{n} godz. temu|{n} godz. temu
|
||||
@plural {n} day ago|{n} days ago {n} dzień temu|{n} dni temu|{n} dni temu
|
||||
|
||||
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
|
||||
@msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
|
||||
@msg Download cancelled Anulowano pobieranie
|
||||
@msg {name} updated to {version} Zaktualizowano {name} do wersji {version}
|
||||
@msg {name} installed: {version} Zainstalowano {name} w wersji {version}
|
||||
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Zaktualizowano {name} do wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
|
||||
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Zainstalowano {name} w wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
|
||||
@msg {name} was removed Usunięto {name}
|
||||
@msg {name} is running. Quit it, then try again. {name} jest uruchomiona. Zamknij ją i spróbuj ponownie.
|
||||
|
||||
# Command IDs for menu items (@id)
|
||||
# Format: @id<TAB>command_id<TAB>translation
|
||||
@id app.install Instaluj
|
||||
@id app.update Zaktualizuj do {v}
|
||||
@id app.open Otwórz
|
||||
@id app.download Pobierz dla {os}
|
||||
@id app.reveal Pokaż w Finderze
|
||||
@id app.reinstall Przeinstaluj
|
||||
@id app.uninstall Odinstaluj…
|
||||
@id app.notes Informacje o wydaniu
|
||||
@id app.website Strona
|
||||
@id app.github Repozytorium na GitHubie
|
||||
@id app.releases Wszystkie wydania
|
||||
@id launcher.update Zaktualizuj i zrestartuj
|
||||
@id launcher.download Pobierz
|
||||
@id launcher.notes Co nowego
|
||||
@id launcher.dismiss Zamknij
|
||||
@id settings.open Ustawienia
|
||||
@id settings.quit Wyjdź z ArtCraft Launcher
|
||||
@id view.grid Siatka
|
||||
@id view.table Tabela
|
||||
@id filter.all Wszystkie
|
||||
@id filter.installed Zainstalowane
|
||||
@id filter.updates Aktualizacje
|
||||
@id filter.notinstalled Niezainstalowane
|
||||
@id sort.suite Domyślna kolejność
|
||||
@id sort.name Nazwa
|
||||
@id sort.inspired Wzorowane na
|
||||
@id sort.status Status
|
||||
@id sort.installed Zainstalowana wersja
|
||||
@id sort.latest Najnowsza wersja
|
||||
@id sort.released Najnowsze wydanie
|
||||
@id help.discord Discord
|
||||
@id help.github GitHub
|
||||
@id help.website Strona
|
||||
@id update.check Sprawdź dostępność aktualizacji
|
||||
@id update.check_auto Automatycznie sprawdzaj dostępność aktualizacji
|
||||
@id update.notify Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
|
||||
@id update.prereleases Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
|
||||
@id linux.preferred_kind Instaluj nowe aplikacje jako
|
||||
@id tray.close_to_tray Po zamknięciu okna działaj w zasobniku systemowym
|
||||
@id tray.show_on_current_desktop Pokaż okno na bieżącym pulpicie
|
||||
@id tray.login_item Uruchom ArtCraft Launcher po zalogowaniu
|
||||
@id appearance.theme Wygląd
|
||||
|
||||
# Tray (menu bar) menu and tooltip
|
||||
Show ArtCraft Launcher Pokaż ArtCraft Launcher
|
||||
Check for Updates Sprawdź dostępność aktualizacji
|
||||
Visit getartcraft.com Odwiedź getartcraft.com
|
||||
ArtCraft on GitHub ArtCraft na GitHubie
|
||||
Join the ArtCraft Discord Dołącz do Discorda ArtCraft
|
||||
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 15.
|
|
|
@ -106,10 +106,6 @@
|
|||
Apps on this computer Aplicativos neste computador
|
||||
Installed apps with a newer version Aplicativos instalados com nova versão
|
||||
Apps you can install Aplicativos que você pode instalar
|
||||
Hide App Ocultar aplicativo
|
||||
Unhide App Mostrar aplicativo
|
||||
Hidden Apps Aplicativos ocultos
|
||||
Apps you hid Aplicativos que você ocultou
|
||||
Checked {ago} · using {sources} Verificado {ago} · via {sources}
|
||||
{a} and {b} {a} e {b}
|
||||
Join the Discord Entrar no Discord
|
||||
|
|
@ -158,7 +154,6 @@ deckcraft Presentations and slide shows Apresentações e slides
|
|||
gridcraft Spreadsheets and data Planilhas e dados
|
||||
cadcraft Computer-aided design and drafting Desenho técnico e projeto assistido por computador (CAD)
|
||||
soundcraft Recording, editing and mixing audio Gravação, edição e mixagem de áudio
|
||||
figcraft Interface design and prototyping Design de interfaces e prototipagem
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan jan.
|
||||
|
|
@ -249,7 +244,6 @@ 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
|
||||
|
|
@ -318,17 +312,4 @@ 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 has a wrong number of fields in line 9.
|
|
|
@ -13,7 +13,7 @@
|
|||
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
|
||||
# name "Craft App Suite",
|
||||
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord", "Website".
|
||||
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord", "Website".
|
||||
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
|
||||
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
|
||||
# {what}, {unit}, {real}, {e} must appear in translation.
|
||||
|
|
@ -116,10 +116,6 @@
|
|||
Apps on this computer Приложения на этом компьютере
|
||||
Installed apps with a newer version Установленные приложения, у которых есть новая версия
|
||||
Apps you can install Приложения, которые можно установить
|
||||
Hide App Скрыть приложение
|
||||
Unhide App Показать приложение
|
||||
Hidden Apps Скрытые приложения
|
||||
Apps you hid Приложения, которые вы скрыли
|
||||
Checked {ago} · using {sources} Проверено {ago} · через {sources}
|
||||
{a} and {b} {a} и {b}
|
||||
Join the Discord Присоединиться к Discord
|
||||
|
|
@ -168,7 +164,6 @@ deckcraft Presentations and slide shows Презентации и слайд-ш
|
|||
gridcraft Spreadsheets and data Электронные таблицы и данные
|
||||
cadcraft Computer-aided design and drafting Автоматизированное проектирование и черчение
|
||||
soundcraft Recording, editing and mixing audio Запись, редактирование и сведение звука
|
||||
figcraft Interface design and prototyping Дизайн интерфейсов и прототипирование
|
||||
|
||||
# Contextual: month abbreviations for date rendering
|
||||
month Jan янв.
|
||||
|
|
@ -258,7 +253,6 @@ 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} приложений
|
||||
|
|
@ -326,17 +320,4 @@ 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.
|
|
|
@ -4,7 +4,7 @@
|
|||
use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use super::catalog::{Catalog, unescape};
|
||||
use super::lang::{plural_czech, plural_east_slavic, plural_polish};
|
||||
use super::lang::plural_east_slavic;
|
||||
use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};
|
||||
|
||||
/// Each catalog's rows as (line number, context, source, translation), split exactly as the
|
||||
|
|
@ -124,7 +124,6 @@ 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"),
|
||||
|
|
@ -209,39 +208,6 @@ fn russian_plural_rule_works() {
|
|||
assert_eq!(trn(Lang::RU, 3, "{n} day ago", "{n} days ago"), "3 дня назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_plural_rule_works() {
|
||||
for n in [0, 5, 11, 21, 22, 25] {
|
||||
assert_eq!(plural_czech(n), 2, "{n}");
|
||||
}
|
||||
assert_eq!(plural_czech(1), 0);
|
||||
for n in [2, 4] {
|
||||
assert_eq!(plural_czech(n), 1, "{n}");
|
||||
}
|
||||
assert_eq!(trn(Lang::CS, 1, "{n} app", "{n} apps"), "1 aplikace");
|
||||
assert_eq!(trn(Lang::CS, 2, "{n} app", "{n} apps"), "2 aplikace");
|
||||
assert_eq!(trn(Lang::CS, 5, "{n} app", "{n} apps"), "5 aplikací");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_plural_rule_works() {
|
||||
// Polish says "one" only for 1 itself: 21, 31 and 101 take "many", unlike Russian.
|
||||
assert_eq!(plural_polish(1), 0);
|
||||
for n in [2, 3, 4, 22, 23, 24, 102, 122] {
|
||||
assert_eq!(plural_polish(n), 1, "{n}");
|
||||
}
|
||||
for n in [0, 5, 11, 12, 13, 14, 15, 21, 25, 31, 100, 101, 111, 112] {
|
||||
assert_eq!(plural_polish(n), 2, "{n}");
|
||||
}
|
||||
assert_eq!(trn(Lang::PL, 1, "{n} app", "{n} apps"), "1 aplikacja");
|
||||
assert_eq!(trn(Lang::PL, 3, "{n} app", "{n} apps"), "3 aplikacje");
|
||||
assert_eq!(trn(Lang::PL, 22, "{n} app", "{n} apps"), "22 aplikacje");
|
||||
assert_eq!(trn(Lang::PL, 5, "{n} app", "{n} apps"), "5 aplikacji");
|
||||
assert_eq!(trn(Lang::PL, 21, "{n} app", "{n} apps"), "21 aplikacji");
|
||||
// A key the Polish catalog doesn't carry falls back to the English forms.
|
||||
assert_eq!(trn(Lang::PL, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untranslated_plurals_fall_back_to_english_by_count() {
|
||||
// Russian's rule says "one" for 21, but English says "other".
|
||||
|
|
@ -252,10 +218,6 @@ fn untranslated_plurals_fall_back_to_english_by_count() {
|
|||
|
||||
#[test]
|
||||
fn locale_tags_resolve() {
|
||||
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS_cz"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::CS), "{tag}");
|
||||
}
|
||||
assert_eq!(super::detect::first_supported(["xx-XX", "cs-CZ", "en-US"]), Some(Lang::CS));
|
||||
assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(Lang::PT_BR));
|
||||
assert_eq!(lang_from_tag("pt-BR"), Some(Lang::PT_BR));
|
||||
assert_eq!(lang_from_tag("pt"), Some(Lang::PT_BR));
|
||||
|
|
@ -290,24 +252,6 @@ fn german_counts_dates_and_messages_work() {
|
|||
assert_eq!(tr(Lang::DE, "Untranslated diagnostic"), "Untranslated diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_locales_and_plurals_work() {
|
||||
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::HU), "{tag}");
|
||||
}
|
||||
assert_eq!(super::detect::first_supported(["xx-XX", "hu-HU", "en-US"]), Some(Lang::HU));
|
||||
// After a number the noun stays singular; 1 is still the "one" form.
|
||||
assert_eq!(trn(Lang::HU, 0, "{n} app", "{n} apps"), "0 alkalmazás");
|
||||
assert_eq!(trn(Lang::HU, 1, "{n} app", "{n} apps"), "1 alkalmazás");
|
||||
assert_eq!(trn(Lang::HU, 2, "{n} app", "{n} apps"), "2 alkalmazás");
|
||||
assert_eq!(trn(Lang::HU, 21, "{n} day ago", "{n} days ago"), "21 napja");
|
||||
assert_eq!(trn(Lang::HU, 1, "{n} day ago", "{n} days ago"), "1 napja");
|
||||
assert_eq!(super::tr_ctx(Lang::HU, "month", "Mar"), "márc.");
|
||||
assert_eq!(tr(Lang::HU, "{month} {day}, {year}"), "{year}. {month} {day}.");
|
||||
assert_eq!(message(Lang::HU, "PhotoCraft updated to 0.5.0"), "PhotoCraft frissítve. Verzió: 0.5.0");
|
||||
assert_eq!(tr(Lang::HU, "Untranslated diagnostic"), "Untranslated diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn french_locales_and_plurals_work() {
|
||||
for tag in ["fr", "fr-FR", "fr_CA.UTF-8", "fr-BE", "FR_ch"] {
|
||||
|
|
@ -335,14 +279,6 @@ fn spanish_locales_and_plurals_work() {
|
|||
assert_eq!(message(Lang::ES, "PhotoCraft updated to 0.5.0"), "PhotoCraft actualizada a 0.5.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_locales_resolve() {
|
||||
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL_pl"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::PL), "{tag}");
|
||||
}
|
||||
assert_eq!(super::detect::first_supported(["xx-XX", "pl-PL", "en-US"]), Some(Lang::PL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplified_chinese_locales_respect_the_script() {
|
||||
for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] {
|
||||
|
|
@ -366,34 +302,16 @@ fn chinese_counts_and_english_fallback_work() {
|
|||
assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_locales_and_counts_work() {
|
||||
for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(Lang::JA), "{tag}");
|
||||
}
|
||||
assert_eq!(super::detect::first_supported(["xx-XX", "ja-JP", "en-US"]), Some(Lang::JA));
|
||||
// Japanese has one plural form.
|
||||
for n in [0, 1, 2, 11, 100] {
|
||||
assert_eq!(trn(Lang::JA, n, "{n} app", "{n} apps"), format!("{n} 個のアプリ"));
|
||||
assert_eq!(trn(Lang::JA, n, "{n} day ago", "{n} days ago"), format!("{n} 日前"));
|
||||
}
|
||||
assert_eq!(trn(Lang::JA, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
|
||||
assert_eq!(tr(Lang::JA, "Untranslated diagnostic"), "Untranslated diagnostic");
|
||||
assert_eq!(super::tr_ctx(Lang::JA, "month", "Mar"), "3月");
|
||||
assert_eq!(tr(Lang::JA, "{month} {day}, {year}"), "{year}年{month}{day}日");
|
||||
assert_eq!(message(Lang::JA, "PhotoCraft updated to 0.5.0"), "PhotoCraft を 0.5.0 に更新しました");
|
||||
}
|
||||
|
||||
/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog.
|
||||
/// Machines without one (CI, the web) skip this; the UI then stays in English.
|
||||
#[test]
|
||||
fn system_font_covers_chinese_in_every_ui_family() {
|
||||
use egui::{FontFamily, FontId};
|
||||
if !crate::theme::cjk_font_available(Lang::ZH_CN) {
|
||||
if !crate::theme::cjk_font_available() {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN));
|
||||
crate::theme::install_fonts(&ctx, true);
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let text: String = Lang::ZH_CN
|
||||
|
|
@ -418,54 +336,22 @@ fn system_font_covers_chinese_in_every_ui_family() {
|
|||
}
|
||||
}
|
||||
|
||||
/// With a system CJK font installed, every UI family can draw the whole Japanese catalog.
|
||||
/// Machines without one (CI, the web) skip this.
|
||||
#[test]
|
||||
fn system_font_covers_japanese_in_every_ui_family() {
|
||||
use egui::{FontFamily, FontId};
|
||||
if !crate::theme::cjk_font_available(Lang::JA) {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, Some(Lang::JA));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let text: String = Lang::JA
|
||||
.0
|
||||
.source
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.filter(|c| ('\u{3040}'..='\u{30ff}').contains(c) || ('\u{3400}'..='\u{9fff}').contains(c))
|
||||
.collect();
|
||||
assert!(text.chars().count() > 200);
|
||||
for family in [
|
||||
FontFamily::Proportional,
|
||||
FontFamily::Monospace,
|
||||
crate::theme::medium(),
|
||||
crate::theme::semibold(),
|
||||
crate::theme::display(),
|
||||
crate::theme::mono(),
|
||||
crate::theme::mono_bold(),
|
||||
] {
|
||||
ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_labels_come_from_the_registry() {
|
||||
use crate::settings::Settings;
|
||||
for lang in Lang::all().filter(|l| !l.needs_cjk_font()) {
|
||||
assert_eq!(Settings::language_label(lang.code(), None), lang.name());
|
||||
assert_eq!(Settings::language_label(lang.code()), lang.name());
|
||||
}
|
||||
// A CJK language is named in English, and its own name is added only while its face is the one
|
||||
// in the font stack: no other face draws it.
|
||||
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese");
|
||||
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)");
|
||||
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese");
|
||||
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)");
|
||||
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto");
|
||||
assert_eq!(Settings::language_label("xx", None), "Auto");
|
||||
// Chinese: in English until its font is loaded (which a label never does), then both.
|
||||
// (Other tests may load the font meanwhile.)
|
||||
let before = crate::theme::cjk_font_loaded();
|
||||
let label = Settings::language_label(Lang::ZH_CN.code());
|
||||
let after = crate::theme::cjk_font_loaded();
|
||||
assert!(label == "Simplified Chinese (简体中文)" || label == "Simplified Chinese", "{label}");
|
||||
assert!(!before || label.contains("简体中文"), "{label}");
|
||||
assert!(after || !label.contains("简体中文"), "{label}");
|
||||
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO), "Auto");
|
||||
assert_eq!(Settings::language_label("xx"), "Auto");
|
||||
}
|
||||
|
||||
// The parser.
|
||||
|
|
|
|||
|
|
@ -174,7 +174,6 @@ 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} 天前
|
||||
|
|
@ -247,7 +246,6 @@ deckcraft Presentations and slide shows 演示文稿与幻灯片
|
|||
gridcraft Spreadsheets and data 电子表格与数据处理
|
||||
cadcraft Computer-aided design and drafting 计算机辅助设计与制图
|
||||
soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
|
||||
figcraft Interface design and prototyping 界面设计与原型制作
|
||||
|
||||
Show in Files 在文件管理器中显示
|
||||
Loading ArtCraft Launcher… 正在加载 ArtCraft Launcher…
|
||||
|
|
@ -264,10 +262,6 @@ figcraft Interface design and prototyping 界面设计与原型制作
|
|||
Apps on this computer 此电脑上的应用
|
||||
Installed apps with a newer version 有新版本的已安装应用
|
||||
Apps you can install 可安装的应用
|
||||
Hide App 隐藏应用
|
||||
Unhide App 取消隐藏应用
|
||||
Hidden Apps 已隐藏的应用
|
||||
Apps you hid 你隐藏的应用
|
||||
Cancel update 取消更新
|
||||
Stop waiting; the update can be started again later 停止等待;稍后可重新开始更新
|
||||
Download again 重新下载
|
||||
|
|
@ -289,19 +283,6 @@ figcraft Interface design and prototyping 界面设计与原型制作
|
|||
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.
|
|
|
@ -17,7 +17,6 @@ pub fn app(id: &str) -> Option<ImageSource<'static>> {
|
|||
"gridcraft" => egui::include_image!("../../../assets/app-icons/gridcraft.png"),
|
||||
"cadcraft" => egui::include_image!("../../../assets/app-icons/cadcraft.png"),
|
||||
"soundcraft" => egui::include_image!("../../../assets/app-icons/soundcraft.png"),
|
||||
"figcraft" => egui::include_image!("../../../assets/app-icons/figcraft.png"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,10 @@ macro_rules! tl {
|
|||
mod app;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cjk_font;
|
||||
mod details;
|
||||
mod dialogs;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod model;
|
||||
mod motion;
|
||||
mod notes;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
|
|
|
|||
|
|
@ -1,155 +0,0 @@
|
|||
//! Fewer animations when the OS asks for that: Windows' "Animation effects" switched off, macOS'
|
||||
//! "Reduce motion", a Linux desktop's animations setting, a browser's `prefers-reduced-motion`.
|
||||
//! The shell says what the OS wants ([`crate::Host::reduce_motion`]); this makes egui follow it.
|
||||
//! Progress bars and spinners keep moving: they say that work is going on.
|
||||
|
||||
use egui::{Event, MouseWheelUnit, RawInput, TouchPhase};
|
||||
|
||||
/// Seconds between asking the shell again: the setting can change while the launcher runs.
|
||||
pub(crate) const RECHECK_EVERY: f64 = 1.0;
|
||||
|
||||
/// With `reduce`, popups, tooltips and dialogs appear at once instead of fading in, and scrolling
|
||||
/// to a place jumps there. Without it, egui's own timings.
|
||||
pub(crate) fn apply(ctx: &egui::Context, reduce: bool) {
|
||||
let stock = egui::Style::default();
|
||||
ctx.all_styles_mut(|style| {
|
||||
style.animation_time = if reduce { 0.0 } else { stock.animation_time };
|
||||
style.scroll_animation = if reduce { egui::style::ScrollAnimation::none() } else { stock.scroll_animation };
|
||||
});
|
||||
}
|
||||
|
||||
/// The most one step of a rewritten wheel event moves, in points: under what egui takes for a
|
||||
/// trackpad's step (8), also once Shift has folded both axes into one.
|
||||
const WHEEL_STEP: f32 = 4.0;
|
||||
/// A wheel event that would take more steps than this is left as it came.
|
||||
const WHEEL_MAX_STEPS: f32 = 4096.0;
|
||||
|
||||
/// Makes a mouse wheel scroll at once. egui spreads each notch of a wheel over several frames and
|
||||
/// has no switch for that, but it applies a trackpad's small steps as they come: so every wheel
|
||||
/// event is rewritten as a run of such steps, adding up to the same distance.
|
||||
pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
|
||||
let wheel = |event: &Event| matches!(event, Event::MouseWheel { phase: TouchPhase::Move, .. });
|
||||
if !raw.events.iter().any(wheel) {
|
||||
return;
|
||||
}
|
||||
let line = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let page = raw.screen_rect.unwrap_or_else(|| ctx.viewport_rect()).height();
|
||||
let mut events = Vec::with_capacity(raw.events.len());
|
||||
for event in std::mem::take(&mut raw.events) {
|
||||
let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = &event else {
|
||||
events.push(event);
|
||||
continue;
|
||||
};
|
||||
let points = match unit {
|
||||
MouseWheelUnit::Point => *delta,
|
||||
MouseWheelUnit::Line => line * *delta,
|
||||
MouseWheelUnit::Page => page * *delta,
|
||||
};
|
||||
let steps = ((points.x.abs() + points.y.abs()) / WHEEL_STEP).ceil();
|
||||
// Nothing to move, or nonsense (NaN included): egui's to deal with.
|
||||
if !(1.0..=WHEEL_MAX_STEPS).contains(&steps) {
|
||||
events.push(event);
|
||||
continue;
|
||||
}
|
||||
let step = Event::MouseWheel { unit: MouseWheelUnit::Point, delta: points / steps, phase: TouchPhase::Move, modifiers: *modifiers };
|
||||
events.extend(std::iter::repeat_n(step, steps as usize));
|
||||
}
|
||||
raw.events = events;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Modifiers, Vec2, vec2};
|
||||
|
||||
use super::*;
|
||||
|
||||
const FRAME: f64 = 1.0 / 60.0;
|
||||
|
||||
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
|
||||
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
|
||||
}
|
||||
|
||||
/// One frame: how far egui scrolls in it.
|
||||
fn scrolled(ctx: &egui::Context, frame: u32, events: Vec<Event>, reduce: bool) -> Vec2 {
|
||||
let mut raw = RawInput { time: Some(f64::from(frame) * FRAME), events, ..Default::default() };
|
||||
if reduce {
|
||||
unsmooth_wheel(ctx, &mut raw);
|
||||
}
|
||||
let mut seen = Vec2::ZERO;
|
||||
ctx.run_ui(raw, |ui| seen = ui.input(|i| i.smooth_scroll_delta)).drop_without_applying_deltas();
|
||||
seen
|
||||
}
|
||||
|
||||
fn close(a: Vec2, b: Vec2) -> bool {
|
||||
(a - b).length() < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egui_spreads_a_wheel_notch_over_frames() {
|
||||
let ctx = egui::Context::default();
|
||||
let line = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], false);
|
||||
assert!(first.y < 0.0 && first.y > -1.5 * line, "{first:?}");
|
||||
assert!(scrolled(&ctx, 1, Vec::new(), false).y < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wheel_notch_scrolls_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let line = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], true);
|
||||
assert!(close(first, vec2(0.0, -3.0 * line)), "{first:?}");
|
||||
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_big_step_in_points_scrolls_at_once_too() {
|
||||
let ctx = egui::Context::default();
|
||||
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Point, vec2(30.0, -90.0), Modifiers::NONE)], true);
|
||||
assert!(close(first, vec2(30.0, -90.0)), "{first:?}");
|
||||
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_scrolls_sideways_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let line = ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
// Both axes at once: Shift adds them up, which must still pass for a trackpad's step.
|
||||
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(1.0, 2.0), Modifiers::SHIFT)], true);
|
||||
assert!(close(first, vec2(3.0 * line, 0.0)), "{first:?}");
|
||||
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_input_is_left_alone() {
|
||||
let ctx = egui::Context::default();
|
||||
let events = vec![
|
||||
Event::PointerMoved(egui::pos2(10.0, 10.0)),
|
||||
wheel(MouseWheelUnit::Line, Vec2::ZERO, Modifiers::NONE),
|
||||
wheel(MouseWheelUnit::Line, vec2(0.0, f32::NAN), Modifiers::NONE),
|
||||
wheel(MouseWheelUnit::Page, vec2(f32::INFINITY, 0.0), Modifiers::NONE),
|
||||
wheel(MouseWheelUnit::Point, vec2(0.0, 1.0e9), Modifiers::NONE),
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 40.0), phase: TouchPhase::Start, modifiers: Modifiers::NONE },
|
||||
];
|
||||
let mut raw = RawInput { events: events.clone(), ..Default::default() };
|
||||
unsmooth_wheel(&ctx, &mut raw);
|
||||
// NaN is not equal to itself: compare what the events look like.
|
||||
assert_eq!(format!("{:?}", raw.events), format!("{events:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_styles_follow_the_setting() {
|
||||
let ctx = egui::Context::default();
|
||||
let stock = egui::Style::default();
|
||||
apply(&ctx, true);
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
assert_eq!(ctx.style_of(theme).animation_time, 0.0);
|
||||
assert_eq!(ctx.style_of(theme).scroll_animation, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
apply(&ctx, false);
|
||||
for theme in [egui::Theme::Dark, egui::Theme::Light] {
|
||||
assert_eq!(ctx.style_of(theme).animation_time, stock.animation_time);
|
||||
assert_eq!(ctx.style_of(theme).scroll_animation, stock.scroll_animation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,6 @@ pub enum Filter {
|
|||
Installed,
|
||||
Updates,
|
||||
NotInstalled,
|
||||
/// Apps the user chose to hide from the other lists.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -90,7 +88,7 @@ pub struct Settings {
|
|||
pub sort_descending: bool,
|
||||
pub theme: ThemeChoice,
|
||||
/// Interface language: `auto` (follow the system) or a language code from the registry
|
||||
/// (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `pl`).
|
||||
/// (`en`, `ru`, `zh-CN`, `de`).
|
||||
pub language: String,
|
||||
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
|
||||
pub close_to_tray: bool,
|
||||
|
|
@ -112,8 +110,6 @@ pub struct Settings {
|
|||
pub notified: BTreeMap<String, String>,
|
||||
/// Unix seconds of the last finished check.
|
||||
pub last_check: Option<i64>,
|
||||
/// Catalog ids of apps hidden from the main lists (`photocraft`, …).
|
||||
pub hidden: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
|
|
@ -121,13 +117,13 @@ impl Settings {
|
|||
pub const LANGUAGE_AUTO: &'static str = "auto";
|
||||
|
||||
/// Display name for a language `code`: a language's name in itself, from the registry
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English,
|
||||
/// with its own name added only while `installed` — the CJK face in the current font stack — is
|
||||
/// that language (no other face draws it); "Auto" for `auto` and unknown codes, like
|
||||
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing the system's Chinese font is
|
||||
/// named in English, with its own name added only once that font is loaded (labels never load
|
||||
/// it: choosing the language does); "Auto" for `auto` and unknown codes, like
|
||||
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
|
||||
pub fn language_label(code: &str, installed: Option<crate::i18n::Lang>) -> String {
|
||||
pub fn language_label(code: &str) -> String {
|
||||
match crate::i18n::Lang::from_code(code) {
|
||||
Some(lang) if lang.needs_cjk_font() && installed == Some(lang) => format!("{} ({})", lang.english_name(), lang.name()),
|
||||
Some(lang) if lang.needs_cjk_font() && crate::theme::cjk_font_loaded() => format!("{} ({})", lang.english_name(), lang.name()),
|
||||
Some(lang) if lang.needs_cjk_font() => lang.english_name().to_string(),
|
||||
Some(lang) => lang.name().to_string(),
|
||||
None => t("Auto").to_string(),
|
||||
|
|
@ -185,7 +181,6 @@ impl Default for Settings {
|
|||
dismissed_notices: std::collections::BTreeSet::new(),
|
||||
notified: BTreeMap::new(),
|
||||
last_check: None,
|
||||
hidden: std::collections::BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ use egui::epaint::text::VariationCoords;
|
|||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::i18n::Lang;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ThemeChoice {
|
||||
/// Follow the system's light or dark setting.
|
||||
|
|
@ -145,27 +143,6 @@ 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;
|
||||
|
|
@ -268,9 +245,7 @@ pub fn mono_bold() -> FontFamily {
|
|||
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
|
||||
|
||||
/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
|
||||
/// `cjk`: the CJK language to draw, whose system font is added behind everything else, or `None`
|
||||
/// for a language that needs no CJK font.
|
||||
pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
||||
pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
|
||||
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
|
||||
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
|
||||
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
|
||||
|
|
@ -287,19 +262,14 @@ pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
|||
("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
|
||||
];
|
||||
for (name, bytes, coords, family) in faces {
|
||||
// No subpixel binning: these fonts carry no hinting instructions, so a glyph drawn at a
|
||||
// fractional x smears each stem across two pixels, leaving small text blurry at 100 %.
|
||||
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), subpixel_binning: Some(false), ..Default::default() };
|
||||
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
|
||||
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
|
||||
let mut stack = vec![name.to_owned()];
|
||||
stack.extend(fallback.iter().cloned());
|
||||
fonts.families.insert(family, stack);
|
||||
}
|
||||
// The CJK face, behind everything else, from the system: only when asked for, as the font is
|
||||
// large.
|
||||
if let Some(lang) = cjk
|
||||
&& let Some(font) = cjk_font(lang)
|
||||
{
|
||||
// Chinese, behind everything else, from the system: only when asked for, as the font is large.
|
||||
if cjk && let Some(font) = cjk_font() {
|
||||
fonts.font_data.insert("CJK".to_owned(), font);
|
||||
for stack in fonts.families.values_mut() {
|
||||
stack.push("CJK".to_owned());
|
||||
|
|
@ -308,47 +278,36 @@ pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
|
|||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// The system's font for a CJK language, read once per language (tens of MB). Desktop only: the
|
||||
/// web build has none, so CJK text can't be drawn there.
|
||||
pub fn cjk_font(lang: Lang) -> Option<Arc<FontData>> {
|
||||
/// The system's Simplified Chinese font, read once on the first call (tens of MB). Desktop only:
|
||||
/// the web build has none, so Chinese can't be drawn there.
|
||||
pub fn cjk_font() -> Option<Arc<FontData>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(lang.code()).or_insert_with(|| load_cjk(lang)).clone()
|
||||
CJK_FONT.get_or_init(|| crate::cjk_font::load().map(Arc::new)).clone()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lang;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the system font for the CJK language `lang`: the face for its script, or `None` where the
|
||||
/// system has none. Nothing falls back to another script's face — a face for a different script
|
||||
/// leaves its characters as tofu — so a language without its font stays in English, like any other.
|
||||
/// The system's Chinese font once [`cjk_font`] has looked for it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cjk(lang: Lang) -> Option<Arc<FontData>> {
|
||||
crate::cjk_font::load(lang.script()?).map(Arc::new)
|
||||
}
|
||||
static CJK_FONT: std::sync::OnceLock<Option<Arc<FontData>>> = std::sync::OnceLock::new();
|
||||
|
||||
/// The system fonts looked for so far, by language code.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static CJK_FONTS: std::sync::Mutex<std::collections::BTreeMap<&'static str, Option<Arc<FontData>>>> =
|
||||
std::sync::Mutex::new(std::collections::BTreeMap::new());
|
||||
|
||||
/// Has the system font for `lang` already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded(lang: Lang) -> bool {
|
||||
/// Has the system's Chinese font already been loaded? Never loads it.
|
||||
pub fn cjk_font_loaded() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(lang.code()).is_some_and(Option::is_some)
|
||||
CJK_FONT.get().is_some_and(Option::is_some)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = lang;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Can the CJK language `lang` be drawn? Loads the system font on the first call ([`cjk_font`]).
|
||||
pub fn cjk_font_available(lang: Lang) -> bool {
|
||||
cjk_font(lang).is_some()
|
||||
/// Can Chinese be drawn? Loads the system font on the first call ([`cjk_font`]).
|
||||
pub fn cjk_font_available() -> bool {
|
||||
cjk_font().is_some()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! 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;
|
||||
|
|
@ -127,9 +126,6 @@ impl LauncherUi {
|
|||
}
|
||||
|
||||
pub(crate) fn toolbar(&mut self, ui: &mut Ui) {
|
||||
if self.settings.filter == Filter::Hidden && self.settings.hidden.is_empty() {
|
||||
self.settings.filter = Filter::All;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if self.dialog.is_none()
|
||||
&& let Some(r) = self.launcher_update.clone()
|
||||
|
|
@ -199,19 +195,17 @@ impl LauncherUi {
|
|||
self.announcements(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let updates = self.count(Filter::Updates);
|
||||
let mut filters = vec![
|
||||
let filters: Vec<(Filter, Cell<'_>)> = [
|
||||
(Filter::All, tl!("All"), tl!("Every Craft app"), None),
|
||||
// The tab names a group ("installed ones"); the status pill says "Installed".
|
||||
(Filter::Installed, tr_ctx(current(), "filter", "Installed"), tl!("Apps on this computer"), None),
|
||||
(Filter::Updates, tl!("Updates"), tl!("Installed apps with a newer version"), Some(t.warn)),
|
||||
(Filter::NotInstalled, tl!("Not installed"), tl!("Apps you can install"), None),
|
||||
];
|
||||
filters.retain(|(f, ..)| self.caps.manage_apps || *f == Filter::All);
|
||||
if !self.settings.hidden.is_empty() {
|
||||
filters.push((Filter::Hidden, tl!("Hidden Apps"), tl!("Apps you hid"), None));
|
||||
}
|
||||
let filters: Vec<(Filter, Cell<'_>)> =
|
||||
filters.into_iter().map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) })).collect();
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(f, ..)| self.caps.manage_apps || *f == Filter::All)
|
||||
.map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) }))
|
||||
.collect();
|
||||
widgets::cells(ui, &mut self.settings.filter, &filters);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if self.caps.manage_apps && updates > 0 {
|
||||
|
|
@ -221,8 +215,12 @@ impl LauncherUi {
|
|||
fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())])
|
||||
};
|
||||
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
|
||||
let ids: Vec<&str> =
|
||||
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
|
||||
let ids: Vec<&str> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status(self.platform, true) == AppStatus::UpdateAvailable && e.job.is_none())
|
||||
.map(|e| e.app.id)
|
||||
.collect();
|
||||
for id in ids {
|
||||
self.send(Command::Install { app: id.to_string() });
|
||||
}
|
||||
|
|
@ -395,27 +393,13 @@ impl LauncherUi {
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let app = entry.app;
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
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 lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, 0.3);
|
||||
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));
|
||||
// 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, inside the
|
||||
// hairlines the card shares with its neighbours (the site's rule sits within its grid gaps).
|
||||
painter.rect(rect, CornerRadius::ZERO, t.bg, 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 {
|
||||
let inside = rect.shrink(1.0);
|
||||
painter.rect_filled(Rect::from_min_size(inside.min, vec2(inside.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
|
||||
painter.rect_filled(Rect::from_min_size(rect.min, vec2(rect.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
|
||||
}
|
||||
|
||||
// The top strip: the app's number in its colour, and its pages.
|
||||
|
|
@ -574,20 +558,6 @@ 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)));
|
||||
}
|
||||
|
|
@ -678,13 +648,7 @@ impl LauncherUi {
|
|||
}
|
||||
|
||||
/// The main button (or progress) and the … menu, within `width`.
|
||||
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) {
|
||||
fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = entry.app.id.to_string();
|
||||
// One square button (the menu) beside the main one.
|
||||
|
|
@ -767,7 +731,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, links_in_menu);
|
||||
self.menu(ui, entry, status);
|
||||
return;
|
||||
}
|
||||
match self.primary_action(entry, status) {
|
||||
|
|
@ -805,10 +769,10 @@ impl LauncherUi {
|
|||
ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, main_w));
|
||||
}
|
||||
}
|
||||
self.menu(ui, entry, status, links_in_menu);
|
||||
self.menu(ui, entry, status);
|
||||
}
|
||||
|
||||
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) {
|
||||
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus) {
|
||||
let id = entry.app.id.to_string();
|
||||
let manage = self.caps.manage_apps;
|
||||
let reveal = self.caps.reveal_label;
|
||||
|
|
@ -864,32 +828,16 @@ impl LauncherUi {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 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() {
|
||||
if hidden {
|
||||
self.settings.hidden.remove(entry.app.id);
|
||||
if self.settings.hidden.is_empty() && self.settings.filter == Filter::Hidden {
|
||||
self.settings.filter = Filter::All;
|
||||
}
|
||||
} else {
|
||||
self.settings.hidden.insert(id);
|
||||
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();
|
||||
}
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -906,7 +854,7 @@ fn sort_label(k: SortKey) -> &'static str {
|
|||
}
|
||||
|
||||
/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
|
||||
pub(crate) fn version_line(e: &AppEntry, status: &AppStatus) -> String {
|
||||
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,17 +70,6 @@ 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);
|
||||
|
|
@ -311,12 +300,6 @@ pub enum Kind {
|
|||
Danger,
|
||||
}
|
||||
|
||||
/// The natural width of a button, including its horizontal padding.
|
||||
pub fn button_width(ui: &Ui, text: &str, minimum: f32) -> f32 {
|
||||
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::BUTTON_SPACING, Tokens::get(ui.ctx()).ink);
|
||||
(galley.size().x + 32.0).max(minimum)
|
||||
}
|
||||
|
||||
/// A square button with a mono upper-case label, at least `width` wide.
|
||||
pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
|
|||
|
|
@ -155,13 +155,13 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
|
|||
h.get_by_value("English").click();
|
||||
h.run_steps(2);
|
||||
// Opening Settings doesn't load the (large) Chinese font: the picker names it in English.
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN));
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded());
|
||||
h.get_by_label("Simplified Chinese").click();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN) {
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available() {
|
||||
// No Chinese font: choosing it keeps the UI in English, and Settings says why.
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-CN");
|
||||
h.get_by_label_contains("No font for this language was found");
|
||||
h.get_by_label_contains("No Chinese font was found");
|
||||
h.get_by_label("Notify me when an installed app has an update");
|
||||
snapshot(&mut h, "settings-zh-CN-no-font");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
//! The Hungarian UI, in its own test binary: the UI language is process-wide, so it can't share one
|
||||
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
|
||||
//! `-- --test-threads=1`, see `ui.rs`).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
clock: Rc<Cell<i64>>,
|
||||
os: Os,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, _command: Command) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
|
||||
}
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
Some(Platform { os: self.os, arch: Arch::Arm64 })
|
||||
}
|
||||
fn now(&self) -> i64 {
|
||||
self.clock.get()
|
||||
}
|
||||
}
|
||||
|
||||
struct DesktopHost;
|
||||
impl Host for DesktopHost {
|
||||
fn has_tray(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn login_item(&self) -> Option<bool> {
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
tag: format!("v{v}"),
|
||||
version: version::parse(v),
|
||||
name: format!("v{v}"),
|
||||
published_at: Some(at),
|
||||
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
|
||||
notes: String::new(),
|
||||
prerelease: false,
|
||||
assets: vec![Asset {
|
||||
name: name.clone(),
|
||||
size: 150 << 20,
|
||||
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
|
||||
sha256: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn events() -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
for (i, app) in SUITE.iter().enumerate() {
|
||||
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
|
||||
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
|
||||
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
|
||||
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
|
||||
source: InstallSource::MacApp,
|
||||
location: format!("/Applications/{}.app", app.name),
|
||||
package: None,
|
||||
});
|
||||
ev.push(Event::Detected { app: app.id.into(), installed });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
|
||||
// A backend message, translated through its `@msg` template where it's shown.
|
||||
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
|
||||
ev
|
||||
}
|
||||
|
||||
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
|
||||
harness_on(view, Os::MacOs)
|
||||
}
|
||||
|
||||
fn harness_on(view: ViewMode, os: Os) -> Harness<'static, Option<LauncherUi>> {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)), os });
|
||||
let settings =
|
||||
Settings { view, notify: false, language: "hu".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
|
||||
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut DesktopHost);
|
||||
l.ui(ui, &mut DesktopHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn hungarian_ui_translates_labels_dates_and_messages() {
|
||||
let mut h = harness(ViewMode::Grid);
|
||||
h.run_steps(8);
|
||||
snapshot(&mut h, "grid-hu-dark");
|
||||
// Product names stay in English.
|
||||
h.get_by_label("Launcher");
|
||||
h.get_by_label("Craft App Suite");
|
||||
h.get_by_label("Képszerkesztés");
|
||||
h.get_by_label("Frissítés: 0.5.0");
|
||||
h.get_by_label_contains("Ellenőrzőösszeg ellenőrzése…");
|
||||
h.get_by_label_contains("Ellenőrizve: 3 perce · ArtCraft API");
|
||||
h.get_by_label("LightCraft frissítve. Verzió: 0.5.0");
|
||||
|
||||
let mut table = harness(ViewMode::Table);
|
||||
table.run_steps(8);
|
||||
snapshot(&mut table, "table-hu-dark");
|
||||
table.get_by_label("Telepített");
|
||||
assert!(table.query_all_by_label_contains("2026. aug.").count() >= 1);
|
||||
check_settings_layout();
|
||||
}
|
||||
|
||||
/// Checks the actual modal after resizing it while open, including Linux's extra controls.
|
||||
fn check_settings_layout() {
|
||||
let mut h = harness_on(ViewMode::Grid, Os::Linux);
|
||||
h.run_steps(8);
|
||||
h.get_by_label("Beállítások").click();
|
||||
h.run_steps(8);
|
||||
for (width, height) in [(1180.0, 900.0), (720.0, 500.0), (480.0, 500.0), (1180.0, 900.0)] {
|
||||
h.set_size(egui::vec2(width, height));
|
||||
h.run_steps(8);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
|
||||
let done = h.get_by_label("Kész").rect();
|
||||
let quit = h.get_by_label("Kilépés az ArtCraft Launcherből").rect();
|
||||
assert!(screen.contains_rect(done), "{width}x{height}: Done is clipped: {done:?}");
|
||||
assert!(screen.contains_rect(quit), "{width}x{height}: Quit is clipped: {quit:?}");
|
||||
assert!(!done.intersects(quit));
|
||||
let title = h.get_all_by_label("Beállítások").last().unwrap().rect();
|
||||
assert!(screen.contains_rect(title), "{width}x{height}: title is clipped: {title:?}");
|
||||
h.get_by_label("Rendszerbeállítás szerint").scroll_to_me();
|
||||
h.run_steps(8);
|
||||
// The footer has the same services, and the cards behind the modal have Webhely and GitHub
|
||||
// links as one 14px line of text; keep only the settings body's buttons (34px) and icons (24px).
|
||||
let discord = h.get_by_label("Csatlakozás a Discordhoz").rect();
|
||||
let theme = h.get_by_label("Rendszerbeállítás szerint").rect();
|
||||
assert!(screen.contains_rect(discord), "community links must be reachable by scrolling");
|
||||
assert!(theme.bottom() < done.top(), "appearance controls must scroll above the fixed actions");
|
||||
let mut links = vec![discord];
|
||||
for label in ["Webhely", "GitHub", "X", "Instagram", "LinkedIn", "YouTube", "TikTok"] {
|
||||
let candidates: Vec<_> = h
|
||||
.query_all_by_label(label)
|
||||
.map(|n| n.rect())
|
||||
.filter(|r| r.top() >= discord.top() - 1.0 && r.bottom() < theme.top() && r.height() > 20.0)
|
||||
.collect();
|
||||
assert_eq!(candidates.len(), 1, "{width}x{height}: settings link {label}: {candidates:?}");
|
||||
links.extend(candidates);
|
||||
}
|
||||
for (i, r) in links.iter().enumerate() {
|
||||
assert!(screen.contains_rect(*r), "{width}x{height}: link is clipped: {r:?}");
|
||||
for other in links.iter().skip(i + 1) {
|
||||
assert!(!r.intersects(*other), "{width}x{height}: links overlap: {r:?}, {other:?}");
|
||||
}
|
||||
}
|
||||
snapshot(&mut h, &format!("settings-hu-{width}x{height}-bottom"));
|
||||
h.get_by_label("Nyelv").scroll_to_me();
|
||||
h.run_steps(8);
|
||||
let language = h.get_by_label("Nyelv").rect();
|
||||
assert!(screen.contains_rect(language), "the top of settings must remain reachable");
|
||||
snapshot(&mut h, &format!("settings-hu-{width}x{height}-top"));
|
||||
}
|
||||
h.get_by_label("Kész").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Kész").is_none(), "Done must still close the resized dialog");
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
//! The Japanese UI, in its own test binary: the UI language is process-wide, so it can't share one
|
||||
//! with the English UI tests. Japanese is drawn with the system's Japanese font; where there is
|
||||
//! none (CI, minimal Linux installs), the UI stays in English and says why.
|
||||
//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
sent: Rc<RefCell<Vec<Command>>>,
|
||||
manage: bool,
|
||||
clock: Rc<Cell<i64>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, command: Command) {
|
||||
self.sent.borrow_mut().push(command);
|
||||
}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
|
||||
}
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||||
}
|
||||
fn now(&self) -> i64 {
|
||||
self.clock.get()
|
||||
}
|
||||
}
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
tag: format!("v{v}"),
|
||||
version: version::parse(v),
|
||||
name: format!("v{v}"),
|
||||
published_at: Some(at),
|
||||
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
|
||||
notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(),
|
||||
prerelease: false,
|
||||
assets: vec![Asset {
|
||||
name: name.clone(),
|
||||
size: 150 << 20,
|
||||
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
|
||||
sha256: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release.
|
||||
fn events() -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
let versions = [
|
||||
("photocraft", "0.5.0"),
|
||||
("vectorcraft", "0.7.0"),
|
||||
("filmcraft", "0.4.0"),
|
||||
("lightcraft", "0.4.0"),
|
||||
("pdfcraft", "0.4.0"),
|
||||
("effectcraft", "0.6.0"),
|
||||
("designcraft", "0.4.0"),
|
||||
("wordcraft", "0.3.0"),
|
||||
("deckcraft", "0.3.0"),
|
||||
("gridcraft", "0.3.0"),
|
||||
("cadcraft", "0.3.0"),
|
||||
];
|
||||
for (i, (id, v)) in versions.iter().enumerate() {
|
||||
ev.push(Event::Latest {
|
||||
app: (*id).into(),
|
||||
latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),
|
||||
from: Origin::ArtCraftApi,
|
||||
});
|
||||
}
|
||||
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
|
||||
let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];
|
||||
for app in &SUITE {
|
||||
let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {
|
||||
version: version::parse(v),
|
||||
source: InstallSource::MacApp,
|
||||
location: format!("/Applications/{}.app", app.name),
|
||||
package: None,
|
||||
});
|
||||
ev.push(Event::Detected { app: app.id.into(), installed: inst });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } });
|
||||
ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
|
||||
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
|
||||
ev
|
||||
}
|
||||
|
||||
fn harness_language(
|
||||
view: ViewMode,
|
||||
theme: egui::Theme,
|
||||
manage: bool,
|
||||
language: &str,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
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: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
|
||||
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
|
||||
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
let mut s = settings.clone();
|
||||
s.theme = if theme == egui::Theme::Dark {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Dark
|
||||
} else {
|
||||
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
|
||||
};
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
(harness, sent)
|
||||
}
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn japanese_switches_persists_and_keeps_install_actions() {
|
||||
use artcraft_launcher_ui_egui::i18n::Lang;
|
||||
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
|
||||
h.run_steps(6);
|
||||
h.get_by_label("Settings").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_value("English").click();
|
||||
h.run_steps(2);
|
||||
// Opening Settings doesn't load the (large) Japanese font: the picker names it in English.
|
||||
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::JA));
|
||||
// The list is long enough to scroll, so the last option may be out of view: click it through
|
||||
// accesskit, which reaches widgets that aren't visible.
|
||||
h.get_by_label("Japanese").click_accesskit();
|
||||
h.run_steps(3);
|
||||
if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::JA) {
|
||||
// No font for Japanese: choosing it keeps the UI in English, and Settings says why.
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.language, "ja");
|
||||
h.get_by_label_contains("No font for this language was found");
|
||||
h.get_by_label("Notify me when an installed app has an update");
|
||||
snapshot(&mut h, "settings-ja-no-font");
|
||||
return;
|
||||
}
|
||||
// Choosing it loaded the font: now the picker shows both names.
|
||||
h.get_by_value("Japanese (日本語)");
|
||||
h.get_by_label("インストール済みアプリに更新があるときに通知する");
|
||||
snapshot(&mut h, "settings-ja-dark");
|
||||
let settings = h.state().as_ref().unwrap().settings.clone();
|
||||
assert_eq!(settings.language, "ja");
|
||||
let saved = serde_json::to_string(&settings).unwrap();
|
||||
let restored: Settings = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(restored.language, "ja");
|
||||
h.get_by_label("完了").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("画像編集");
|
||||
h.get_by_label_contains("最終確認: 3 分前 · 取得元: ArtCraft API");
|
||||
h.get_by_label("0.5.0 に更新");
|
||||
// Product names stay in English.
|
||||
h.get_by_label("Launcher");
|
||||
snapshot(&mut h, "grid-ja-dark");
|
||||
h.get_by_label("すべて更新(2)").click();
|
||||
h.run_steps(2);
|
||||
let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();
|
||||
assert_eq!(installs, ["photocraft", "effectcraft"]);
|
||||
|
||||
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
|
||||
table.run_steps(6);
|
||||
table.get_by_label("インストール済みバージョン");
|
||||
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
|
||||
snapshot(&mut table, "table-ja-light");
|
||||
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
|
||||
web.run_steps(6);
|
||||
assert!(web.query_all_by_label("macOS 版をダウンロード").count() >= 3);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
//! The Polish UI, in its own test binary: the UI language is process-wide, so it can't share one
|
||||
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
|
||||
//! `-- --test-threads=1`, see `ui.rs`).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
|
||||
use artcraft_launcher_core::release::{Asset, Release};
|
||||
use artcraft_launcher_core::status::InstallSource;
|
||||
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
|
||||
use artcraft_launcher_ui_egui::settings::ViewMode;
|
||||
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
const NOW: i64 = 1_791_500_000;
|
||||
|
||||
struct Fake {
|
||||
events: Vec<Event>,
|
||||
clock: Rc<Cell<i64>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn send(&mut self, _command: Command) {}
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
|
||||
}
|
||||
fn platform(&self) -> Option<Platform> {
|
||||
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
|
||||
}
|
||||
fn now(&self) -> i64 {
|
||||
self.clock.get()
|
||||
}
|
||||
}
|
||||
|
||||
struct NoHost;
|
||||
impl Host for NoHost {}
|
||||
|
||||
fn release(id: &str, v: &str, at: i64) -> Release {
|
||||
let name = format!("{id}-{v}-macos-universal.dmg");
|
||||
Release {
|
||||
tag: format!("v{v}"),
|
||||
version: version::parse(v),
|
||||
name: format!("v{v}"),
|
||||
published_at: Some(at),
|
||||
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
|
||||
notes: String::new(),
|
||||
prerelease: false,
|
||||
assets: vec![Asset {
|
||||
name: name.clone(),
|
||||
size: 150 << 20,
|
||||
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
|
||||
sha256: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn events() -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
for (i, app) in SUITE.iter().enumerate() {
|
||||
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
|
||||
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
|
||||
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
|
||||
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
|
||||
source: InstallSource::MacApp,
|
||||
location: format!("/Applications/{}.app", app.name),
|
||||
package: None,
|
||||
});
|
||||
ev.push(Event::Detected { app: app.id.into(), installed });
|
||||
}
|
||||
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
|
||||
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
|
||||
// A backend message, translated through its `@msg` template where it's shown.
|
||||
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
|
||||
ev
|
||||
}
|
||||
|
||||
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
|
||||
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
|
||||
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let settings =
|
||||
Settings { view, notify: false, language: "pl".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
|
||||
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut NoHost);
|
||||
l.ui(ui, &mut NoHost);
|
||||
}
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
|
||||
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
|
||||
let img = h.render().unwrap();
|
||||
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
|
||||
// One test: the UI language is process-wide.
|
||||
#[test]
|
||||
fn polish_ui_translates_labels_dates_and_messages() {
|
||||
let mut h = harness(ViewMode::Grid);
|
||||
h.run_steps(8);
|
||||
snapshot(&mut h, "grid-pl-dark");
|
||||
// Product names stay in English.
|
||||
h.get_by_label("Launcher");
|
||||
h.get_by_label("Craft App Suite");
|
||||
h.get_by_label("Edycja zdjęć");
|
||||
h.get_by_label("Zaktualizuj do 0.5.0");
|
||||
h.get_by_label_contains("Weryfikowanie sumy kontrolnej…");
|
||||
h.get_by_label_contains("Ostatnio sprawdzono 3 min temu · przy użyciu ArtCraft API");
|
||||
h.get_by_label("Zaktualizowano LightCraft do wersji 0.5.0");
|
||||
|
||||
let mut table = harness(ViewMode::Table);
|
||||
table.run_steps(8);
|
||||
snapshot(&mut table, "table-pl-dark");
|
||||
table.get_by_label("Zainstalowana");
|
||||
assert!(table.query_all_by_label_contains("sie 2026").count() >= 1);
|
||||
}
|
||||
|
|
@ -5,9 +5,7 @@
|
|||
|
||||
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,
|
||||
};
|
||||
|
|
@ -21,66 +19,19 @@ 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> {
|
||||
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
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
|
||||
|
|
@ -172,19 +123,7 @@ fn harness_with(
|
|||
manage: bool,
|
||||
scale: f32,
|
||||
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
|
||||
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)
|
||||
harness_with_title_bar(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native)
|
||||
}
|
||||
|
||||
fn harness_with_title_bar(
|
||||
|
|
@ -194,18 +133,6 @@ 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 {
|
||||
|
|
@ -218,7 +145,7 @@ fn harness_full(
|
|||
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, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events, sent: sent2, manage, 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.
|
||||
|
|
@ -254,8 +181,7 @@ 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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) };
|
||||
let backend = Fake { events: events(), sent: sent.clone(), manage: true, 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.
|
||||
|
|
@ -464,35 +390,6 @@ fn table_view_and_light_theme() {
|
|||
assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_an_app_removes_it_until_it_is_unhidden() {
|
||||
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
// PhotoCraft has an update, so hiding it also drops the Updates count.
|
||||
h.get_by_label_contains("Updates 2");
|
||||
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
|
||||
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Hide App").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("PhotoCraft").is_none());
|
||||
assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft"));
|
||||
h.get_by_label_contains("Updates 1");
|
||||
h.get_by_label_contains("Hidden Apps").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("PhotoCraft");
|
||||
assert!(h.query_by_label("WordCraft").is_none());
|
||||
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
|
||||
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Unhide App").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("PhotoCraft");
|
||||
assert!(h.query_by_label_contains("Hidden Apps").is_none());
|
||||
assert!(h.state().as_ref().unwrap().settings.hidden.is_empty());
|
||||
assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_views_and_filters() {
|
||||
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
|
||||
|
|
@ -631,8 +528,8 @@ fn links_to_the_website_github_and_each_apps_pages() {
|
|||
// Cards: every app links to its repo and to its own page on getartcraft.com (plus the footer's).
|
||||
let shown_cards = h.query_all_by_label("GitHub").count() - 1;
|
||||
let shown_sites = h.query_all_by_label("Website").count() - 1;
|
||||
assert!(shown_cards >= 13, "{shown_cards}");
|
||||
assert_eq!(shown_sites, 13, "every app has a website page");
|
||||
assert!(shown_cards >= 12, "{shown_cards}");
|
||||
assert_eq!(shown_sites, 12, "every app has a website page");
|
||||
// The table's … menu has the app's website and GitHub repo.
|
||||
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
|
||||
h.run_steps(6);
|
||||
|
|
@ -697,8 +594,7 @@ 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, pictures: Rc::default(), asked: Vec::new(), clock: clock.clone() });
|
||||
let mut backend = Some(Fake { events: events(), sent: sent.clone(), manage: true, 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()
|
||||
|
|
@ -761,10 +657,8 @@ 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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) },
|
||||
later: shared_events.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 h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
|
||||
move |ui, state: &mut Option<LauncherUi>| {
|
||||
if state.is_none()
|
||||
|
|
@ -843,7 +737,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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, 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()
|
||||
|
|
@ -890,8 +784,7 @@ 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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, 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()
|
||||
|
|
@ -919,8 +812,7 @@ 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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, 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()
|
||||
|
|
@ -947,7 +839,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, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
|
||||
let mut backend = Some(Fake { events: evs, sent, manage: true, 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()
|
||||
|
|
@ -990,14 +882,7 @@ 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,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, 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()
|
||||
|
|
@ -1022,272 +907,12 @@ 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 thirteen 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,
|
||||
pictures: Rc::default(),
|
||||
asked: Vec::new(),
|
||||
clock: Rc::new(Cell::new(NOW)),
|
||||
});
|
||||
let mut backend = Some(Fake { events: evs, sent: Rc::new(RefCell::new(Vec::new())), manage: true, 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()
|
||||
|
|
@ -1364,135 +989,3 @@ fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shell on an OS whose animation setting the test switches.
|
||||
struct OsMotion(Rc<Cell<bool>>);
|
||||
|
||||
impl Host for OsMotion {
|
||||
fn reduce_motion(&self) -> bool {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid in a window too short for it, at 60 frames a second, on an OS asking for fewer
|
||||
/// animations (`reduce`) or not. Nothing is downloading, so the window is still unless something
|
||||
/// animates.
|
||||
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, 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>| {
|
||||
if state.is_none()
|
||||
&& let Some(b) = backend.take()
|
||||
{
|
||||
let settings = Settings { view: ViewMode::Grid, notify: false, ..Settings::default() };
|
||||
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings));
|
||||
}
|
||||
if let Some(l) = state.as_mut() {
|
||||
l.logic(&ui.ctx().clone(), &mut host);
|
||||
l.ui(ui, &mut host);
|
||||
}
|
||||
},
|
||||
None,
|
||||
);
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// How far down the window the grid's first card is.
|
||||
fn grid_top(h: &Harness<'_, Option<LauncherUi>>) -> f32 {
|
||||
h.get_by_label("Image editing").rect().top()
|
||||
}
|
||||
|
||||
/// Turns the mouse wheel `lines` down over the grid, by way of the shell's `raw_input_hook`. The
|
||||
/// next frame takes it in.
|
||||
fn turn_wheel(h: &mut Harness<'_, Option<LauncherUi>>, lines: f32) {
|
||||
h.hover_at(egui::pos2(590.0, 300.0));
|
||||
h.run_steps(2);
|
||||
let wheel = egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -lines),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
};
|
||||
let mut raw = egui::RawInput { events: vec![wheel], ..Default::default() };
|
||||
if let Some(l) = h.state() {
|
||||
l.raw_input(&h.ctx, &mut raw);
|
||||
}
|
||||
for event in raw.events {
|
||||
h.event(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_os_animation_setting_is_followed_as_it_changes() {
|
||||
let reduce = Rc::new(Cell::new(true));
|
||||
let mut h = short_window(reduce.clone());
|
||||
assert_eq!(h.ctx.global_style().animation_time, 0.0);
|
||||
// Switched back on while the launcher runs: noticed within a couple of seconds.
|
||||
reduce.set(false);
|
||||
h.run_steps(150);
|
||||
assert_eq!(h.ctx.global_style().animation_time, egui::Style::default().animation_time);
|
||||
reduce.set(true);
|
||||
h.run_steps(150);
|
||||
assert_eq!(h.ctx.global_style().animation_time, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_off_the_wheel_scrolls_at_once() {
|
||||
let mut h = short_window(Rc::new(Cell::new(true)));
|
||||
let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let start = grid_top(&h);
|
||||
turn_wheel(&mut h, 3.0);
|
||||
// The frame the wheel turned in, and the one that draws where it scrolled to.
|
||||
h.run_steps(2);
|
||||
let moved = start - grid_top(&h);
|
||||
assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}");
|
||||
// Nothing is left to glide.
|
||||
h.run_steps(30);
|
||||
assert_eq!(start - grid_top(&h), moved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_on_the_wheel_glides() {
|
||||
let mut h = short_window(Rc::new(Cell::new(false)));
|
||||
let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed);
|
||||
let start = grid_top(&h);
|
||||
turn_wheel(&mut h, 3.0);
|
||||
h.run_steps(2);
|
||||
let moved = start - grid_top(&h);
|
||||
assert!(moved > 0.0 && moved < notch / 2.0, "moved {moved} of {notch}");
|
||||
// And is there within the second.
|
||||
h.run_steps(60);
|
||||
let moved = start - grid_top(&h);
|
||||
assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}");
|
||||
}
|
||||
|
||||
/// Moves the pointer from one card to the next. Returns how many frames are drawn before the
|
||||
/// window is still again.
|
||||
fn frames_to_hover_the_next_card(h: &mut Harness<'_, Option<LauncherUi>>) -> u64 {
|
||||
let cards: Vec<egui::Pos2> = ["Image editing", "Vector illustration"].map(|t| h.get_by_label(t).rect().center()).to_vec();
|
||||
// Still to begin with: the icons are in, the first card is lit and the scroll bar is showing.
|
||||
h.hover_at(cards[0]);
|
||||
h.run();
|
||||
h.hover_at(cards[1]);
|
||||
h.run()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_off_hovering_a_card_shows_at_once() {
|
||||
let mut h = short_window(Rc::new(Cell::new(true)));
|
||||
let frames = frames_to_hover_the_next_card(&mut h);
|
||||
assert!(frames <= 5, "{frames} frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_on_hovering_a_card_animates() {
|
||||
let mut h = short_window(Rc::new(Cell::new(false)));
|
||||
// The rule wipes in and the icon rises over 0.3 s, at 60 frames a second.
|
||||
let frames = frames_to_hover_the_next_card(&mut h);
|
||||
assert!(frames >= 10, "{frames} frames");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
<description>
|
||||
<p>
|
||||
ArtCraft Launcher lists every app in the Craft App Suite (PhotoCraft, LightCraft, FilmCraft,
|
||||
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
|
||||
SoundCraft and FigCraft), installs the right build for your computer, and keeps them up to date.
|
||||
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
|
||||
and SoundCraft), installs the right build for your computer, and keeps them up to date.
|
||||
</p>
|
||||
<ul>
|
||||
<li>One place to find, install and open every Craft app</li>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ license: MIT OR Apache-2.0
|
|||
description: |-
|
||||
Install, update and launch the Craft App Suite.
|
||||
ArtCraft Launcher lists PhotoCraft, LightCraft, FilmCraft, DesignCraft,
|
||||
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
|
||||
SoundCraft and FigCraft, installs the right build for this computer and keeps
|
||||
them up to date from GitHub releases. It can live in the system tray.
|
||||
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
|
||||
and SoundCraft, installs the right build for this computer and keeps them up
|
||||
to date from GitHub releases. It can live in the system tray.
|
||||
|
||||
# The binary links only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
|
||||
# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
# macOS DMG window
|
||||
|
||||
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
|
||||
`Applications` link side by side, laid out like the other Craft apps' DMGs. `package.sh` copies
|
||||
these files into the image; nothing here is generated at build time, so the DMG builds with a plain
|
||||
`hdiutil create -srcfolder` (no Finder scripting on CI, nothing extra installed in the signing job).
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `background.svg` | Source of the background: the ArtCraft mark in black (`docs/brand/artcraft-mark-black.svg`, linked, not copied) whole on the mark's blue (`#4e7bfb`), Ink and Paper. The mark is a trademark: it stays exactly as supplied, never cropped or recoloured (`docs/brand/LICENSE-brand.txt`), unlike the cropped icons on the other apps' DMGs. Its text (Inter, Geist Mono from `assets/fonts`) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
|
||||
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
|
||||
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, ArtCraft Launcher.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
|
||||
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
|
||||
|
||||
## The volume name has no version
|
||||
|
||||
The mounted volume is called `ArtCraft Launcher`, not `ArtCraft Launcher <version>`. The layout
|
||||
points at the background through an alias that includes the volume name, and Finder resolves it by
|
||||
that name: with a versioned name the window keeps its size and icon positions but shows no
|
||||
background. The DMG file name (`artcraft-launcher-<version>-macos-<arch>.dmg`) still carries the
|
||||
version.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
|
||||
so the area under both icons stays light (Paper).
|
||||
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
|
||||
the label strip under it.
|
||||
|
||||
## Regenerate
|
||||
|
||||
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
|
||||
|
||||
```sh
|
||||
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
|
||||
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
|
||||
```
|
||||
|
||||
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
|
||||
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
|
||||
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
|
||||
outline it (`usvg --skip-system-fonts --use-font-file assets/fonts/Inter-Variable.ttf
|
||||
--use-font-file assets/fonts/GeistMono-Variable.ttf` converts text to paths). The window is
|
||||
660 × 432 with Finder's 32 pt title bar; the content area is 660 × 400.
|
||||
|
||||
This follows the other Craft apps' `packaging/macos/dmg/` (storytold/wordcraft#21,
|
||||
storytold/photocraft#1201, first in storytold/vectorcraft#493).
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 52 KiB |
Binary file not shown.
Binary file not shown.
|
|
@ -1,158 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
|
||||
|
||||
Needs resvg on PATH and:
|
||||
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
|
||||
|
||||
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
|
||||
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
|
||||
|
||||
python3 packaging/macos/dmg/generate.py
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from ds_store import DSStore
|
||||
from mac_alias import Alias, TargetInfo, VolumeInfo
|
||||
from PIL import Image, ImageCms
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Keep in sync with package.sh (volume name) and README.md (layout).
|
||||
VOLUME = "ArtCraft Launcher"
|
||||
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
|
||||
TITLE_BAR = 32
|
||||
ICON_SIZE = 128
|
||||
PALETTE = 16 # colours in background.tiff
|
||||
ICONS = {"ArtCraft Launcher.app": (326, 205), "Applications": (574, 205)}
|
||||
# A fixed date for the alias and the colour profile. Finder never matches it against the image
|
||||
# (each build is a new volume): it finds the background by volume name and path.
|
||||
FIXED_DATE = datetime.datetime(2026, 10, 11, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
def render(svg, width, out):
|
||||
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
|
||||
# same pixels.
|
||||
subprocess.run(
|
||||
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
|
||||
check=True,
|
||||
)
|
||||
with Image.open(out) as im:
|
||||
return im.convert("RGB")
|
||||
|
||||
|
||||
def quantize(im):
|
||||
# No dithering: dither noise compresses badly and the artwork has flat fills.
|
||||
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
|
||||
|
||||
|
||||
def srgb_profile():
|
||||
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
|
||||
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
|
||||
d = FIXED_DATE
|
||||
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
|
||||
if icc[84:100] != bytes(16):
|
||||
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
|
||||
return bytes(icc)
|
||||
|
||||
|
||||
def write_tiff(svg, out):
|
||||
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
one, two = (
|
||||
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
|
||||
)
|
||||
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
|
||||
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
|
||||
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
|
||||
one.save(
|
||||
out,
|
||||
"TIFF",
|
||||
save_all=True,
|
||||
append_images=[two],
|
||||
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
|
||||
dpi=(72, 72),
|
||||
icc_profile=srgb_profile(),
|
||||
)
|
||||
with Image.open(out) as im:
|
||||
im.seek(1)
|
||||
if im.info.get("dpi") != (144, 144):
|
||||
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
|
||||
strips = []
|
||||
for page in (0, 1):
|
||||
im.seek(page)
|
||||
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
|
||||
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
|
||||
# leaves the padding byte after it uninitialised, so the file changed from run to run with
|
||||
# identical pixels. Zero it: nothing refers to that byte.
|
||||
starts = {o for o, _ in strips}
|
||||
with open(out, "r+b") as f:
|
||||
for o, n in strips:
|
||||
if (o + n) % 2 and o + n not in starts:
|
||||
f.seek(o + n)
|
||||
f.write(bytes(1))
|
||||
|
||||
|
||||
def background_alias():
|
||||
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
|
||||
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
|
||||
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
|
||||
# are only hints, as the volume's creation date.
|
||||
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
|
||||
volume.posix_path = f"/Volumes/{VOLUME}"
|
||||
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
|
||||
target.folder_name = ".background"
|
||||
target.cnid_path = [25]
|
||||
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
|
||||
target.posix_path = "/.background/background.tiff"
|
||||
return Alias(volume=volume, target=target).to_bytes()
|
||||
|
||||
|
||||
def write_ds_store(out):
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
with DSStore.open(out, "w+") as d:
|
||||
d["."]["bwsp"] = {
|
||||
"ShowStatusBar": False,
|
||||
"ShowToolbar": False,
|
||||
"ShowTabView": False,
|
||||
"ContainerShowSidebar": False,
|
||||
"ShowSidebar": False,
|
||||
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
|
||||
}
|
||||
d["."]["icvp"] = {
|
||||
"viewOptionsVersion": 1,
|
||||
"backgroundType": 2,
|
||||
"backgroundImageAlias": background_alias(),
|
||||
"backgroundColorRed": 1.0,
|
||||
"backgroundColorGreen": 1.0,
|
||||
"backgroundColorBlue": 1.0,
|
||||
"gridOffsetX": 0.0,
|
||||
"gridOffsetY": 0.0,
|
||||
"gridSpacing": 100.0,
|
||||
"arrangeBy": "none",
|
||||
"showIconPreview": True,
|
||||
"showItemInfo": False,
|
||||
"labelOnBottom": True,
|
||||
"textSize": 16.0,
|
||||
"iconSize": float(ICON_SIZE),
|
||||
}
|
||||
d["."]["vSrn"] = ("long", 1)
|
||||
for name, pos in ICONS.items():
|
||||
d[name]["Iloc"] = pos
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil.which("resvg"):
|
||||
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
|
||||
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
|
||||
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -113,10 +113,6 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
|
|||
fi
|
||||
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
||||
|
||||
# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
|
||||
# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
|
||||
xattr -cr "$APP"
|
||||
|
||||
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
|
||||
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/$APP_NAME"
|
||||
|
|
@ -137,31 +133,12 @@ STAGE="$WORK/dmg"
|
|||
mkdir -p "$STAGE"
|
||||
ditto "$APP" "$STAGE/$APP_NAME.app"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
|
||||
mkdir -p "$STAGE/.background"
|
||||
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
|
||||
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
|
||||
rm -f "$DMG"
|
||||
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
|
||||
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
|
||||
# similar detritus" on the app in the image and on a copy installed from it. `create` attaches a
|
||||
# device while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a
|
||||
# few tries. verify.sh checks the app inside the finished image with --strict. The volume name has
|
||||
# no version: .DS_Store finds the background through an alias that includes it.
|
||||
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
|
||||
hdiutil_retry() {
|
||||
local what="$1" attempt
|
||||
shift
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if hdiutil "$@"; then
|
||||
return 0
|
||||
fi
|
||||
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
|
||||
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
}
|
||||
hdiutil_retry create create -srcfolder "$STAGE" -volname "$APP_NAME" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
|
||||
rm -f "$DMG" "$WORK/raw.dmg"
|
||||
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
|
||||
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "$APP_NAME $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||
rm -f "$WORK/raw.dmg"
|
||||
sign "$DMG"
|
||||
codesign --verify --strict --verbose=2 "$DMG"
|
||||
if [ "$NOTARIZE" = 1 ]; then
|
||||
|
|
|
|||
|
|
@ -112,13 +112,10 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Integrity first: this must hold even for ad-hoc builds, and with --strict, on the app in the
|
||||
# image and on a copy installed from it with ditto. A valid DMG signature says nothing about the
|
||||
# bundle inside: the old makehybrid image gave every file a com.apple.FinderInfo, which strict
|
||||
# mode rejects as "detritus".
|
||||
codesign --verify --strict --deep --verbose=2 "$APP"
|
||||
ditto "$APP" "$WORK/copy/$APP_NAME.app"
|
||||
codesign --verify --strict --deep --verbose=2 "$WORK/copy/$APP_NAME.app"
|
||||
# Integrity first: this must hold even for ad-hoc builds. Not --strict here: files read back from
|
||||
# the DMG's HFS volume (hdiutil makehybrid) report a com.apple.FinderInfo attribute, which strict
|
||||
# mode calls "detritus". package.sh already ran the strict check on the bundle before imaging it.
|
||||
codesign --verify --deep --verbose=2 "$APP"
|
||||
details="$(codesign -d --verbose=2 "$APP" 2>&1)"
|
||||
printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
|
|||
$line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)
|
||||
# The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).
|
||||
$apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',
|
||||
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft', 'figcraft'
|
||||
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft'
|
||||
$mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))
|
||||
$wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))
|
||||
$icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }
|
||||
|
|
@ -148,14 +148,14 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
|
|||
$x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35
|
||||
}
|
||||
$brush.Dispose()
|
||||
# The apps, three to a row, in 36 px cells divided by hairlines (five rows fit above 312).
|
||||
# The apps, three to a row, in 40 px cells divided by hairlines.
|
||||
$pen = New-Object System.Drawing.Pen $line, 1
|
||||
$left = 22; $top = 118; $cell = 36
|
||||
$left = 22; $top = 126; $cell = 40
|
||||
for ($i = 0; $i -lt $icons.Count; $i++) {
|
||||
$cx = $left + ($i % 3) * $cell
|
||||
$cy = $top + [math]::Floor($i / 3) * $cell
|
||||
$g.DrawRectangle($pen, $cx, $cy, $cell, $cell)
|
||||
$g.DrawImage($icons[$i], $cx + 5, $cy + 5, $cell - 10, $cell - 10)
|
||||
$g.DrawImage($icons[$i], $cx + 6, $cy + 6, $cell - 12, $cell - 12)
|
||||
}
|
||||
$pen.Dispose()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ const APPS: &[(&str, &str)] = &[
|
|||
("gridcraft", "GridCraft"),
|
||||
("cadcraft", "CADCraft"),
|
||||
("soundcraft", "SoundCraft"),
|
||||
("figcraft", "FigCraft"),
|
||||
];
|
||||
|
||||
const DEFAULT_PORT: u16 = 8787;
|
||||
|
|
|
|||
Loading…
Reference in New Issue