Add artcraft-native: pure Rust (egui) desktop app, starting with Create Image and Create Video (#2067)

## What

A new workspace member, `artcraft-native/`. It is the ArtCraft desktop
app in pure Rust: an egui UI with no Tauri, no webview and no
JavaScript. It covers the two main product pages, **Create Image** and
**Create Video**, and reuses the Tauri-free API crates
(`artcraft_client`, `artcraft_api_defs`, `tokens`, `enums`).

It also adds:
- a Home page and a Library page
- a lightbox
- sign-in and settings dialogs
- a sidebar and top bar in the style of the brutalist
**artcraft-webapp**

Tools that aren't ported yet show a "coming soon" page that links to the
web app.

```bash
cargo run -p artcraft_native --release
```

## Why

To get a native, webview-free desktop app. Generation goes through
**OmniGen** (`/v1/omni_gen/{models,cost,generate}`), the same API as the
webapp. Model capabilities come from the server, so the pickers stay
current without app updates.

## Prompt box

Recreated feature for feature from `PromptBoxImage` / `PromptBoxVideo`:
- **Editor:** auto-grow, focus mode, resize grip, length counter with
the model's limit, Enter-to-generate setting, and `@Image1` / `@Video1`
/ `@Audio1` mentions with highlighting and autocomplete.
- **References:** the fanned reference deck (hover-expand, reorder, add
menus with limits) and the first/last keyframe slots with swap. Files
can be uploaded, picked from the library, dragged and dropped, or pasted
as images.
- **Model selector:** families with a flyout, maker logos and capability
badges.
- **Pickers:** aspect ratio, resolution, quality, count, bitrate, output
format, duration slider, sound and input mode (Keyframe / Omni
Reference).
- **Generate:** a live credit cost estimate, validation (starting frame,
text-only models, limits, uploads in progress), and clear-all with
confirmation.

The feed shows running jobs (with progress and time left), failures
(dismissable), finished results and the paginated library, in a masonry
grid or a list. It supports Recreate, Make Video, Share and Download,
plus select mode for batch downloads.

## Reviewer notes

- **Sign-in.** On first launch the app reuses an existing desktop login
by reading the session cookie from the Tauri app's jar, and keeps its
own copy in `~/Artcraft/credentials/native_session.json`. Browser
(device-code) and password sign-in are also available.
- **Decoding.**
- Generate and cost requests are sent as JSON maps, like the Tauri app's
omni path.
- Jobs, library, batch and prompt responses use lenient structs
(`backend/wire.rs`), so new server enum values can't break decoding.
- **Renderer.** It uses eframe's **glow** renderer. `wgpu-hal` fails to
build against the `windows` crate versions this workspace pins for
Tauri.
- **Model names and logos.** Known families use curated names: "Flux 1
Dev" rather than the server's "FLUX.1 [dev]". When the server reports
`artcraft` as a model's maker, the logo comes from the id prefix instead
(Nano Banana → Google), matching the webapp.
- **Fonts and assets.** Inter, Archivo and Geist Mono are bundled under
the OFL and listed in `NOTICE`. The wordmark, icon and maker logos are
the existing in-repo assets.
- **Diagnostics.** `ARTCRAFT_SCREENSHOT=<png>` (optionally with
`ARTCRAFT_PAGE` and `ARTCRAFT_SCREENSHOT_EXIT=1`) saves a screenshot
once the window has settled.

## Testing

- `cargo test -p artcraft_native`: 29 tests pass. Unit tests cover the
catalog, feed store, wire decoding and backend helpers. Headless
`egui_kittest` tests drive the prompt box: generate gating,
Enter-to-generate, pickers, `@` mentions and clear-all.
- `cargo clippy -p artcraft_native --all-targets --no-deps` is clean.
- Run against a real account: the session import, model lists, cost
estimates, job polling, library paging and thumbnails all worked.
- **Not exercised: a real paid generation and real uploads.** I held
back to avoid spending credits, so they need a manual check.

## Not done yet

- `@Character` mentions for Seedance 2.0
- Provider choice (Midjourney direct, fal); everything runs through
ArtCraft
- In-app video and audio playback; these open in the system player
- Animated previews in the feed
- The remaining tools (Audio, 3D, editors)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-11 00:44:59 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8abde75133
commit b5614ad333
46 changed files with 11978 additions and 353 deletions
Generated
+2551 -353
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
# Desktop application and its local build/test dependencies.
members = [
"artcraft-native", # Pure Rust (egui) desktop app; no Tauri or webview
"crates/api_clients/artcraft/artcraft_api_defs",
"crates/api_clients/artcraft/artcraft_client",
"crates/api_clients/artcraft/artcraft_router",
+3
View File
@@ -24,6 +24,9 @@ Font Awesome Free icons (frontend/libs/icons/src/lib/brand-icons.tsx,
Copyright Fonticons, Inc. https://fontawesome.com/license/free
Carter One font (frontend/apps/artcraft/app/public/resources/fonts/CarterOneRegular.json) —
SIL Open Font License 1.1. https://fonts.google.com/specimen/Carter+One
Inter, Archivo and Geist Mono fonts (artcraft-native/assets/fonts/) — SIL Open Font License 1.1;
each font's license is next to it. https://rsms.me/inter/ ·
https://fonts.google.com/specimen/Archivo · https://vercel.com/font
Third-party trademarks
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "artcraft_native"
description = "ArtCraft desktop app in pure Rust: an egui UI over the ArtCraft API client (no Tauri, no webview)"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "artcraft-native"
path = "src/main.rs"
[dependencies]
# Internal (Tauri-free) API crates shared with the Tauri app.
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
cookie.workspace = true
chrono.workspace = true
directories = "6.0.0"
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
egui = "0.36"
enums.workspace = true
egui_extras = { version = "0.36", default-features = false, features = ["image", "svg"] }
env_logger.workspace = true
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
log.workspace = true
open = "5"
reqwest = { workspace = true, features = ["multipart"] }
resvg = "0.45"
rfd = "0.16"
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "sync", "time", "fs"] }
uuid = { workspace = true, features = ["v4"] }
# Pasting images from the clipboard (egui's own clipboard support is text only).
arboard = { version = "3", default-features = false, features = ["image-data"] }
[dev-dependencies]
egui_kittest = "0.36"
+106
View File
@@ -0,0 +1,106 @@
# ArtCraft Native
ArtCraft's desktop app in pure Rust: an [egui](https://github.com/emilk/egui) UI over the same
ArtCraft API client crates the Tauri app uses. There is no Tauri, no webview and no JavaScript.
The look follows the brutalist webapp (`artcraft-services/frontend/apps/artcraft-webapp`). That
means near-black surfaces with white/15 hairlines and 3 px corners on controls. Type is Archivo for
display, Inter for body text and Geist Mono for labels. The brand blue marks the primary actions.
Create Image and Create Video are fully built. The other tools show a "coming soon" page that
links to the same tool in the web app.
## Running
```bash
cargo run -p artcraft_native --release
```
The workspace's default member is the Tauri app, so pass `-p artcraft_native`.
- **Sign-in.** On first launch the app reuses an existing ArtCraft login. It reads the session
cookie from the Tauri app's jar (`<cache dir>/ai.artcraft.app/.cookies`) and keeps its own copy in
`~/Artcraft/credentials/native_session.json`. Otherwise use "Sign in with browser" (device code)
or a username and password.
- **API host.** `~/Artcraft/settings/env_configs.json` selects it, exactly as for the Tauri app.
## Architecture
| Module | What it does |
|--------------------|--------------------------------------------------------------------------------|
| `backend` | Tokio runtime and every API call; results come back to the UI as `Event`s |
| `backend::wire` | Lenient response shapes; new server enum values can't break decoding |
| `backend::session` | Credentials: own file, Tauri cookie import, env config |
| `backend::media_cache` | Thumbnails and previews: downloaded and decoded off-thread, evicted when unused |
| `models` | Model catalog from OmniGen listings, plus names, families and limits |
| `prompt_box` | The shared prompt box: editor, reference deck, keyframes, pickers, model selector |
| `pages` | Create Image, Create Video, Home, Library, and the shared page shell |
| `feed` | Generation feed: running, failed and finished jobs, library paging, grid and list |
| `overlays` | Lightbox, library picker, sign-in and settings dialogs |
| `shell` | Sidebar and top bar |
| `theme`, `ui` | Colour and type tokens, Lucide-style vector icons, widgets, toasts |
How the backend works:
- **Generation.** It goes through OmniGen: `/v1/omni_gen/{models,cost,generate}/{image,video}`.
Model capabilities come from the server, so new models and options appear without an app update.
- **Typed client functions.** Model lists, uploads, credits, login and deletes use
`artcraft_client`.
- **Lenient decoding.** Generate and cost requests are JSON maps, as in the Tauri app. Job polling
(`/v1/jobs/session`), the library list, batches and prompts are decoded with lenient structs.
- **Identity headers.** Every request sends the desktop identity headers: `Origin` and
`User-Agent`.
## Prompt box parity
| Feature | Status |
|-----------------------------------------------------------------|-----------------------------------------|
| Auto-growing prompt (~4 lines), expand/collapse, resize grip | Done |
| Focus mode (fullscreen editor with deck and toolbar) | Done |
| Length counter, red over the model's limit | Done |
| Enter to generate (setting; Shift+Enter for a new line) | Done |
| `@Image1` / `@Video1` / `@Audio1` highlight and autocomplete | Done |
| Reference deck: fan, +N badge, hover-expand, reorder, clear all | Done |
| Keyframe slots: first/last frame, tilt, swap | Done |
| Upload, pick from library, drag & drop, paste image | Done (video uploads must be MP4) |
| Model selector with families, logos, capability badges | Done |
| Aspect ratio, resolution, quality, count | Done |
| Bitrate, output format, duration slider, sound, input mode | Done |
| Live credit cost on the generate button | Done |
| Validation: starting frame, text-only banner, limits, uploads | Done |
| Clear all (confirms when references are attached) | Done |
| `@Character` mentions and the Characters modal (Seedance 2.0) | Not yet |
| Provider choice (Midjourney direct, fal) | Not yet; everything runs via ArtCraft |
| In-app video and audio playback | Opens in the system player instead |
| Animated video previews in the feed | Still frames only |
Feed and lightbox:
- **Feed.** Pending cards show progress, time left and a batch banner. Failed cards show the reason
and can be dismissed. The grid is masonry, with a list view alongside. Hover actions are Recreate,
Make Video, Share and Download. Select mode does batch downloads. The last viewed item is marked.
- **Lightbox.** Prev/next with the arrow keys, prompt copy and details. Actions are Recreate, Make
Video, Download, Share and Delete.
## Diagnostics
Set `ARTCRAFT_SCREENSHOT=<file.png>` to save a screenshot once the window has settled.
`ARTCRAFT_PAGE` (`home`, `create-image`, `create-video` or `library`) picks the page, and
`ARTCRAFT_SCREENSHOT_EXIT=1` closes the app afterwards.
## Tests
```bash
cargo test -p artcraft_native
```
There are unit tests for the catalog, feed store, wire decoding and backend helpers. Headless UI
tests (`egui_kittest`) drive the prompt box: generate gating, Enter to generate, the pickers,
`@` mentions and clear-all.
## Fonts and brand assets
`assets/fonts` holds Inter, Archivo and Geist Mono, all under the SIL Open Font License; the
licences sit next to the files. The wordmark, app icon and creator logos are the Tauri app's own
(`frontend/apps/artcraft/app/public/resources`, `crates/desktop/artcraft/icons`); see `NOTICE`
for their terms.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+729
View File
@@ -0,0 +1,729 @@
//! The app: owns the backend, the pages and the overlays; routes backend events to whoever is
//! waiting for them and page requests to the services that fulfil them.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use crate::backend::media_cache::MediaCache;
use crate::backend::wire::{Prompt, SessionUser};
use crate::backend::{Backend, Event, Modality, SHARE_URL_BASE};
use crate::feed::grid::{RatioCache, ViewMode};
use crate::feed::store::{FeedNotice, FeedStore};
use crate::feed::types::{FeedItem, MediaKind};
use crate::models::Catalog;
use crate::overlays::account::{LoginAction, LoginDialog, SettingsAction, settings_dialog};
use crate::overlays::library_picker::{LibraryPicker, PickerAction};
use crate::overlays::lightbox::{Lightbox, LightboxAction, fit_inside};
use crate::pages::common::{self, AppRequest, Env, FeedKey};
use crate::pages::create_image::{CreateImagePage, ImageSettings};
use crate::pages::create_video::{CreateVideoPage, VideoSettings};
use crate::pages::other;
use crate::prompt_box::refs::{RefKind, RefMedia};
use crate::shell::{self, Account, Page, ShellAction};
use crate::theme;
use crate::ui::toast::Toasts;
use crate::ui::widgets;
/// Builds the page environment from the app's fields (disjoint borrows, so pages can be
/// borrowed mutably alongside it).
macro_rules! env {
($app:ident, $ctx:expr) => {
Env { ctx: $ctx.clone(), backend: &$app.backend, cache: &mut $app.cache, toasts: &mut $app.toasts, catalog: &$app.catalog, signed_in: $app.user.is_some(), enter_to_generate: $app.enter_to_generate, view_mode: $app.view_mode, ratios: &mut $app.ratios, prompts: &$app.prompts, requests: &mut $app.requests }
};
}
const STORAGE_KEY: &str = "artcraft-native";
const JOB_POLL_INTERVAL: f64 = 5.0;
const CREDITS_INTERVAL: f64 = 60.0;
const MODEL_RETRY_INTERVAL: f64 = 15.0;
/// List view: prompt records fetched per frame at most.
const PROMPT_FETCHES_PER_FRAME: usize = 8;
/// Preferences that survive restarts.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
struct Saved {
page: Page,
enter_to_generate: bool,
view_mode: ViewMode,
sidebar_open: bool,
image: ImageSettings,
video: VideoSettings,
}
impl Default for Saved {
fn default() -> Self {
Self { page: Page::CreateImage, enter_to_generate: false, view_mode: ViewMode::Grid, sidebar_open: true, image: ImageSettings { count: 1, ..Default::default() }, video: VideoSettings::default() }
}
}
pub struct ArtcraftApp {
backend: Backend,
cache: MediaCache,
toasts: Toasts,
catalog: Catalog,
user: Option<SessionUser>,
credits: Option<u64>,
page: Page,
enter_to_generate: bool,
view_mode: ViewMode,
sidebar_open: bool,
image: CreateImagePage,
video: CreateVideoPage,
library: FeedStore,
ratios: RatioCache,
/// Prompt texts by prompt token (list view, lightbox).
prompts: HashMap<String, String>,
prompt_records: HashMap<String, Prompt>,
requested_prompts: HashSet<String>,
/// A Recreate waiting for its prompt record.
pending_recreate: Option<(MediaKind, String)>,
/// Which feed a batch expansion belongs to.
batch_kinds: HashMap<String, MediaKind>,
lightbox: Option<Lightbox>,
picker: Option<LibraryPicker>,
login: Option<LoginDialog>,
settings_open: bool,
preview: Option<String>,
logo: egui::TextureHandle,
requests: Vec<AppRequest>,
screenshot: Option<ScreenshotRequest>,
next_poll: f64,
next_credits: f64,
models_retry_at: Option<f64>,
}
impl ArtcraftApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
theme::apply(&cc.egui_ctx);
egui_extras::install_image_loaders(&cc.egui_ctx);
let mut saved: Saved = cc.storage.and_then(|s| eframe::get_value(s, STORAGE_KEY)).unwrap_or_default();
if let Some(page) = std::env::var("ARTCRAFT_PAGE").ok().and_then(|p| Page::from_slug(&p)) {
saved.page = page;
}
let backend = Backend::new(cc.egui_ctx.clone());
let cache = MediaCache::new(backend.runtime(), backend.http());
backend.load_models(Modality::Image);
backend.load_models(Modality::Video);
backend.refresh_session();
Self { cache, toasts: Toasts::default(), catalog: Catalog::default(), user: None, credits: None, page: saved.page, enter_to_generate: saved.enter_to_generate, view_mode: saved.view_mode, sidebar_open: saved.sidebar_open, image: CreateImagePage::new(saved.image), video: CreateVideoPage::new(saved.video), library: FeedStore::default(), ratios: RatioCache::default(), prompts: HashMap::new(), prompt_records: HashMap::new(), requested_prompts: HashSet::new(), pending_recreate: None, batch_kinds: HashMap::new(), lightbox: None, picker: None, login: None, settings_open: false, preview: None, logo: load_logo(&cc.egui_ctx), requests: Vec::new(), screenshot: ScreenshotRequest::from_env(), next_poll: 0.0, next_credits: CREDITS_INTERVAL, models_retry_at: None, backend }
}
// --- Events from the backend -------------------------------------------------------------
fn handle_event(&mut self, ctx: &egui::Context, event: Event) {
match event {
Event::Session(user) => {
let changed = self.user.as_ref().map(|u| &u.username) != user.as_ref().map(|u| &u.username);
self.user = user;
if changed {
self.image.feed.reset();
self.video.feed.reset();
self.library.reset();
self.next_poll = 0.0;
}
if self.user.is_some() {
self.login = None;
} else {
self.credits = None;
if let Some(login) = &mut self.login {
login.busy = false;
}
}
},
Event::LoginFailed(message) => {
if let Some(login) = &mut self.login {
login.busy = false;
login.device = None;
login.error = Some(message);
}
},
Event::DeviceLogin { url, code } => {
if let Some(login) = &mut self.login {
login.device = Some((url, code));
}
},
Event::Credits(credits) => self.credits = Some(credits),
Event::Models(modality, Ok(models)) => match modality {
Modality::Image => self.catalog.set_image(models),
Modality::Video => self.catalog.set_video(models),
},
Event::Models(_, Err(message)) => {
if self.models_retry_at.is_none() {
self.toasts.error(format!("Couldn't load models: {message}"));
}
self.models_retry_at = Some(ctx.input(|i| i.time) + MODEL_RETRY_INTERVAL);
},
Event::Cost { key, credits } => {
self.image.cost.apply(&key, credits);
self.video.cost.apply(&key, credits);
},
Event::Enqueued { modality, job_tokens, meta } => {
let mut env = env!(self, ctx);
match modality {
Modality::Image => self.image.on_enqueued(&mut env, &job_tokens, &meta),
Modality::Video => self.video.on_enqueued(&mut env, &job_tokens, &meta),
}
self.next_poll = ctx.input(|i| i.time) + 2.0;
},
Event::EnqueueFailed { modality, message } => {
let mut env = env!(self, ctx);
match modality {
Modality::Image => self.image.on_enqueue_failed(&mut env, &message),
Modality::Video => self.video.on_enqueue_failed(&mut env, &message),
}
},
Event::Uploaded { ref_id, result } => {
if self.image.refs.find_mut(ref_id).is_some() {
common::apply_upload(&mut self.toasts, &mut self.image.refs, ref_id, result);
} else if common::apply_upload(&mut self.toasts, &mut self.video.refs, ref_id, result) {
let mut env = env!(self, ctx);
self.video.check_durations(&mut env, ref_id);
}
},
Event::Jobs(jobs) => {
for (kind, notices) in [(MediaKind::Image, self.image.feed.apply_jobs(MediaKind::Image, &jobs, &self.catalog)), (MediaKind::Video, self.video.feed.apply_jobs(MediaKind::Video, &jobs, &self.catalog))] {
for notice in notices {
match notice {
FeedNotice::Completed => {
self.toasts.success(format!("{} generation complete!", kind.label()));
self.backend.refresh_credits();
},
FeedNotice::Failed(reason) => self.toasts.error(reason),
FeedNotice::LoadBatch { job_token, batch_token } => {
self.batch_kinds.insert(job_token.clone(), kind);
self.backend.load_batch(job_token, batch_token);
},
}
}
}
},
Event::LibraryPage { key, page, items, total_pages } => match key.as_str() {
"picker" => {
if let Some(p) = &mut self.picker {
p.apply_page(page, items, total_pages);
}
},
"image" => self.image.feed.apply_library_page(MediaKind::Image, page, &items, total_pages),
"video" => self.video.feed.apply_library_page(MediaKind::Video, page, &items, total_pages),
_ => self.library.apply_library_page(MediaKind::Image, page, &items, total_pages),
},
Event::LibraryFailed { key, message } => {
match key.as_str() {
"picker" => {
if let Some(p) = &mut self.picker {
p.loading = false;
p.has_more = false;
}
},
"image" => self.image.feed.library_failed(),
"video" => self.video.feed.library_failed(),
_ => self.library.library_failed(),
}
self.toasts.error(format!("Couldn't load your library: {message}"));
},
Event::Batch { job_token, items } => match self.batch_kinds.remove(&job_token) {
Some(MediaKind::Video) => self.video.feed.apply_batch(MediaKind::Video, &items),
_ => self.image.feed.apply_batch(MediaKind::Image, &items),
},
Event::Prompt(prompt) => {
if let Some(text) = &prompt.maybe_positive_prompt {
self.prompts.insert(prompt.token.clone(), text.clone());
}
if self.pending_recreate.as_ref().is_some_and(|(_, t)| *t == prompt.token) {
if let Some((kind, _)) = self.pending_recreate.take() {
self.apply_recreate(kind, &prompt);
}
}
self.prompt_records.insert(prompt.token.clone(), prompt);
},
Event::Deleted(token) => {
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
feed.remove_item(&token);
}
if self.lightbox.as_ref().is_some_and(|l| l.token == token) {
self.lightbox = None;
}
},
Event::Saved(path) => self.toasts.success(format!("Downloaded {}", common::file_name(&path))),
Event::Toast { error, message } => {
if error {
self.toasts.error(message);
} else {
self.toasts.success(message);
}
},
}
}
// --- Requests from the pages -------------------------------------------------------------
fn handle_request(&mut self, ctx: &egui::Context, request: AppRequest) {
match request {
AppRequest::SignIn => self.open_login(),
AppRequest::Open { kind, token } => {
if let Some(item) = self.find_item(&token) {
self.request_prompt(item.prompt_token.clone());
}
self.lightbox = Some(Lightbox::new(kind, token));
},
AppRequest::PickFromLibrary { kind, slot, max, page } => {
let Some(user) = &self.user else {
self.open_login();
return;
};
let refs = if page == MediaKind::Image { &self.image.refs } else { &self.video.refs };
let attached: HashSet<String> = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).filter_map(|r| r.token.clone()).collect();
let mut picker = LibraryPicker::new(kind, slot, page, max, attached);
picker.loading = true;
self.backend.load_library("picker".to_owned(), user.username.clone(), picker.media_class(), 0);
self.picker = Some(picker);
},
AppRequest::MakeVideo { token } => {
let Some(item) = self.find_item(&token) else {
return;
};
let image = RefMedia::from_library(RefKind::Image, item.token.clone(), item.thumbnail.clone(), Some(item.full_url.clone()), 0.0);
self.video.set_start_image(image);
self.lightbox = None;
self.page = Page::CreateVideo;
},
AppRequest::Recreate { kind, token } => {
let Some(prompt_token) = self.find_item(&token).and_then(|i| i.prompt_token.clone()) else {
self.toasts.error("Could not load the original prompt.");
return;
};
match self.prompt_records.get(&prompt_token).cloned() {
Some(prompt) => self.apply_recreate(kind, &prompt),
None => {
self.pending_recreate = Some((kind, prompt_token.clone()));
self.requested_prompts.insert(prompt_token.clone());
self.backend.load_prompt(prompt_token);
},
}
},
AppRequest::Share(token) => {
ctx.copy_text(format!("{SHARE_URL_BASE}{token}"));
self.toasts.success("Share link copied");
},
AppRequest::CopyText(text) => {
ctx.copy_text(text);
self.toasts.success("Prompt copied");
},
AppRequest::Download(tokens) => self.download(&tokens),
AppRequest::Preview(url) => self.preview = Some(url),
AppRequest::LoadMoreLibrary(key) => {
let Some(user) = &self.user else {
return;
};
let (feed, name) = match key {
FeedKey::Image => (&mut self.image.feed, "image"),
FeedKey::Video => (&mut self.video.feed, "video"),
FeedKey::Library => (&mut self.library, "library"),
};
if let Some(page) = feed.next_library_page() {
self.backend.load_library(name.to_owned(), user.username.clone(), key.media_classes(), page);
}
},
}
}
fn apply_recreate(&mut self, kind: MediaKind, prompt: &Prompt) {
match kind {
MediaKind::Image => {
self.image.apply_prompt(prompt);
self.page = Page::CreateImage;
},
MediaKind::Video => {
self.video.apply_prompt(prompt);
self.page = Page::CreateVideo;
},
}
self.lightbox = None;
}
fn download(&mut self, tokens: &[String]) {
let items: Vec<FeedItem> = tokens.iter().filter_map(|t| self.find_item(t).cloned()).collect();
match items.as_slice() {
[] => {},
[item] => {
let name = file_name_for(item);
if let Some(path) = rfd::FileDialog::new().set_file_name(&name).save_file() {
self.backend.save_url(item.full_url.clone(), path);
}
},
many => {
if let Some(dir) = rfd::FileDialog::new().pick_folder() {
for item in many {
self.backend.save_url(item.full_url.clone(), dir.join(file_name_for(item)));
}
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
feed.selecting = false;
feed.selected.clear();
}
}
},
}
}
fn find_item(&self, token: &str) -> Option<&FeedItem> {
self.image.feed.find(token).or_else(|| self.video.feed.find(token)).or_else(|| self.library.find(token))
}
fn request_prompt(&mut self, prompt_token: Option<String>) {
if let Some(t) = prompt_token {
if self.requested_prompts.insert(t.clone()) {
self.backend.load_prompt(t);
}
}
}
fn open_login(&mut self) {
if self.login.is_none() {
self.login = Some(LoginDialog::default());
}
}
/// The feed the top bar's toggles act on.
fn current_feed(&mut self) -> Option<&mut FeedStore> {
match self.page {
Page::CreateImage => Some(&mut self.image.feed),
Page::CreateVideo => Some(&mut self.video.feed),
Page::Library => Some(&mut self.library),
_ => None,
}
}
// --- Frame -------------------------------------------------------------------------------
fn timers(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
if self.user.is_some() && now >= self.next_poll {
self.next_poll = now + JOB_POLL_INTERVAL;
self.backend.poll_jobs();
}
if self.user.is_some() && now >= self.next_credits {
self.next_credits = now + CREDITS_INTERVAL;
self.backend.refresh_credits();
}
if self.models_retry_at.is_some_and(|t| now >= t) {
self.models_retry_at = None;
if self.catalog.image.is_empty() {
self.backend.load_models(Modality::Image);
}
if self.catalog.video.is_empty() {
self.backend.load_models(Modality::Video);
}
}
ctx.request_repaint_after(Duration::from_secs(1));
}
fn shell(&mut self, ui: &mut Ui) {
let account = Account { username: self.user.as_ref().map(|u| u.username.as_str()), display_name: self.user.as_ref().map(|u| u.display_name.as_str()).filter(|n| !n.is_empty()), credits: self.credits };
let mut actions = Vec::new();
if self.sidebar_open {
egui::Panel::left("sidebar").exact_size(shell::sidebar_width()).resizable(false).frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| {
actions.extend(shell::sidebar(ui, self.page, &account, &self.logo));
});
}
let toggles = match self.page {
Page::CreateImage => Some((self.view_mode, self.image.feed.selecting)),
Page::CreateVideo => Some((self.view_mode, self.video.feed.selecting)),
Page::Library => Some((self.view_mode, self.library.selecting)),
_ => None,
};
egui::Panel::top("topbar").exact_size(shell::topbar_height()).resizable(false).frame(egui::Frame::NONE).show(ui, |ui| {
actions.extend(shell::topbar(ui, self.page, &account, toggles, self.sidebar_open));
});
for action in actions {
match action {
ShellAction::Navigate(page) => {
self.page = page;
self.lightbox = None;
},
ShellAction::ToggleSidebar => self.sidebar_open = !self.sidebar_open,
ShellAction::SignIn => self.open_login(),
ShellAction::SignOut => {
self.backend.logout();
self.settings_open = false;
},
ShellAction::OpenSettings => self.settings_open = true,
ShellAction::SetViewMode(mode) => self.view_mode = mode,
ShellAction::ToggleSelect => {
if let Some(feed) = self.current_feed() {
feed.selecting = !feed.selecting;
feed.selected.clear();
}
},
}
}
}
fn page_ui(&mut self, ui: &mut Ui) {
let ctx = ui.ctx().clone();
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(theme::BG)).show(ui, |ui| {
let mut env = env!(self, ctx);
match self.page {
Page::Home => {
if let Some(page) = other::home(ui, env.signed_in, env.requests) {
self.page = page;
}
},
Page::CreateImage => self.image.ui(ui, &mut env),
Page::CreateVideo => self.video.ui(ui, &mut env),
Page::Library => other::library(ui, &mut env, &mut self.library),
page => other::coming_soon(ui, page),
}
});
self.prefetch_list_prompts();
}
/// The list view shows prompt texts: fetch the records for the visible feed, a few at a time.
fn prefetch_list_prompts(&mut self) {
if self.view_mode != ViewMode::List {
return;
}
let feed = match self.page {
Page::CreateImage => &self.image.feed,
Page::CreateVideo => &self.video.feed,
Page::Library => &self.library,
_ => return,
};
let wanted: Vec<String> = feed.items.iter().filter_map(|i| i.prompt_token.clone()).filter(|t| !self.requested_prompts.contains(t)).take(PROMPT_FETCHES_PER_FRAME).collect();
for token in wanted {
self.request_prompt(Some(token));
}
}
fn overlays(&mut self, ctx: &egui::Context) {
if let Some(lb) = &mut self.lightbox {
let feed = match lb.kind {
_ if self.page == Page::Library => &self.library,
MediaKind::Image => &self.image.feed,
MediaKind::Video => &self.video.feed,
};
let order = feed.ordered_tokens();
let item = self.image.feed.find(&lb.token).or_else(|| self.video.feed.find(&lb.token)).or_else(|| self.library.find(&lb.token)).cloned();
match item {
Some(item) => {
let prompt = item.prompt_token.as_ref().and_then(|t| self.prompt_records.get(t));
let actions = lb.show(ctx, &item, &order, prompt, &self.catalog, &mut self.cache);
for action in actions {
self.lightbox_action(ctx, action, &item);
}
},
None => self.lightbox = None,
}
}
if let Some(picker) = &mut self.picker {
for action in picker.show(ctx, &mut self.cache) {
match action {
PickerAction::Close => self.picker = None,
PickerAction::LoadMore => {
if let (Some(p), Some(user)) = (self.picker.as_mut(), self.user.as_ref()) {
if !p.loading {
p.loading = true;
self.backend.load_library("picker".to_owned(), user.username.clone(), p.media_class(), p.next_page);
}
}
},
PickerAction::Confirm(picks) => {
if let Some(p) = self.picker.take() {
let refs = if p.page == MediaKind::Image { &mut self.image.refs } else { &mut self.video.refs };
common::attach_from_library(refs, p.kind, p.slot, picks);
}
},
}
if self.picker.is_none() {
break;
}
}
}
if let Some(login) = &mut self.login {
match login.show(ctx) {
Some(LoginAction::Close) => self.login = None,
Some(LoginAction::Password) => {
login.busy = true;
login.error = None;
self.backend.login_with_password(login.username.trim().to_owned(), std::mem::take(&mut login.password));
},
Some(LoginAction::Browser) => {
login.error = None;
self.backend.login_with_browser();
},
Some(LoginAction::CancelBrowser) => {
self.backend.cancel_browser_login();
login.device = None;
},
None => {},
}
}
if self.settings_open {
let username = self.user.as_ref().map(|u| u.username.clone());
match settings_dialog(ctx, &mut self.enter_to_generate, username.as_deref(), self.credits, self.backend.data_root()) {
Some(SettingsAction::Close) => self.settings_open = false,
Some(SettingsAction::SignOut) => {
self.settings_open = false;
self.backend.logout();
},
Some(SettingsAction::SignIn) => {
self.settings_open = false;
self.open_login();
},
None => {},
}
}
if let Some(url) = self.preview.clone() {
let close = widgets::modal(ctx, Id::new("preview"), (ctx.content_rect().width() * 0.9).min(1100.0), |ui| {
let bounds = egui::vec2(ui.available_width(), ctx.content_rect().height() * 0.8);
match self.cache.get(ctx, &url) {
crate::backend::media_cache::Lookup::Ready(t) => {
let size = fit_inside(t.size_vec2(), bounds);
ui.vertical_centered(|ui| ui.add(egui::Image::new(&t).fit_to_exact_size(size)));
},
_ => {
ui.add_sized(bounds, egui::Spinner::new());
},
}
});
if close {
self.preview = None;
}
}
}
fn lightbox_action(&mut self, ctx: &egui::Context, action: LightboxAction, item: &FeedItem) {
match action {
LightboxAction::Close => self.lightbox = None,
LightboxAction::Navigate(token) => {
if let Some(next) = self.find_item(&token).cloned() {
self.request_prompt(next.prompt_token);
}
for feed in [&mut self.image.feed, &mut self.video.feed, &mut self.library] {
if feed.find(&token).is_some() {
feed.last_viewed = Some(token.clone());
}
}
if let Some(lb) = &mut self.lightbox {
lb.token = token;
}
},
LightboxAction::Recreate => self.handle_request(ctx, AppRequest::Recreate { kind: item.kind, token: item.token.clone() }),
LightboxAction::MakeVideo => self.handle_request(ctx, AppRequest::MakeVideo { token: item.token.clone() }),
LightboxAction::Download => self.download(std::slice::from_ref(&item.token)),
LightboxAction::Share => self.handle_request(ctx, AppRequest::Share(item.token.clone())),
LightboxAction::Delete => {
self.backend.delete_media(item.token.clone());
self.lightbox = None;
},
LightboxAction::CopyPrompt(text) => self.handle_request(ctx, AppRequest::CopyText(text)),
LightboxAction::Play(url) => {
if let Err(err) = open::that(&url) {
self.toasts.error(format!("Couldn't open the video: {err}"));
}
},
}
}
}
impl eframe::App for ArtcraftApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
for event in self.backend.drain() {
self.handle_event(ctx, event);
}
self.timers(ctx);
}
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.cache.poll(&ctx);
self.shell(ui);
self.page_ui(ui);
for request in std::mem::take(&mut self.requests) {
self.handle_request(&ctx, request);
}
self.overlays(&ctx);
self.toasts.show(&ctx);
if let Some(shot) = &mut self.screenshot {
shot.tick(&ctx);
}
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
let saved = Saved { page: self.page, enter_to_generate: self.enter_to_generate, view_mode: self.view_mode, sidebar_open: self.sidebar_open, image: self.image.settings.clone(), video: self.video.settings.clone() };
eframe::set_value(storage, STORAGE_KEY, &saved);
}
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
theme::BG.to_normalized_gamma_f32()
}
}
/// Diagnostics: `ARTCRAFT_SCREENSHOT=<file.png>` saves a screenshot once the window has settled
/// (and `ARTCRAFT_SCREENSHOT_EXIT=1` then closes the app), for checking the UI headlessly.
struct ScreenshotRequest {
path: std::path::PathBuf,
exit: bool,
frames: u32,
requested: bool,
}
impl ScreenshotRequest {
/// Frames to wait before capturing (models, session and thumbnails get a moment to load).
const SETTLE_SECS: f64 = 8.0;
fn from_env() -> Option<Self> {
let path = std::env::var_os("ARTCRAFT_SCREENSHOT")?.into();
Some(Self { path, exit: std::env::var_os("ARTCRAFT_SCREENSHOT_EXIT").is_some(), frames: 0, requested: false })
}
fn tick(&mut self, ctx: &egui::Context) {
self.frames += 1;
ctx.request_repaint();
if !self.requested && ctx.input(|i| i.time) >= Self::SETTLE_SECS {
self.requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
}
let image = ctx.input(|i| {
i.raw.events.iter().find_map(|e| match e {
egui::Event::Screenshot { image, .. } => Some(image.clone()),
_ => None,
})
});
if let Some(image) = image {
let [w, h] = image.size;
let pixels: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
match image::RgbaImage::from_raw(w as u32, h as u32, pixels).map(|img| img.save(&self.path)) {
Some(Ok(())) => log::info!("Saved a screenshot to {}", self.path.display()),
other => log::error!("Couldn't save the screenshot: {other:?}"),
}
if self.exit {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
}
/// `artcraft_<token>.<ext>`, the extension taken from the URL.
fn file_name_for(item: &FeedItem) -> String {
let ext = item.full_url.rsplit('.').next().filter(|e| e.len() <= 4 && e.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(if item.kind == MediaKind::Video { "mp4" } else { "png" });
format!("artcraft_{}.{ext}", item.token)
}
/// The ArtCraft wordmark (the Tauri app's white-on-dark SVG), rasterized for the sidebar.
fn load_logo(ctx: &egui::Context) -> egui::TextureHandle {
const LOGO: &[u8] = include_bytes!("../../frontend/apps/artcraft/app/public/resources/logo/artcraft-logo-color-white.svg");
/// Twice the 20 pt the sidebar draws it at, for high-DPI screens.
const HEIGHT_PX: f32 = 48.0;
let image = resvg::usvg::Tree::from_data(LOGO, &resvg::usvg::Options::default()).ok().and_then(|tree| {
let scale = HEIGHT_PX / tree.size().height();
let (w, h) = ((tree.size().width() * scale).ceil() as u32, HEIGHT_PX as u32);
let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
Some(egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pixmap.data()))
});
ctx.load_texture("artcraft-logo", image.unwrap_or_else(|| egui::ColorImage::filled([1, 1], egui::Color32::TRANSPARENT)), egui::TextureOptions::LINEAR)
}
+153
View File
@@ -0,0 +1,153 @@
//! Images shown in the UI: CDN thumbnails and local previews, as GPU textures.
//!
//! Downloads run on the tokio runtime (a few at a time) and decode off the UI thread; the UI
//! uploads finished images in [`MediaCache::poll`]. Textures nobody has drawn for a while are
//! dropped once the cache grows past its budget, so long feeds don't hold every image forever.
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use egui::{ColorImage, TextureHandle, TextureOptions};
use log::warn;
use tokio::runtime::Handle;
use tokio::sync::Semaphore;
/// How many images may download at once.
const MAX_CONCURRENT_DOWNLOADS: usize = 6;
/// Longest side of a decoded image; larger ones are downscaled before upload.
const MAX_DECODED_SIDE: u32 = 2048;
/// Textures kept even when unused (roughly two screens of thumbnails).
const SOFT_CAPACITY: usize = 160;
/// Frames an image may go undrawn before it can be evicted.
const EVICT_AFTER_FRAMES: u64 = 600;
pub struct MediaCache {
rt: Handle,
http: reqwest::Client,
limiter: Arc<Semaphore>,
tx: Sender<Loaded>,
rx: Receiver<Loaded>,
entries: HashMap<String, Entry>,
frame: u64,
}
/// What [`MediaCache::get`] knows about an image.
pub enum Lookup {
Loading,
Ready(TextureHandle),
Failed,
}
enum Entry {
Loading { last_used: u64 },
Ready { texture: TextureHandle, last_used: u64 },
Failed,
}
struct Loaded {
key: String,
image: Option<ColorImage>,
}
impl MediaCache {
pub fn new(rt: Handle, http: reqwest::Client) -> Self {
let (tx, rx) = channel();
Self { rt, http, limiter: Arc::new(Semaphore::new(MAX_CONCURRENT_DOWNLOADS)), tx, rx, entries: HashMap::new(), frame: 0 }
}
/// The texture for `url`, starting its download the first time it's asked for.
pub fn get(&mut self, ctx: &egui::Context, url: &str) -> Lookup {
let frame = self.frame;
match self.entries.get_mut(url) {
Some(Entry::Ready { texture, last_used }) => {
*last_used = frame;
Lookup::Ready(texture.clone())
},
Some(Entry::Loading { last_used }) => {
*last_used = frame;
Lookup::Loading
},
Some(Entry::Failed) => Lookup::Failed,
None => {
self.entries.insert(url.to_owned(), Entry::Loading { last_used: frame });
self.spawn_download(ctx, url.to_owned());
Lookup::Loading
},
}
}
/// Decodes `bytes` off the UI thread and registers the result under `key`.
pub fn insert_bytes(&mut self, ctx: &egui::Context, key: &str, bytes: Arc<[u8]>) {
if self.entries.contains_key(key) {
return;
}
self.entries.insert(key.to_owned(), Entry::Loading { last_used: self.frame });
let (tx, ctx, key) = (self.tx.clone(), ctx.clone(), key.to_owned());
self.rt.spawn_blocking(move || {
let image = decode(&bytes);
let _ = tx.send(Loaded { key, image });
ctx.request_repaint();
});
}
/// Uploads finished downloads and evicts stale textures. Call once per frame.
pub fn poll(&mut self, ctx: &egui::Context) {
self.frame += 1;
while let Ok(Loaded { key, image }) = self.rx.try_recv() {
// Dropped while loading (evicted): don't resurrect it.
let Some(entry) = self.entries.get_mut(&key) else {
continue;
};
*entry = match image {
Some(image) => Entry::Ready { texture: ctx.load_texture(&key, image, TextureOptions::LINEAR), last_used: self.frame },
None => Entry::Failed,
};
}
if self.entries.len() > SOFT_CAPACITY {
let frame = self.frame;
self.entries.retain(|_, e| match e {
Entry::Ready { last_used, .. } | Entry::Loading { last_used } => frame.saturating_sub(*last_used) < EVICT_AFTER_FRAMES,
Entry::Failed => true,
});
}
}
fn spawn_download(&self, ctx: &egui::Context, url: String) {
let (http, limiter, tx, ctx) = (self.http.clone(), self.limiter.clone(), self.tx.clone(), ctx.clone());
self.rt.spawn(async move {
let image = match limiter.acquire_owned().await {
Ok(_permit) => download(&http, &url).await,
Err(_) => None,
};
let _ = tx.send(Loaded { key: url, image });
ctx.request_repaint();
});
}
}
async fn download(http: &reqwest::Client, url: &str) -> Option<ColorImage> {
let response = match http.get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(response) => response,
Err(err) => {
warn!("Image download failed for {url}: {err}");
return None;
},
};
let bytes = response.bytes().await.ok()?;
tokio::task::spawn_blocking(move || decode(&bytes)).await.ok().flatten()
}
fn decode(bytes: &[u8]) -> Option<ColorImage> {
let image = match image::load_from_memory(bytes) {
Ok(image) => image,
Err(err) => {
warn!("Image decode failed: {err}");
return None;
},
};
let image = if image.width().max(image.height()) > MAX_DECODED_SIDE { image.thumbnail(MAX_DECODED_SIDE, MAX_DECODED_SIDE) } else { image };
let rgba = image.to_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
Some(ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()))
}
+734
View File
@@ -0,0 +1,734 @@
//! Everything that talks to the ArtCraft API. Calls run on a tokio runtime and report back as
//! [`Event`]s, which the UI drains once per frame; nothing here blocks the UI thread.
pub mod media_cache;
pub mod session;
pub mod wire;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use artcraft_api_defs::users::login_challenges::LoginChallengeState;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::credits::get_session_credits::get_session_credits;
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use artcraft_client::endpoints::media_files::upload_image_media_file_from_bytes::{ImageType, UploadImageBytesArgs, upload_image_media_file_from_bytes};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::{UploadImageFromFileArgs, upload_image_media_file_from_file};
use artcraft_client::endpoints::media_files::upload_video_media_file_from_file::{UploadVideoFromFileArgs, upload_video_media_file_from_file};
use artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::{OmniGenListImageModelsArgs, omni_gen_list_image_models};
use artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::{OmniGenListVideoModelsArgs, omni_gen_list_video_models};
use artcraft_client::endpoints::users::password_login::PasswordLoginRequest;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use artcraft_client::utils::api_host::ApiHost;
use artcraft_client::utils::basic_json_post_request::basic_json_post_request;
use artcraft_client::utils::login_challenge_client::{LoginChallengeClient, LoginChallengeClientError};
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, warn};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use tokens::tokens::media_files::MediaFileToken;
use crate::feed::types::MediaKind;
use crate::models::{self, ModelInfo};
use session::Credentials;
use wire::{BatchMedia, MediaFile, PromptResponse, SessionInfo, SessionJobs, SessionUser, UploadResponse, UserMediaList};
/// Items per library page (`useGalleryData`'s `PAGE_SIZE`).
pub const LIBRARY_PAGE_SIZE: u32 = 40;
/// Model listings are retried a few times before giving up (`list_*_models_command`).
const MODEL_LIST_ATTEMPTS: u32 = 3;
const MODEL_LIST_BACKOFF: Duration = Duration::from_millis(250);
/// The share link base (`SHARE_URL_BASE`).
pub const SHARE_URL_BASE: &str = "https://getartcraft.com/media/";
/// Sent on every request (`artcraft_client_identity::ARTCRAFT_DESKTOP_USER_AGENT`).
const USER_AGENT: &str = "storyteller-client/1.0";
/// Which media an operation is for (OmniGen's modality in the URL).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Modality {
Image,
Video,
}
impl Modality {
fn path(self) -> &'static str {
match self {
Modality::Image => "image",
Modality::Video => "video",
}
}
}
impl From<MediaKind> for Modality {
fn from(kind: MediaKind) -> Self {
match kind {
MediaKind::Image => Modality::Image,
MediaKind::Video => Modality::Video,
}
}
}
/// A result coming back from the backend.
pub enum Event {
/// The session check finished: `Some` when signed in.
Session(Option<SessionUser>),
LoginFailed(String),
/// A browser sign-in started: show the code while we wait.
DeviceLogin {
url: String,
code: String,
},
Credits(u64),
Models(Modality, Result<Vec<ModelInfo>, String>),
/// A cost estimate for the request identified by `key`.
Cost {
key: String,
credits: Option<u64>,
},
Enqueued {
modality: Modality,
job_tokens: Vec<String>,
meta: EnqueueMeta,
},
EnqueueFailed {
modality: Modality,
message: String,
},
Uploaded {
ref_id: u64,
result: Result<UploadedMedia, String>,
},
Jobs(Vec<wire::Job>),
LibraryPage {
key: String,
page: u32,
items: Vec<MediaFile>,
total_pages: u32,
},
LibraryFailed {
key: String,
message: String,
},
Batch {
job_token: String,
items: Vec<MediaFile>,
},
Prompt(wire::Prompt),
Deleted(String),
Saved(PathBuf),
Toast {
error: bool,
message: String,
},
}
/// What the UI remembers about a request it just sent, to label the pending card.
#[derive(Clone, Debug)]
pub struct EnqueueMeta {
pub prompt: String,
pub model_id: String,
pub batch_count: u32,
pub ref_image: Option<String>,
}
/// A finished upload: the media token, plus what the server knows about the file.
#[derive(Clone, Debug)]
pub struct UploadedMedia {
pub token: String,
pub duration_secs: f32,
pub thumbnail: Option<String>,
pub full_url: Option<String>,
}
pub struct Backend {
rt: tokio::runtime::Runtime,
ctx: egui::Context,
tx: Sender<Event>,
rx: Receiver<Event>,
host: ApiHost,
root: PathBuf,
creds: Arc<RwLock<Option<Credentials>>>,
http: reqwest::Client,
device_login_cancel: Arc<AtomicBool>,
}
impl Backend {
pub fn new(ctx: egui::Context) -> Self {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(4).thread_name("artcraft-io").enable_all().build().expect("tokio runtime");
let (tx, rx) = channel();
let root = session::data_root();
let host = session::api_host(&root);
let creds = session::load(&root);
let http = reqwest::Client::builder().user_agent(USER_AGENT).gzip(true).timeout(Duration::from_secs(60)).build().unwrap_or_default();
Self { rt, ctx, tx, rx, host, root, creds: Arc::new(RwLock::new(creds)), http, device_login_cancel: Arc::new(AtomicBool::new(false)) }
}
pub fn runtime(&self) -> tokio::runtime::Handle {
self.rt.handle().clone()
}
/// A plain HTTP client for CDN downloads (thumbnails, files).
pub fn http(&self) -> reqwest::Client {
self.http.clone()
}
pub fn data_root(&self) -> &Path {
&self.root
}
/// Events that arrived since the last call.
pub fn drain(&self) -> Vec<Event> {
self.rx.try_iter().collect()
}
// --- Session -----------------------------------------------------------------------------
/// Checks who's signed in (`GET /v1/session`), then fetches their credits.
pub fn refresh_session(&self) {
let api = self.api();
self.spawn(move |tx| async move {
if api.creds.is_none() {
let _ = tx.send(Event::Session(None));
return;
}
match api.get_json::<SessionInfo>("/v1/session").await {
Ok(info) if info.logged_in => {
let _ = tx.send(Event::Session(info.user));
api.send_credits(&tx).await;
},
Ok(_) => {
let _ = tx.send(Event::Session(None));
},
Err(err) => {
warn!("Session check failed: {err}");
let _ = tx.send(Event::Session(None));
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't reach ArtCraft: {err}") });
},
}
});
}
pub fn refresh_credits(&self) {
let api = self.api();
self.spawn(move |tx| async move { api.send_credits(&tx).await });
}
/// Signs in with a username (or email) and password (`POST /v1/login`).
pub fn login_with_password(&self, username_or_email: String, password: String) {
let (host, root, creds, ctx) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.ctx.clone());
let this = self.handle();
self.spawn(move |tx| async move {
let result = async {
let client = LoginChallengeClient::new(&host).map_err(login_error)?;
let (response, set_cookie) = client.password_login(&PasswordLoginRequest { username_or_email, password }).await.map_err(login_error)?;
response.signed_session.filter(|s| !s.is_empty()).or_else(|| set_cookie.as_deref().and_then(session_from_set_cookie)).ok_or_else(|| "The server didn't return a session.".to_owned())
}
.await;
match result {
Ok(session) => {
store_session(&root, &creds, session);
this.refresh_session();
},
Err(message) => {
let _ = tx.send(Event::LoginFailed(message));
},
}
ctx.request_repaint();
});
}
/// Signs in through the browser: shows a code, opens the approval page, and waits.
pub fn login_with_browser(&self) {
self.device_login_cancel.store(false, Ordering::Relaxed);
let (host, root, creds, cancel) = (self.host.clone(), self.root.clone(), self.creds.clone(), self.device_login_cancel.clone());
let this = self.handle();
self.spawn(move |tx| async move {
let client = match LoginChallengeClient::new(&host) {
Ok(c) => c,
Err(err) => {
let _ = tx.send(Event::LoginFailed(login_error(err)));
return;
},
};
let challenge = match client.create().await {
Ok(c) => c,
Err(err) => {
let _ = tx.send(Event::LoginFailed(login_error(err)));
return;
},
};
let _ = tx.send(Event::DeviceLogin { url: challenge.verification_url.clone(), code: challenge.confirmation_code.clone() });
if let Err(err) = open::that(&challenge.verification_url) {
warn!("Couldn't open the browser: {err}");
}
let interval = Duration::from_secs(u64::from(challenge.poll_interval_seconds.max(1)));
loop {
tokio::time::sleep(interval).await;
if cancel.load(Ordering::Relaxed) {
return;
}
if chrono::Utc::now() > challenge.expires_at {
let _ = tx.send(Event::LoginFailed("The sign-in code expired. Please try again.".to_owned()));
return;
}
match client.poll(&challenge.device_token).await {
Ok(result) => {
let signed = result.response.maybe_signed_session.or_else(|| result.session_set_cookie.as_deref().and_then(session_from_set_cookie));
if let Some(session) = signed {
store_session(&root, &creds, session);
this.refresh_session();
return;
}
if result.response.status == LoginChallengeState::Failed {
let _ = tx.send(Event::LoginFailed("Sign-in was declined or failed.".to_owned()));
return;
}
},
Err(err) => warn!("Login poll failed: {}", login_error(err)),
}
}
});
}
pub fn cancel_browser_login(&self) {
self.device_login_cancel.store(true, Ordering::Relaxed);
}
/// Signs out on the server (best effort) and forgets the session here.
pub fn logout(&self) {
let api = self.api();
let (root, creds) = (self.root.clone(), self.creds.clone());
if let Ok(mut c) = creds.write() {
*c = None;
}
session::delete(&root);
self.spawn(move |tx| async move {
if let Some(set) = api.creds.as_ref() {
let result: Result<Value, _> = basic_json_post_request(&api.host, "/v1/logout", Some(set), serde_json::json!({})).await;
if let Err(err) = result {
warn!("Logout request failed: {err}");
}
}
let _ = tx.send(Event::Session(None));
});
}
// --- Models and costs --------------------------------------------------------------------
/// Fetches the OmniGen model listing for `modality`.
pub fn load_models(&self, modality: Modality) {
let host = self.host.clone();
self.spawn(move |tx| async move {
let mut last_err = String::new();
for attempt in 0..MODEL_LIST_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(MODEL_LIST_BACKOFF).await;
}
let result = match modality {
Modality::Image => omni_gen_list_image_models(OmniGenListImageModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::image_model).collect::<Vec<_>>()),
Modality::Video => omni_gen_list_video_models(OmniGenListVideoModelsArgs { api_host: &host, maybe_creds: None, provider: None }).await.map(|r| r.models.iter().map(models::video_model).collect::<Vec<_>>()),
};
match result {
Ok(models) => {
let _ = tx.send(Event::Models(modality, Ok(models)));
return;
},
Err(err) => last_err = api_message(&err),
}
}
error!("Listing {modality:?} models failed: {last_err}");
let _ = tx.send(Event::Models(modality, Err(last_err)));
});
}
/// Asks what a request would cost (`POST /v1/omni_gen/cost/{modality}`).
pub fn estimate_cost(&self, modality: Modality, key: String, fields: Map<String, Value>) {
let api = self.api();
self.spawn(move |tx| async move {
let path = format!("/v1/omni_gen/cost/{}", modality.path());
let credits = match basic_json_post_request::<_, Value>(&api.host, &path, api.creds.as_ref(), fields).await {
Ok(response) => response.get("cost_in_credits").and_then(Value::as_u64),
Err(err) => {
warn!("Cost estimate failed: {}", api_message(&err));
None
},
};
let _ = tx.send(Event::Cost { key, credits });
});
}
/// Starts a generation (`POST /v1/omni_gen/generate/{modality}`).
pub fn generate(&self, modality: Modality, mut fields: Map<String, Value>, meta: EnqueueMeta) {
let api = self.api();
self.spawn(move |tx| async move {
let Some(creds) = api.creds.as_ref() else {
let _ = tx.send(Event::EnqueueFailed { modality, message: "Please sign in to generate.".to_owned() });
return;
};
fields.entry("idempotency_token").or_insert_with(|| Value::String(uuid::Uuid::new_v4().to_string()));
let path = format!("/v1/omni_gen/generate/{}", modality.path());
match basic_json_post_request::<_, Value>(&api.host, &path, Some(creds), fields).await {
Ok(response) => {
let tokens = job_tokens(&response);
if tokens.is_empty() {
let _ = tx.send(Event::EnqueueFailed { modality, message: "The server didn't start a job.".to_owned() });
} else {
let _ = tx.send(Event::Enqueued { modality, job_tokens: tokens, meta });
}
api.send_credits(&tx).await;
},
Err(err) => {
let _ = tx.send(Event::EnqueueFailed { modality, message: api_message(&err) });
},
}
});
}
// --- Uploads -----------------------------------------------------------------------------
/// Uploads a reference file from disk; images, MP4 videos and audio.
pub fn upload_file(&self, ref_id: u64, kind: crate::prompt_box::refs::RefKind, path: PathBuf) {
use crate::prompt_box::refs::RefKind;
let api = self.api();
self.spawn(move |tx| async move {
let token = match kind {
RefKind::Image => upload_image_media_file_from_file(UploadImageFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, is_intermediate_system_file: true, maybe_prompt_token: None, maybe_generation_provider: None, maybe_batch_token: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_message(&e)),
RefKind::Video => upload_video_media_file_from_file(UploadVideoFromFileArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), path: &path, maybe_prompt_token: None, maybe_generation_provider: None }).await.map(|r| r.media_file_token.to_string()).map_err(|e| api_error_message(&e)),
RefKind::Audio => api.upload_audio(&path).await,
};
let result = match token {
Ok(token) => Ok(api.describe_upload(token).await),
Err(message) => Err(message),
};
let _ = tx.send(Event::Uploaded { ref_id, result });
});
}
/// Uploads a pasted image (PNG bytes).
pub fn upload_png(&self, ref_id: u64, png: Vec<u8>) {
let api = self.api();
self.spawn(move |tx| async move {
let result = upload_image_media_file_from_bytes(UploadImageBytesArgs { api_host: &api.host, maybe_creds: api.creds.as_ref(), image_bytes: png, image_type: ImageType::Png, is_intermediate_system_file: true, maybe_generation_provider: None }).await;
let result = match result {
Ok(r) => Ok(api.describe_upload(r.media_file_token.to_string()).await),
Err(err) => Err(api_message(&err)),
};
let _ = tx.send(Event::Uploaded { ref_id, result });
});
}
// --- Feed --------------------------------------------------------------------------------
/// Polls the session's recent jobs (`GET /v1/jobs/session`).
pub fn poll_jobs(&self) {
let api = self.api();
if api.creds.is_none() {
return;
}
self.spawn(move |tx| async move {
match api.get_json::<SessionJobs>("/v1/jobs/session").await {
Ok(jobs) => {
let _ = tx.send(Event::Jobs(jobs.jobs));
},
Err(err) => warn!("Polling jobs failed: {err}"),
}
});
}
/// Loads one page of the user's library, filtered to `classes` (e.g. `image`, `video`).
pub fn load_library(&self, key: String, username: String, classes: &'static str, page: u32) {
let api = self.api();
self.spawn(move |tx| async move {
let path = format!("/v1/media_files/list/user/{username}?filter_media_classes={classes}&page_size={LIBRARY_PAGE_SIZE}&page_index={page}");
match api.get_json::<UserMediaList>(&path).await {
Ok(list) => {
let total_pages = list.pagination.map_or(0, |p| p.total_page_count);
let items = list.results.into_iter().filter(|m| m.media_type != "scene_json" && m.origin_category != "upload").collect();
let _ = tx.send(Event::LibraryPage { key, page, items, total_pages });
},
Err(message) => {
let _ = tx.send(Event::LibraryFailed { key, message });
},
}
});
}
/// Lists every file a batch job produced.
pub fn load_batch(&self, job_token: String, batch_token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match api.get_json::<BatchMedia>(&format!("/v1/media_files/batch_gen_redux/{batch_token}")).await {
Ok(batch) => {
let _ = tx.send(Event::Batch { job_token, items: batch.media_files });
},
Err(err) => warn!("Loading batch {batch_token} failed: {err}"),
}
});
}
/// Fetches the prompt record behind a generation (for Recreate and the list view).
pub fn load_prompt(&self, prompt_token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match api.get_json::<PromptResponse>(&format!("/v1/prompts/{prompt_token}")).await {
Ok(r) if r.success => {
let _ = tx.send(Event::Prompt(r.prompt));
},
Ok(_) => warn!("Prompt {prompt_token} not found"),
Err(err) => warn!("Loading prompt {prompt_token} failed: {err}"),
}
});
}
pub fn delete_media(&self, token: String) {
let api = self.api();
self.spawn(move |tx| async move {
match delete_media_file(&api.host, api.creds.as_ref(), &MediaFileToken::new(token.clone())).await {
Ok(_) => {
let _ = tx.send(Event::Deleted(token));
let _ = tx.send(Event::Toast { error: false, message: "File deleted.".to_owned() });
},
Err(err) => {
let _ = tx.send(Event::Toast { error: true, message: format!("Couldn't delete: {}", api_message(&err)) });
},
}
});
}
/// Downloads `url` to `path`.
pub fn save_url(&self, url: String, path: PathBuf) {
let http = self.http.clone();
self.spawn(move |tx| async move {
let result = async {
let bytes = http.get(&url).send().await.and_then(|r| r.error_for_status()).map_err(|e| e.to_string())?.bytes().await.map_err(|e| e.to_string())?;
tokio::fs::write(&path, &bytes).await.map_err(|e| e.to_string())
}
.await;
match result {
Ok(()) => {
let _ = tx.send(Event::Saved(path));
},
Err(err) => {
let _ = tx.send(Event::Toast { error: true, message: format!("File download failed: {err}") });
},
}
});
}
// --- Plumbing ----------------------------------------------------------------------------
fn api(&self) -> Api {
let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set));
Api { host: self.host.clone(), creds, http: self.http.clone() }
}
/// A cheap handle for tasks that need to call back into the backend.
fn handle(&self) -> BackendHandle {
BackendHandle { host: self.host.clone(), creds: self.creds.clone(), http: self.http.clone(), tx: self.tx.clone(), ctx: self.ctx.clone(), rt: self.rt.handle().clone() }
}
fn spawn<F, Fut>(&self, task: F)
where
F: FnOnce(Sender<Event>) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let (tx, ctx) = (self.tx.clone(), self.ctx.clone());
self.rt.spawn(async move {
task(tx).await;
ctx.request_repaint();
});
}
}
/// Enough of the backend to refresh the session from inside a task.
#[derive(Clone)]
struct BackendHandle {
host: ApiHost,
creds: Arc<RwLock<Option<Credentials>>>,
http: reqwest::Client,
tx: Sender<Event>,
ctx: egui::Context,
rt: tokio::runtime::Handle,
}
impl BackendHandle {
fn refresh_session(&self) {
let creds = self.creds.read().ok().and_then(|c| c.as_ref().map(Credentials::to_set));
let api = Api { host: self.host.clone(), creds, http: self.http.clone() };
let (tx, ctx) = (self.tx.clone(), self.ctx.clone());
self.rt.spawn(async move {
match api.get_json::<SessionInfo>("/v1/session").await {
Ok(info) if info.logged_in => {
let _ = tx.send(Event::Session(info.user));
api.send_credits(&tx).await;
},
Ok(_) => {
let _ = tx.send(Event::LoginFailed("The session wasn't accepted. Please try again.".to_owned()));
},
Err(err) => {
let _ = tx.send(Event::LoginFailed(err));
},
}
ctx.request_repaint();
});
}
}
/// One request's worth of host, credentials and HTTP client.
struct Api {
host: ApiHost,
creds: Option<StorytellerCredentialSet>,
http: reqwest::Client,
}
impl Api {
/// A GET with the desktop app's identity headers and cookies, decoded leniently.
async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, String> {
let url = format!("{}{path}", self.host.to_api_hostname_and_scheme());
let mut request = self.http.get(&url).header("Accept", "application/json").header("Origin", self.host.request_origin());
if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) {
request = request.header("Cookie", cookie);
}
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}")));
}
serde_json::from_str(&body).map_err(|e| format!("Unexpected response from {path}: {e}"))
}
async fn send_credits(&self, tx: &Sender<Event>) {
match get_session_credits(&self.host, self.creds.as_ref(), PaymentsNamespace::Artcraft).await {
Ok(c) => {
let _ = tx.send(Event::Credits(c.sum_total_credits));
},
Err(err) => warn!("Credits check failed: {}", api_message(&err)),
}
}
/// `POST /v1/media_files/upload/audio` (no client function for audio yet).
async fn upload_audio(&self, path: &Path) -> Result<String, String> {
let bytes = tokio::fs::read(path).await.map_err(|e| e.to_string())?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("audio").to_owned();
let form = reqwest::multipart::Form::new().text("uuid_idempotency_token", uuid::Uuid::new_v4().to_string()).text("maybe_visibility", "public").part("file", reqwest::multipart::Part::bytes(bytes).file_name(name));
let url = format!("{}/v1/media_files/upload/audio", self.host.to_api_hostname_and_scheme());
let mut request = self.http.post(url).header("Accept", "application/json").header("Origin", self.host.request_origin()).multipart(form);
if let Some(cookie) = self.creds.as_ref().and_then(StorytellerCredentialSet::maybe_as_cookie_header) {
request = request.header("Cookie", cookie);
}
let response = request.send().await.map_err(|e| e.to_string())?;
let status = response.status();
let body = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(body_message(&body).unwrap_or_else(|| format!("HTTP {status}")));
}
let upload: UploadResponse = serde_json::from_str(&body).map_err(|e| e.to_string())?;
if upload.media_file_token.is_empty() {
return Err("The server didn't return a media token.".to_owned());
}
Ok(upload.media_file_token)
}
/// Looks up an uploaded file's duration and links (best effort).
async fn describe_upload(&self, token: String) -> UploadedMedia {
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct FileResponse {
media_file: MediaFile,
}
let info = self.get_json::<FileResponse>(&format!("/v1/media_files/file/{token}")).await.ok();
let file = info.map(|r| r.media_file);
UploadedMedia { duration_secs: file.as_ref().and_then(|f| f.maybe_duration_millis).map_or(0.0, |ms| ms as f32 / 1000.0), thumbnail: file.as_ref().and_then(|f| f.media_links.thumbnail(256)), full_url: file.as_ref().map(|f| f.media_links.cdn_url.clone()).filter(|u| !u.is_empty()), token }
}
}
fn store_session(root: &Path, creds: &Arc<RwLock<Option<Credentials>>>, session: String) {
let visitor = creds.read().ok().and_then(|c| c.as_ref().and_then(|c| c.visitor.clone()));
let new = Credentials { session, visitor };
session::save(root, &new);
if let Ok(mut c) = creds.write() {
*c = Some(new);
}
}
/// The `session` value out of a raw `Set-Cookie` header.
fn session_from_set_cookie(header: &str) -> Option<String> {
cookie::Cookie::parse(header).ok().filter(|c| c.name() == "session").map(|c| c.value().to_owned())
}
/// All job tokens a generate call created (`inference_job_token` plus `all_job_tokens`).
fn job_tokens(response: &Value) -> Vec<String> {
let mut tokens: Vec<String> = response.get("inference_job_token").and_then(Value::as_str).map(str::to_owned).into_iter().collect();
for t in response.get("all_job_tokens").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {
if !tokens.iter().any(|x| x == t) {
tokens.push(t.to_owned());
}
}
tokens
}
/// The server's `error_message` (or `message`) from an error body.
fn body_message(body: &str) -> Option<String> {
let v: Value = serde_json::from_str(body).ok()?;
v.get("error_message").or_else(|| v.get("message")).and_then(Value::as_str).map(str::to_owned)
}
/// A user-facing message for a client error.
pub fn api_message(err: &StorytellerError) -> String {
match err {
StorytellerError::Api(api) => api_error_message(api),
StorytellerError::Client(client) => format!("{client:?}"),
}
}
fn api_error_message(err: &ApiError) -> String {
let body = match err {
ApiError::InvalidRequest(b) | ApiError::Unauthorized(b) | ApiError::PaymentRequired(b) | ApiError::Forbidden(b) | ApiError::NotFound(b) | ApiError::TooManyRequests(b) => Some(b.as_str()),
ApiError::InternalServerError { body, .. } | ApiError::UncategorizedBadResponseWithStatusAndBody { body, .. } => Some(body.as_str()),
_ => None,
};
let fallback = match err {
ApiError::Unauthorized(_) | ApiError::Forbidden(_) => "Please sign in to ArtCraft.".to_owned(),
ApiError::PaymentRequired(_) => "You don't have enough credits for this.".to_owned(),
ApiError::TooManyRequests(_) => "Too many requests. Please wait a moment.".to_owned(),
ApiError::NetworkError(_) | ApiError::Timeout(_) => "Couldn't reach ArtCraft. Check your connection.".to_owned(),
other => format!("{other:?}"),
};
body.and_then(body_message).unwrap_or(fallback)
}
fn login_error(err: LoginChallengeClientError) -> String {
err.maybe_rejection.and_then(|r| r.error_message).unwrap_or_else(|| err.message.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collects_every_job_token_once() {
let response = serde_json::json!({ "success": true, "inference_job_token": "j1", "all_job_tokens": ["j1", "j2", "j3"] });
assert_eq!(job_tokens(&response), ["j1", "j2", "j3"]);
assert_eq!(job_tokens(&serde_json::json!({ "inference_job_token": "only" })), ["only"]);
}
#[test]
fn reads_the_servers_error_message() {
assert_eq!(body_message(r#"{"success":false,"error_message":"Not enough credits"}"#).as_deref(), Some("Not enough credits"));
assert_eq!(body_message(r#"{"message":"Origin is not allowed"}"#).as_deref(), Some("Origin is not allowed"));
assert_eq!(body_message("<html>502</html>"), None);
assert_eq!(api_error_message(&ApiError::PaymentRequired("{}".into())), "You don't have enough credits for this.");
}
#[test]
fn takes_the_session_from_set_cookie() {
assert_eq!(session_from_set_cookie("session=abc.def; Path=/; Secure").as_deref(), Some("abc.def"));
assert_eq!(session_from_set_cookie("visitor=x; Path=/"), None);
}
}
+118
View File
@@ -0,0 +1,118 @@
//! Where the signed-in session lives: our own file under `~/Artcraft/credentials`, seeded on
//! first run from the Tauri app's cookie jar so an existing ArtCraft login carries over.
use std::fs;
use std::path::{Path, PathBuf};
use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie;
use artcraft_client::utils::api_host::ApiHost;
use log::{info, warn};
use serde::{Deserialize, Serialize};
/// The Tauri app's identifier: its cookie jar lives in `<cache dir>/<identifier>/.cookies`.
const TAURI_APP_IDENTIFIER: &str = "ai.artcraft.app";
const TAURI_COOKIES_FILENAME: &str = ".cookies";
const CREDENTIALS_FILENAME: &str = "native_session.json";
/// The two ArtCraft cookies, as sent on every request (`Cookie: visitor=…; session=…`).
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct Credentials {
pub session: String,
#[serde(default)]
pub visitor: Option<String>,
}
impl Credentials {
pub fn to_set(&self) -> StorytellerCredentialSet {
StorytellerCredentialSet::initialize(self.visitor.clone().map(StorytellerAvtCookie::new), Some(StorytellerSessionCookie::new(self.session.clone())))
}
}
/// `~/Artcraft`, shared with the Tauri app (`AppDataRoot`).
pub fn data_root() -> PathBuf {
directories::UserDirs::new().map(|d| d.home_dir().to_path_buf()).unwrap_or_else(|| PathBuf::from(".")).join("Artcraft")
}
/// The API host from `~/Artcraft/settings/env_configs.json` (production unless it says
/// `"storyteller_host": "localhost"`).
pub fn api_host(root: &Path) -> ApiHost {
#[derive(Deserialize)]
struct EnvConfigs {
storyteller_host: Option<String>,
storyteller_port: Option<u32>,
}
let path = root.join("settings").join("env_configs.json");
let configs = fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str::<EnvConfigs>(&s).ok());
match configs {
Some(EnvConfigs { storyteller_host: Some(host), storyteller_port }) if host == "localhost" => ApiHost::Localhost { port: storyteller_port.unwrap_or(12345) },
_ => ApiHost::Storyteller,
}
}
/// The saved session, or the Tauri app's (imported and saved for next time).
pub fn load(root: &Path) -> Option<Credentials> {
let path = credentials_path(root);
if let Ok(text) = fs::read_to_string(&path) {
match serde_json::from_str::<Credentials>(&text) {
Ok(creds) if !creds.session.is_empty() => return Some(creds),
Ok(_) => {},
Err(err) => warn!("Ignoring unreadable {}: {err}", path.display()),
}
}
let imported = import_tauri_cookies()?;
info!("Imported the ArtCraft login from the desktop app's cookie jar");
save(root, &imported);
Some(imported)
}
pub fn save(root: &Path, creds: &Credentials) {
let path = credentials_path(root);
let result = path.parent().map_or(Ok(()), fs::create_dir_all).and_then(|_| fs::write(&path, serde_json::to_vec_pretty(creds).unwrap_or_default()));
if let Err(err) = result {
warn!("Couldn't save the session to {}: {err}", path.display());
}
}
pub fn delete(root: &Path) {
let path = credentials_path(root);
if let Err(err) = fs::remove_file(&path) {
if err.kind() != std::io::ErrorKind::NotFound {
warn!("Couldn't delete {}: {err}", path.display());
}
}
}
fn credentials_path(root: &Path) -> PathBuf {
root.join("credentials").join(CREDENTIALS_FILENAME)
}
/// Reads `session` and `visitor` for the ArtCraft API from the Tauri HTTP plugin's jar: a JSON
/// array of `cookie_store` cookies (`{"raw_cookie": "...", "domain": {...}, "expires": {...}}`).
fn import_tauri_cookies() -> Option<Credentials> {
let path = directories::BaseDirs::new()?.cache_dir().join(TAURI_APP_IDENTIFIER).join(TAURI_COOKIES_FILENAME);
let text = fs::read_to_string(&path).ok()?;
let cookies: Vec<serde_json::Value> = serde_json::from_str(&text).ok()?;
let now = chrono::Utc::now();
let mut creds = Credentials::default();
for entry in &cookies {
let for_api = entry.get("domain").map(|d| d.to_string()).is_some_and(|d| d.contains("storyteller.ai"));
let expired = entry.pointer("/expires/AtUtc").and_then(|v| v.as_str()).and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()).is_some_and(|t| t < now);
let Some(raw) = entry.get("raw_cookie").and_then(|v| v.as_str()) else {
continue;
};
if !for_api || expired {
continue;
}
let Ok(cookie) = cookie::Cookie::parse(raw) else {
continue;
};
match cookie.name() {
"session" => creds.session = cookie.value().to_owned(),
"visitor" => creds.visitor = Some(cookie.value().to_owned()),
_ => {},
}
}
(!creds.session.is_empty()).then_some(creds)
}
+249
View File
@@ -0,0 +1,249 @@
//! Lenient shapes for the API responses the app reads itself. Enums stay strings and every field
//! has a default, so a new server-side value can't break decoding (the typed client structs use
//! closed enums).
use serde::Deserialize;
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MediaLinks {
pub cdn_url: String,
pub maybe_thumbnail_template: Option<String>,
pub maybe_video_previews: Option<VideoPreviews>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct VideoPreviews {
pub still: String,
pub animated: String,
pub still_thumbnail_template: String,
pub animated_thumbnail_template: String,
}
impl MediaLinks {
/// A still thumbnail `width` pixels wide: the video still frame, the resized image, or the
/// original (`thumbnail-utils.ts`).
pub fn thumbnail(&self, width: u32) -> Option<String> {
let w = width.to_string();
if let Some(previews) = &self.maybe_video_previews {
if !previews.still_thumbnail_template.is_empty() {
return Some(previews.still_thumbnail_template.replace("{WIDTH}", &w));
}
if !previews.still.is_empty() {
return Some(previews.still.clone());
}
}
match &self.maybe_thumbnail_template {
Some(template) if !template.is_empty() => Some(template.replace("{WIDTH}", &w)),
_ if !self.cdn_url.is_empty() && self.maybe_video_previews.is_none() => Some(self.cdn_url.clone()),
_ => None,
}
}
}
/// `GET /v1/media_files/list/user/{username}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct UserMediaList {
pub success: bool,
pub results: Vec<MediaFile>,
pub pagination: Option<Pagination>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Pagination {
pub current: u32,
pub total_page_count: u32,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct MediaFile {
pub token: String,
pub media_class: Option<String>,
pub media_type: String,
pub origin_category: String,
pub maybe_batch_token: Option<String>,
pub maybe_prompt_token: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_origin_model_type: Option<String>,
pub maybe_duration_millis: Option<u64>,
pub media_links: MediaLinks,
pub created_at: String,
}
/// `GET /v1/media_files/batch_gen_redux/{token}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct BatchMedia {
pub success: bool,
pub media_files: Vec<MediaFile>,
}
/// `GET /v1/jobs/session`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionJobs {
pub success: bool,
pub jobs: Vec<Job>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct Job {
pub job_token: String,
pub request: JobRequest,
pub status: JobStatus,
pub maybe_result: Option<JobResult>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobRequest {
pub inference_category: String,
pub maybe_prompt_token: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_raw_inference_text: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobStatus {
pub status: String,
pub maybe_extra_status_description: Option<String>,
pub maybe_failure_category_updated: Option<String>,
pub maybe_failure_message: Option<String>,
pub progress_percentage: u8,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct JobResult {
pub entity_token: String,
pub maybe_batch_token: Option<String>,
pub media_links: MediaLinks,
}
/// Where a job is in its life.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobPhase {
Running,
Succeeded,
Failed,
}
impl JobStatus {
pub fn phase(&self) -> JobPhase {
match self.status.as_str() {
"pending" | "started" => JobPhase::Running,
"complete_success" => JobPhase::Succeeded,
_ => JobPhase::Failed,
}
}
/// The user-facing failure reason (`FAILURE_REASON_LABEL`).
pub fn failure_reason(&self) -> String {
let label = match self.maybe_failure_category_updated.as_deref().unwrap_or("") {
"rule_bans_user_image" => "Image violates content policy",
"rule_bans_user_image_with_faces" => "Images with faces are not allowed",
"rule_bans_user_text_prompt" => "Text prompt violates content policy",
"rule_bans_user_content" => "Content violates content policy",
"rule_bans_generated_video" => "Generated video flagged by content policy",
"rule_bans_generated_audio" => "Generated audio flagged by content policy",
"rule_bans_generated_content" => "Generated content flagged by content policy",
"unknown" => "An unknown error occurred",
_ if self.status.starts_with("cancelled") => "Cancelled",
_ => "Generation failed",
};
label.to_owned()
}
}
/// `GET /v1/prompts/{token}`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct PromptResponse {
pub success: bool,
pub prompt: Prompt,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct Prompt {
pub token: String,
pub maybe_positive_prompt: Option<String>,
pub maybe_negative_prompt: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_aspect_ratio: Option<String>,
pub maybe_resolution: Option<String>,
pub maybe_duration_seconds: Option<u16>,
pub maybe_generate_audio: Option<bool>,
pub maybe_batch_count: Option<u16>,
pub maybe_context_images: Option<Vec<ContextImage>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ContextImage {
pub media_token: String,
pub semantic: String,
pub media_links: MediaLinks,
}
/// `GET /v1/session` (only the parts the app shows).
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionInfo {
pub success: bool,
pub logged_in: bool,
pub user: Option<SessionUser>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct SessionUser {
pub username: String,
pub display_name: String,
pub email_gravatar_hash: String,
}
/// The generic `{success, media_file_token}` upload reply.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct UploadResponse {
pub success: bool,
pub media_file_token: String,
}
/// Parses an RFC 3339 timestamp into unix seconds (0 when absent or malformed).
pub fn unix_secs(timestamp: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(timestamp).map(|t| t.timestamp()).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thumbnails_prefer_the_video_still_then_the_resized_image() {
let video: MediaLinks = serde_json::from_value(serde_json::json!({
"cdn_url": "https://cdn/v.mp4",
"maybe_video_previews": { "still": "https://cdn/s.jpg", "animated": "https://cdn/a.webp", "still_thumbnail_template": "https://cdn/w={WIDTH}/s.jpg", "animated_thumbnail_template": "" }
}))
.unwrap();
assert_eq!(video.thumbnail(512).as_deref(), Some("https://cdn/w=512/s.jpg"));
let image: MediaLinks = serde_json::from_value(serde_json::json!({ "cdn_url": "https://cdn/i.png", "maybe_thumbnail_template": null })).unwrap();
assert_eq!(image.thumbnail(512).as_deref(), Some("https://cdn/i.png"));
}
#[test]
fn unknown_statuses_and_fields_still_decode() {
let job: Job = serde_json::from_value(serde_json::json!({ "job_token": "j", "status": { "status": "something_new" }, "brand_new_field": 1 })).unwrap();
assert_eq!(job.status.phase(), JobPhase::Failed);
assert_eq!(job.status.failure_reason(), "Generation failed");
}
}
+531
View File
@@ -0,0 +1,531 @@
//! The generation feed as a masonry grid (`GenerationGridView`) or a list (`GenerationListView`):
//! running jobs first-class alongside failures and finished media, newest first, loading more as
//! the user scrolls.
use std::collections::{HashMap, HashSet};
use egui::{Align2, Color32, CornerRadius, FontFamily, FontId, Id, Mesh, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob, pending_status, time_ago};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::{creator_icons, widgets};
/// `react-masonry-css` gutter and the cap on tall portraits (≈ 5:7).
const GAP: f32 = 8.0;
const MAX_RATIO: f32 = 1.4;
/// Start loading the next page this far before the end.
const LOAD_MORE_MARGIN: f32 = 400.0;
const LIST_THUMB: f32 = 100.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ViewMode {
#[default]
Grid,
List,
}
/// Something the user did in the feed.
#[derive(Clone, Debug, PartialEq)]
pub enum FeedAction {
Open(String),
Recreate(String),
MakeVideo(String),
Share(String),
Download(String),
CopyPrompt(String),
DismissFailed(String),
ToggleSelect(String),
LoadMore,
}
/// Looks up a model's display name and maker for the cards' model chip.
pub trait ModelNames {
fn display_name(&self, model_id: &str) -> String;
fn creator(&self, model_id: &str) -> String;
}
pub struct FeedView<'a> {
pub id: Id,
pub mode: ViewMode,
pub pending: &'a [PendingJob],
pub failed: &'a [FailedJob],
pub items: &'a [FeedItem],
pub has_more: bool,
pub loading: bool,
/// Items that are new this session (shown even before the library catches up).
pub selecting: bool,
pub selected: &'a HashSet<String>,
pub last_viewed: Option<&'a str>,
/// Prompt texts by prompt token (list view and pending cards).
pub prompts: &'a HashMap<String, String>,
pub make_video: bool,
pub bottom_padding: f32,
}
enum Entry<'a> {
Pending(&'a PendingJob),
Failed(&'a FailedJob),
Item(&'a FeedItem),
}
impl Entry<'_> {
fn time(&self) -> i64 {
match self {
Entry::Pending(j) => j.created_at,
Entry::Failed(j) => j.updated_at,
Entry::Item(i) => i.created_at,
}
}
}
/// Measured height/width ratios of loaded thumbnails, so cards keep their shape.
#[derive(Clone, Default)]
pub struct RatioCache(HashMap<String, f32>);
pub fn show(ui: &mut Ui, view: &FeedView<'_>, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames) -> Vec<FeedAction> {
let mut entries: Vec<Entry<'_>> = view.pending.iter().map(Entry::Pending).chain(view.failed.iter().map(Entry::Failed)).chain(view.items.iter().map(Entry::Item)).collect();
entries.sort_by_key(|e| std::cmp::Reverse(e.time()));
let mut actions = Vec::new();
let now = chrono::Utc::now().timestamp();
if view.pending.iter().any(|_| true) {
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
egui::ScrollArea::vertical().id_salt(view.id.with("scroll")).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let width = ui.available_width();
let origin = ui.min_rect().min;
let layout = match view.mode {
ViewMode::Grid => masonry(&entries, width, ratios),
ViewMode::List => list_layout(&entries, width),
};
let total = layout.iter().map(|r| r.bottom()).fold(0.0_f32, f32::max);
ui.allocate_exact_size(vec2(width, total + view.bottom_padding + if view.has_more { 64.0 } else { 0.0 }), Sense::hover());
for (entry, local) in entries.iter().zip(&layout) {
if !local.intersects(viewport.expand(200.0)) {
continue;
}
let rect = local.translate(origin.to_vec2());
match (entry, view.mode) {
(Entry::Pending(job), ViewMode::Grid) => pending_card(ui, rect, job, now, names),
(Entry::Failed(job), ViewMode::Grid) => failed_card(ui, view.id, rect, job, cache, names, &mut actions),
(Entry::Item(item), ViewMode::Grid) => item_card(ui, view, rect, item, cache, ratios, names, &mut actions),
(Entry::Pending(job), ViewMode::List) => pending_row(ui, rect, job, now, names),
(Entry::Failed(job), ViewMode::List) => failed_row(ui, view.id, rect, job, names, &mut actions),
(Entry::Item(item), ViewMode::List) => item_row(ui, view, rect, item, cache, names, now, &mut actions),
}
}
if view.has_more {
let spinner = Rect::from_center_size(origin + vec2(width / 2.0, total + 32.0), vec2(24.0, 24.0));
if view.loading {
icons::paint_spinner(ui, spinner, theme::MUTED);
}
if !view.loading && viewport.max.y + LOAD_MORE_MARGIN >= total {
actions.push(FeedAction::LoadMore);
}
}
});
actions
}
/// Number of columns for a width (`BREAKPOINT_COLS = {default: 4, 900: 3, 640: 2}`).
fn columns(width: f32) -> usize {
if width <= 640.0 {
2
} else if width <= 900.0 {
3
} else {
4
}
}
/// Places each entry in the shortest column, like `react-masonry-css`.
fn masonry(entries: &[Entry<'_>], width: f32, ratios: &RatioCache) -> Vec<Rect> {
let cols = columns(width);
let col_w = (width - GAP * (cols - 1) as f32) / cols as f32;
let mut heights = vec![0.0_f32; cols];
entries
.iter()
.map(|entry| {
let ratio = match entry {
Entry::Item(item) => ratios.0.get(&item.token).copied().unwrap_or(1.0).min(MAX_RATIO),
_ => 1.0,
};
let (col, y) = heights.iter().copied().enumerate().min_by(|a, b| a.1.total_cmp(&b.1)).unwrap_or((0, 0.0));
let rect = Rect::from_min_size(pos2(col as f32 * (col_w + GAP), y), vec2(col_w, col_w * ratio));
heights[col] = rect.bottom() + GAP;
rect
})
.collect()
}
/// One row per entry, centred at `max-w-5xl`.
fn list_layout(entries: &[Entry<'_>], width: f32) -> Vec<Rect> {
let w = width.min(1024.0);
let x = (width - w) / 2.0;
let row_h = LIST_THUMB + 24.0;
(0..entries.len()).map(|i| Rect::from_min_size(pos2(x, i as f32 * row_h), vec2(w, row_h))).collect()
}
fn pending_card(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) {
let p = ui.painter();
p.rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(8));
shimmer(ui, rect);
let (progress, time_label) = pending_status(job, now);
if job.batch_count > 1 {
let banner = Rect::from_min_max(rect.min + vec2(8.0, 8.0), pos2(rect.right() - 8.0, rect.top() + 34.0));
p.rect_filled(banner, 0.0, Color32::from_black_alpha(153));
p.text(banner.center(), Align2::CENTER_CENTER, format!("Generating {} {} \u{b7} Results may appear one at a time", job.batch_count, job.kind.plural()), FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.7));
}
let c = rect.center() - vec2(0.0, 18.0);
icons::paint_spinner(ui, Rect::from_center_size(c, vec2(26.0, 26.0)), theme::fade(Color32::WHITE, 0.2));
let p = ui.painter();
p.text(c + vec2(0.0, 26.0), Align2::CENTER_CENTER, format!("{progress}%"), FontId::new(12.0, FontFamily::Monospace), theme::fade(Color32::WHITE, 0.4));
p.text(c + vec2(0.0, 42.0), Align2::CENTER_CENTER, time_label, FontId::new(10.0, FontFamily::Proportional), theme::fade(Color32::WHITE, 0.3));
let footer = bottom_gradient(ui, rect, 96.0, 178);
let text_rect = footer.shrink2(vec2(12.0, 10.0));
let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.8), text_rect.width(), 3);
let y = text_rect.bottom() - 24.0 - galley.size().y;
ui.painter().galley(pos2(text_rect.left(), y), galley, Color32::WHITE);
let model_id = job.model_id.as_deref().unwrap_or_default();
model_chip(ui, pos2(text_rect.left(), text_rect.bottom() - 15.0), model_id, names, 10.0, theme::fade(Color32::WHITE, 0.5));
widgets::progress_bar(ui.painter(), Rect::from_min_size(pos2(text_rect.left(), text_rect.bottom() - 2.0), vec2(text_rect.width(), 4.0)), progress as f32 / 100.0);
}
fn failed_card(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, cache: &mut MediaCache, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26));
if let Some(Lookup::Ready(t)) = job.ref_image.as_deref().map(|u| cache.get(ui.ctx(), u)) {
egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).tint(theme::fade(Color32::WHITE, 0.1)).paint_at(ui, rect);
}
let c = rect.center() - vec2(0.0, 20.0);
let p = ui.painter();
icons::paint(p, Rect::from_center_size(c - vec2(0.0, 18.0), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD);
p.text(c + vec2(0.0, 6.0), Align2::CENTER_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD);
if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) {
let galley = clamp_lines(ui, msg, 10.0, theme::fade(theme::BAD, 0.6), rect.width() - 32.0, 3);
ui.painter().galley(pos2(rect.center().x - galley.size().x / 2.0, c.y + 16.0), galley, Color32::WHITE);
}
let button = Rect::from_center_size(c + vec2(0.0, 62.0), vec2(84.0, 28.0));
let resp = ui.interact(button, id.with(("dismiss", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let p = ui.painter();
p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
let ink = if resp.hovered() { theme::fade(Color32::WHITE, 0.7) } else { theme::fade(Color32::WHITE, 0.5) };
icons::paint(p, Rect::from_center_size(button.left_center() + vec2(18.0, 0.0), vec2(12.0, 12.0)), Icon::X, ink);
p.text(button.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), ink);
if resp.clicked() {
actions.push(FeedAction::DismissFailed(job.job_token.clone()));
}
let footer = bottom_gradient(ui, rect, 56.0, 153);
let text = footer.shrink2(vec2(12.0, 10.0));
let galley = clamp_lines(ui, &job.prompt, 12.0, theme::fade(Color32::WHITE, 0.7), text.width(), 1);
ui.painter().galley(pos2(text.left(), text.bottom() - 30.0), galley, Color32::WHITE);
model_chip(ui, pos2(text.left(), text.bottom() - 12.0), job.model_id.as_deref().unwrap_or_default(), names, 10.0, theme::fade(Color32::WHITE, 0.4));
}
#[allow(clippy::too_many_arguments)]
fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, ratios: &mut RatioCache, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
let resp = ui.interact(rect, view.id.with(("item", &item.token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
ui.painter().rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4));
match item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
Some(Lookup::Ready(t)) => {
let size = t.size_vec2();
if size.x > 0.0 && !ratios.0.contains_key(&item.token) {
ratios.0.insert(item.token.clone(), size.y / size.x);
}
egui::Image::new(&t).uv(cover_uv_for(size, rect.size())).corner_radius(theme::RADIUS).paint_at(ui, rect);
},
Some(Lookup::Loading) => shimmer(ui, rect),
_ => icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(28.0, 28.0)), if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }, theme::FAINT),
}
if item.kind == MediaKind::Video {
let badge = Rect::from_center_size(rect.center(), vec2(40.0, 40.0));
ui.painter().circle_filled(badge.center(), 20.0, Color32::from_black_alpha(110));
icons::paint(ui.painter(), badge.shrink(12.0).translate(vec2(1.5, 0.0)), Icon::Play, theme::fade(Color32::WHITE, 0.9));
}
let selected = view.selected.contains(&item.token);
let ring = if selected {
Some(Stroke::new(2.0, theme::ACCENT_400))
} else if hovered {
Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)))
} else if view.last_viewed == Some(item.token.as_str()) {
Some(Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.5)))
} else {
None
};
if let Some(stroke) = ring {
ui.painter().rect_stroke(rect, theme::RADIUS, stroke, egui::StrokeKind::Inside);
}
if view.selecting {
let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(20.0, 20.0));
let (fill, border) = if selected { (theme::ACCENT_400, theme::ACCENT_400) } else { (Color32::from_black_alpha(102), theme::fade(Color32::WHITE, 0.6)) };
ui.painter().rect(chip, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside);
if selected {
icons::paint(ui.painter(), chip.shrink(4.0), Icon::Check, Color32::WHITE);
}
}
if view.last_viewed == Some(item.token.as_str()) {
let galley = widgets::caps(ui, "Last viewed", 10.0, theme::medium(), 0.5, theme::fade(Color32::WHITE, 0.8));
let badge = Rect::from_min_size(pos2(rect.right() - 8.0 - galley.size().x - 26.0, rect.top() + 8.0), vec2(galley.size().x + 26.0, 18.0));
let p = ui.painter();
p.rect_filled(badge, theme::RADIUS, Color32::from_black_alpha(178));
icons::paint(p, Rect::from_center_size(badge.left_center() + vec2(11.0, 0.0), vec2(11.0, 11.0)), Icon::Eye, theme::fade(Color32::WHITE, 0.8));
p.galley(pos2(badge.left() + 20.0, badge.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
}
let mut action_clicked = false;
// The pointer may be on an action button rather than the card itself.
if (hovered || ui.rect_contains_pointer(rect)) && !view.selecting {
let footer = bottom_gradient(ui, rect, 56.0, 178);
let chip_y = footer.bottom() - 18.0;
let mut x = footer.left() + 8.0;
x = chip(ui, pos2(x, chip_y), Some(if item.kind == MediaKind::Video { Icon::Video } else { Icon::Image }), None, item.kind.label());
if let Some(model) = item.model_id.as_deref() {
chip(ui, pos2(x + 6.0, chip_y), None, Some(&names.creator(model)), &truncate(&names.display_name(model), 16));
}
let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new();
if item.prompt_token.is_some() {
buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone())));
}
if view.make_video && item.kind == MediaKind::Image {
buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone())));
}
buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone())));
buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone())));
let pill = Rect::from_min_size(pos2(footer.right() - 8.0 - 8.0 - 28.0 * buttons.len() as f32, chip_y - 18.0), vec2(8.0 + 28.0 * buttons.len() as f32, 36.0));
ui.painter().rect_filled(pill, 0.0, Color32::from_black_alpha(153));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink(4.0)).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, action) in buttons {
if widgets::overlay_icon(&mut child, icon, tip).clicked() {
actions.push(action);
action_clicked = true;
}
}
}
if resp.clicked() && !action_clicked {
actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) });
}
}
fn pending_row(ui: &mut Ui, rect: Rect, job: &PendingJob, now: i64, names: &dyn ModelNames) {
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_white_alpha(8));
shimmer(ui, thumb);
icons::paint_spinner(ui, Rect::from_center_size(thumb.center(), vec2(22.0, 22.0)), theme::fade(Color32::WHITE, 0.25));
let (progress, label) = pending_status(job, now);
let x = thumb.right() + 16.0;
let w = rect.right() - x;
let prompt = if job.prompt.is_empty() { "Generating\u{2026}" } else { job.prompt.as_str() };
let galley = clamp_lines(ui, prompt, 14.0, theme::INK, w, 2);
let gh = galley.size().y;
ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK);
let meta_y = thumb.top() + gh + 14.0;
let mx = model_chip(ui, pos2(x, meta_y), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::MUTED);
if job.batch_count > 1 {
ui.painter().text(pos2(mx + 8.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {} {}", job.batch_count, job.kind.plural()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
}
let bar = Rect::from_min_size(pos2(x, meta_y + 16.0), vec2(w.min(320.0), 4.0));
widgets::progress_bar(ui.painter(), bar, progress as f32 / 100.0);
ui.painter().text(pos2(bar.right() + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("{progress}% \u{b7} {label}"), FontId::new(11.0, FontFamily::Monospace), theme::FAINT);
row_divider(ui, rect);
}
fn failed_row(ui: &mut Ui, id: Id, rect: Rect, job: &FailedJob, names: &dyn ModelNames, actions: &mut Vec<FeedAction>) {
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, Color32::from_rgba_unmultiplied(239, 68, 68, 26));
icons::paint(ui.painter(), Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), Icon::AlertCircle, theme::BAD);
let x = thumb.right() + 16.0;
let w = rect.right() - x - 100.0;
let galley = clamp_lines(ui, &job.prompt, 14.0, theme::INK, w, 2);
let gh = galley.size().y;
ui.painter().galley(pos2(x, thumb.top()), galley, theme::INK);
ui.painter().text(pos2(x, thumb.top() + gh + 14.0), Align2::LEFT_CENTER, &job.reason, FontId::new(12.0, theme::medium()), theme::BAD);
if let Some(msg) = job.message.as_deref().filter(|m| *m != job.reason) {
let g = clamp_lines(ui, msg, 11.0, theme::fade(theme::BAD, 0.6), w, 2);
ui.painter().galley(pos2(x, thumb.top() + gh + 24.0), g, Color32::WHITE);
}
model_chip(ui, pos2(x, thumb.bottom() - 8.0), job.model_id.as_deref().unwrap_or_default(), names, 12.0, theme::FAINT);
let button = Rect::from_min_size(pos2(rect.right() - 92.0, thumb.top()), vec2(92.0, 28.0));
let resp = ui.interact(button, id.with(("dismiss-row", &job.job_token)), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
ui.painter().rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
ui.painter().text(button.center(), Align2::CENTER_CENTER, "Dismiss", FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
if resp.clicked() {
actions.push(FeedAction::DismissFailed(job.job_token.clone()));
}
row_divider(ui, rect);
}
#[allow(clippy::too_many_arguments)]
fn item_row(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cache: &mut MediaCache, names: &dyn ModelNames, now: i64, actions: &mut Vec<FeedAction>) {
let resp = ui.interact(rect, view.id.with(("row", &item.token)), Sense::click());
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(rect.expand2(vec2(8.0, 0.0)), 0.0, Color32::from_white_alpha(6));
}
let thumb = Rect::from_min_size(rect.min + vec2(0.0, 12.0), vec2(LIST_THUMB, LIST_THUMB));
ui.painter().rect_filled(thumb, theme::RADIUS, theme::fade(theme::CONTROLS, 0.4));
if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
egui::Image::new(&t).uv(crate::prompt_box::deck::cover_uv(t.size_vec2())).corner_radius(theme::RADIUS).paint_at(ui, thumb);
}
if view.selected.contains(&item.token) {
ui.painter().rect_stroke(thumb, theme::RADIUS, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside);
}
let x = thumb.right() + 16.0;
let w = rect.right() - x - 140.0;
let prompt = item.prompt_token.as_deref().and_then(|t| view.prompts.get(t));
match prompt {
Some(text) => {
let g = clamp_lines(ui, text, 14.0, theme::INK, w, 3);
ui.painter().galley(pos2(x, thumb.top()), g, theme::INK);
},
None if item.prompt_token.is_some() => {
for (i, frac) in [1.0, 0.7].iter().enumerate() {
ui.painter().rect_filled(Rect::from_min_size(pos2(x, thumb.top() + 4.0 + i as f32 * 20.0), vec2(w * frac, 12.0)), CornerRadius::same(2), theme::WASH);
}
},
None => {
ui.painter().text(pos2(x, thumb.top() + 8.0), Align2::LEFT_CENTER, format!("{} Generation", item.kind.label()), FontId::new(14.0, FontFamily::Proportional), theme::MUTED);
},
}
let meta_y = thumb.bottom() - 10.0;
let mut mx = x;
if let Some(model) = item.model_id.as_deref() {
mx = model_chip(ui, pos2(x, meta_y), model, names, 12.0, theme::MUTED);
ui.painter().text(pos2(mx + 6.0, meta_y), Align2::LEFT_CENTER, format!("\u{b7} {}", item.kind.label()), FontId::new(12.0, FontFamily::Proportional), theme::MUTED);
}
let _ = mx;
ui.painter().text(pos2(rect.right(), thumb.top() + 8.0), Align2::RIGHT_CENTER, time_ago(item.created_at, now), FontId::new(11.0, FontFamily::Proportional), theme::FAINT);
let mut action_clicked = false;
if hovered && !view.selecting {
let mut buttons: Vec<(Icon, &str, FeedAction)> = Vec::new();
if let Some(text) = prompt {
buttons.push((Icon::Copy, "Copy prompt", FeedAction::CopyPrompt(text.clone())));
}
if item.prompt_token.is_some() {
buttons.push((Icon::RotateCw, "Recreate", FeedAction::Recreate(item.token.clone())));
}
if view.make_video && item.kind == MediaKind::Image {
buttons.push((Icon::Video, "Make Video", FeedAction::MakeVideo(item.token.clone())));
}
buttons.push((Icon::Link, "Share", FeedAction::Share(item.token.clone())));
buttons.push((Icon::Download, "Download", FeedAction::Download(item.token.clone())));
let bar = Rect::from_min_size(pos2(rect.right() - 28.0 * buttons.len() as f32, thumb.bottom() - 28.0), vec2(28.0 * buttons.len() as f32, 28.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing.x = 0.0;
for (icon, tip, action) in buttons {
if widgets::overlay_icon(&mut child, icon, tip).clicked() {
actions.push(action);
action_clicked = true;
}
}
}
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() && !action_clicked {
actions.push(if view.selecting { FeedAction::ToggleSelect(item.token.clone()) } else { FeedAction::Open(item.token.clone()) });
}
row_divider(ui, rect);
}
fn row_divider(ui: &Ui, rect: Rect) {
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, Stroke::new(1.0, Color32::from_white_alpha(10)));
}
/// A small dark chip: an icon (or a creator logo) and a label. Returns its right edge.
fn chip(ui: &Ui, left_center: egui::Pos2, icon: Option<Icon>, creator: Option<&str>, label: &str) -> f32 {
let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::new(12.0, theme::medium()), theme::fade(Color32::WHITE, 0.9));
let lead = if icon.is_some() || creator.is_some() { 16.0 } else { 0.0 };
let rect = Rect::from_min_size(pos2(left_center.x, left_center.y - 12.0), vec2(20.0 + lead + galley.size().x, 24.0));
let p = ui.painter();
p.rect_filled(rect, 0.0, Color32::from_black_alpha(153));
let icon_rect = Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(11.0, 11.0));
if let Some(icon) = icon {
icons::paint(p, icon_rect, icon, theme::fade(Color32::WHITE, 0.9));
} else if let Some(creator) = creator {
let tex = creator_icons::texture(ui.ctx(), creator);
egui::Image::new(&tex).tint(theme::fade(Color32::WHITE, 0.9)).paint_at(ui, icon_rect.expand(0.5));
}
p.galley(pos2(rect.left() + 10.0 + lead, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
rect.right()
}
/// A creator logo followed by the model's name. Returns the right edge.
fn model_chip(ui: &Ui, left_center: egui::Pos2, model_id: &str, names: &dyn ModelNames, size: f32, color: Color32) -> f32 {
if model_id.is_empty() {
return left_center.x;
}
let icon_rect = Rect::from_center_size(pos2(left_center.x + size / 2.0 + 1.0, left_center.y), vec2(size + 2.0, size + 2.0));
let tex = creator_icons::texture(ui.ctx(), &names.creator(model_id));
egui::Image::new(&tex).tint(color).paint_at(ui, icon_rect);
let r = ui.painter().text(pos2(icon_rect.right() + 6.0, left_center.y), Align2::LEFT_CENTER, names.display_name(model_id), FontId::new(size, theme::medium()), color);
r.right()
}
/// Darkens the bottom of `rect` (`bg-gradient-to-t from-black/70`), returning the faded band.
fn bottom_gradient(ui: &Ui, rect: Rect, height: f32, alpha: u8) -> Rect {
let band = Rect::from_min_max(pos2(rect.left(), (rect.bottom() - height).max(rect.top())), rect.max);
let mut mesh = Mesh::default();
let (top, bottom) = (Color32::TRANSPARENT, Color32::from_black_alpha(alpha));
mesh.colored_vertex(band.left_top(), top);
mesh.colored_vertex(band.right_top(), top);
mesh.colored_vertex(band.right_bottom(), bottom);
mesh.colored_vertex(band.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
ui.painter().add(mesh);
band
}
/// The sweeping highlight over loading tiles (`.animate-shimmer`).
fn shimmer(ui: &Ui, rect: Rect) {
let t = (ui.input(|i| i.time) / 1.5).fract() as f32;
let x = rect.left() - rect.width() + t * rect.width() * 3.0;
let band = Rect::from_min_max(pos2(x, rect.top()), pos2(x + rect.width(), rect.bottom())).intersect(rect);
if band.width() > 0.0 {
let mut mesh = Mesh::default();
let (edge, mid) = (Color32::TRANSPARENT, Color32::from_white_alpha(20));
let cx = (x + rect.width() / 2.0).clamp(band.left(), band.right());
for (px, c) in [(band.left(), edge), (cx, mid), (band.right(), edge)] {
mesh.colored_vertex(pos2(px, band.top()), c);
mesh.colored_vertex(pos2(px, band.bottom()), c);
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(1, 2, 3);
mesh.add_triangle(2, 3, 4);
mesh.add_triangle(3, 4, 5);
ui.painter().add(mesh);
}
ui.ctx().request_repaint();
}
/// Lays out `text` wrapped to `width`, keeping at most `max_lines` lines (with an ellipsis).
fn clamp_lines(ui: &Ui, text: &str, size: f32, color: Color32, width: f32, max_lines: usize) -> std::sync::Arc<egui::Galley> {
let mut job = egui::text::LayoutJob::simple(text.to_owned(), FontId::new(size, FontFamily::Proportional), color, width);
job.wrap.max_rows = max_lines;
job.wrap.break_anywhere = false;
ui.painter().layout_job(job)
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
format!("{}\u{2026}", s.chars().take(max - 1).collect::<String>())
}
}
/// The UV rect that crops a texture of `size` to fill `target` (`object-cover`).
fn cover_uv_for(size: egui::Vec2, target: egui::Vec2) -> Rect {
if size.x <= 0.0 || size.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
}
let (src, dst) = (size.y / size.x, target.y / target.x);
if src > dst {
let f = dst / src;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
} else {
let f = src / dst;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod grid;
pub mod store;
pub mod types;
+326
View File
@@ -0,0 +1,326 @@
//! One create page's feed (`useDesktopGenerationFeed` + `useGalleryData`): jobs in flight and
//! failures from the session's job list, results as they land, and the library underneath.
use std::collections::{HashMap, HashSet};
use crate::backend::wire::{Job, JobPhase, MediaFile, unix_secs};
use crate::backend::EnqueueMeta;
use crate::feed::types::{FailedJob, FeedItem, MediaKind, PendingJob};
use crate::models::Catalog;
/// Failures older than this are history, not news.
const FAILURE_WINDOW_SECS: i64 = 24 * 60 * 60;
/// Thumbnails are requested at this width (`getMediaThumbnail(@512)`).
pub const THUMBNAIL_WIDTH: u32 = 512;
/// Something the store wants the app to do or say after an update.
#[derive(Debug, PartialEq)]
pub enum FeedNotice {
Completed,
Failed(String),
/// Expand a finished batch job into all of its files.
LoadBatch {
job_token: String,
batch_token: String,
},
}
#[derive(Default)]
pub struct FeedStore {
pub pending: Vec<PendingJob>,
pub failed: Vec<FailedJob>,
pub items: Vec<FeedItem>,
pub has_more: bool,
pub loading: bool,
pub selecting: bool,
pub selected: HashSet<String>,
pub last_viewed: Option<String>,
next_page: u32,
library_started: bool,
/// Jobs seen finished (so only new ones announce themselves).
seen_complete: HashSet<String>,
/// Jobs seen running in this session (so their outcome gets a toast).
seen_running: HashSet<String>,
first_poll_done: bool,
dismissed: HashSet<String>,
meta: HashMap<String, EnqueueMeta>,
}
impl FeedStore {
pub fn has_content(&self) -> bool {
!self.pending.is_empty() || !self.failed.is_empty() || !self.items.is_empty()
}
/// Shows the jobs a generate call just started, before the next poll catches up.
pub fn add_enqueued(&mut self, kind: MediaKind, job_tokens: &[String], meta: &EnqueueMeta, catalog: &Catalog) {
let now = chrono::Utc::now().timestamp();
let expected = catalog.find(&meta.model_id).map_or(30.0, |m| m.expected_secs);
for token in job_tokens {
self.meta.insert(token.clone(), meta.clone());
self.seen_running.insert(token.clone());
if !self.pending.iter().any(|p| &p.job_token == token) {
self.pending.insert(0, PendingJob { job_token: token.clone(), kind, prompt: meta.prompt.clone(), model_id: Some(meta.model_id.clone()), created_at: now, server_progress: None, expected_secs: expected, batch_count: meta.batch_count });
}
}
}
/// Applies a poll of the session's jobs, keeping only `kind`'s.
pub fn apply_jobs(&mut self, kind: MediaKind, jobs: &[Job], catalog: &Catalog) -> Vec<FeedNotice> {
let now = chrono::Utc::now().timestamp();
let mut notices = Vec::new();
let mut pending = Vec::new();
let mut failed = Vec::new();
let relevant: Vec<&Job> = jobs.iter().filter(|j| job_kind(j, catalog) == Some(kind) && !self.dismissed.contains(&j.job_token)).collect();
for job in relevant {
let meta = self.meta.get(&job.job_token).cloned();
let meta = meta.as_ref();
let model_id = meta.map(|m| m.model_id.clone()).or_else(|| job.request.maybe_model_type.clone());
let prompt = meta.map(|m| m.prompt.clone()).or_else(|| job.request.maybe_raw_inference_text.clone()).unwrap_or_default();
match job.status.phase() {
JobPhase::Running => {
self.seen_running.insert(job.job_token.clone());
let expected = model_id.as_deref().and_then(|id| catalog.find(id)).map_or(if kind == MediaKind::Video { 900.0 } else { 30.0 }, |m| m.expected_secs);
pending.push(PendingJob { job_token: job.job_token.clone(), kind, prompt, model_id, created_at: unix_secs(&job.created_at), server_progress: Some(job.status.progress_percentage), expected_secs: expected, batch_count: meta.map_or(1, |m| m.batch_count) });
},
JobPhase::Failed => {
let updated = unix_secs(&job.updated_at);
if self.seen_running.remove(&job.job_token) {
notices.push(FeedNotice::Failed(job.status.maybe_failure_message.clone().unwrap_or_else(|| job.status.failure_reason())));
}
if now - updated <= FAILURE_WINDOW_SECS {
failed.push(FailedJob { job_token: job.job_token.clone(), prompt, model_id, updated_at: updated, reason: job.status.failure_reason(), message: job.status.maybe_failure_message.clone().filter(|_| job.status.maybe_failure_category_updated.as_deref() != Some("unknown")), ref_image: meta.and_then(|m| m.ref_image.clone()) });
}
},
JobPhase::Succeeded => {
if !self.seen_complete.insert(job.job_token.clone()) || !self.first_poll_done {
continue;
}
if self.seen_running.remove(&job.job_token) {
notices.push(FeedNotice::Completed);
}
let Some(result) = &job.maybe_result else {
continue;
};
// The finished card takes the pending card's place: date it by the job.
let created_at = unix_secs(&job.created_at);
self.push_item(FeedItem { token: result.entity_token.clone(), kind, thumbnail: result.media_links.thumbnail(THUMBNAIL_WIDTH), full_url: result.media_links.cdn_url.clone(), created_at, model_id: model_id.clone(), prompt_token: job.request.maybe_prompt_token.clone(), batch_token: result.maybe_batch_token.clone(), duration_secs: None });
if let Some(batch) = &result.maybe_batch_token {
notices.push(FeedNotice::LoadBatch { job_token: job.job_token.clone(), batch_token: batch.clone() });
}
},
}
}
// Jobs started a moment ago may not be in the list yet: keep them.
for p in self.pending.drain(..) {
let listed = jobs.iter().any(|j| j.job_token == p.job_token);
if !listed && now - p.created_at < 60 && !pending.iter().any(|q: &PendingJob| q.job_token == p.job_token) {
pending.push(p);
}
}
// Everything finished before the first poll comes from the library instead.
self.first_poll_done = true;
pending.sort_by_key(|p| std::cmp::Reverse(p.created_at));
failed.sort_by_key(|f| std::cmp::Reverse(f.updated_at));
self.pending = pending;
self.failed = failed;
notices
}
/// Adds every file of a finished batch, dated like the batch's first card.
pub fn apply_batch(&mut self, kind: MediaKind, files: &[MediaFile]) {
for file in files {
let mut item = feed_item(kind, file);
if let Some(first) = self.items.iter().find(|i| i.batch_token.is_some() && i.batch_token == file.maybe_batch_token) {
item.created_at = first.created_at;
}
self.push_item(item);
}
}
pub fn library_started(&self) -> bool {
self.library_started
}
/// The next library page to fetch, if one is due.
pub fn next_library_page(&mut self) -> Option<u32> {
if self.loading || (self.library_started && !self.has_more) {
return None;
}
self.loading = true;
self.library_started = true;
Some(self.next_page)
}
pub fn apply_library_page(&mut self, kind: MediaKind, page: u32, files: &[MediaFile], total_pages: u32) {
self.loading = false;
if page != self.next_page {
return;
}
for file in files {
self.push_item(feed_item(kind, file));
}
self.next_page = page + 1;
self.has_more = self.next_page < total_pages;
}
pub fn library_failed(&mut self) {
self.loading = false;
}
/// Starts the library over (after signing in or out).
pub fn reset(&mut self) {
*self = Self { dismissed: std::mem::take(&mut self.dismissed), ..Self::default() };
}
pub fn dismiss(&mut self, job_token: &str) {
self.dismissed.insert(job_token.to_owned());
self.failed.retain(|f| f.job_token != job_token);
}
pub fn remove_item(&mut self, token: &str) {
self.items.retain(|i| i.token != token);
self.selected.remove(token);
}
pub fn find(&self, token: &str) -> Option<&FeedItem> {
self.items.iter().find(|i| i.token == token)
}
/// Finished items newest first (the lightbox's prev/next order).
pub fn ordered_tokens(&self) -> Vec<String> {
let mut items: Vec<&FeedItem> = self.items.iter().collect();
items.sort_by_key(|i| std::cmp::Reverse(i.created_at));
items.into_iter().map(|i| i.token.clone()).collect()
}
fn push_item(&mut self, item: FeedItem) {
if !item.token.is_empty() && !self.items.iter().any(|i| i.token == item.token) {
self.items.push(item);
}
}
}
/// Builds a feed item from a library or batch file.
pub fn feed_item(kind: MediaKind, file: &MediaFile) -> FeedItem {
FeedItem {
token: file.token.clone(),
kind: match file.media_class.as_deref() {
Some("video") => MediaKind::Video,
Some("image") => MediaKind::Image,
_ => kind,
},
thumbnail: file.media_links.thumbnail(THUMBNAIL_WIDTH),
full_url: file.media_links.cdn_url.clone(),
created_at: unix_secs(&file.created_at),
model_id: file.maybe_model_type.clone().or_else(|| file.maybe_origin_model_type.clone()),
prompt_token: file.maybe_prompt_token.clone(),
batch_token: file.maybe_batch_token.clone(),
duration_secs: file.maybe_duration_millis.map(|ms| ms as f32 / 1000.0),
}
}
/// Whether a job made an image or a video: by its model, else by its category.
fn job_kind(job: &Job, catalog: &Catalog) -> Option<MediaKind> {
if let Some(model) = job.request.maybe_model_type.as_deref() {
if catalog.image.iter().any(|m| m.id == model) {
return Some(MediaKind::Image);
}
if catalog.video.iter().any(|m| m.id == model) {
return Some(MediaKind::Video);
}
}
let category = job.request.inference_category.as_str();
if category.contains("video") {
Some(MediaKind::Video)
} else if category.contains("image") {
Some(MediaKind::Image)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ModelInfo;
fn catalog() -> Catalog {
let mut c = Catalog::default();
c.set_image(vec![ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), prompt_supported: true, ..Default::default() }]);
c.set_video(vec![ModelInfo { id: "seedance_2p0".into(), name: "Seedance 2.0".into(), ..Default::default() }]);
c
}
fn job(token: &str, model: &str, status: &str, created: &str) -> Job {
serde_json::from_value(serde_json::json!({
"job_token": token,
"request": { "inference_category": "image_generation", "maybe_model_type": model, "maybe_raw_inference_text": "a red knight" },
"status": { "status": status, "progress_percentage": 40, "maybe_failure_message": "Too spicy", "maybe_failure_category_updated": "rule_bans_user_text_prompt" },
"maybe_result": { "entity_token": format!("m_{token}"), "media_links": { "cdn_url": "https://cdn/x.png", "maybe_thumbnail_template": "https://cdn/w={WIDTH}/x.png" } },
"created_at": created,
"updated_at": created,
}))
.expect("job json")
}
fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
#[test]
fn first_poll_leaves_old_results_to_the_library() {
let (c, mut store) = (catalog(), FeedStore::default());
let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &now_rfc3339())], &c);
assert!(notices.is_empty());
assert!(store.items.is_empty());
}
#[test]
fn running_jobs_become_pending_then_announce_their_result() {
let (c, mut store) = (catalog(), FeedStore::default());
let t = now_rfc3339();
store.apply_jobs(MediaKind::Image, &[], &c);
store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "started", &t)], &c);
assert_eq!(store.pending.len(), 1);
assert_eq!(store.pending[0].prompt, "a red knight");
assert_eq!(store.pending[0].server_progress, Some(40));
let notices = store.apply_jobs(MediaKind::Image, &[job("j1", "nano_banana_pro", "complete_success", &t)], &c);
assert_eq!(notices, vec![FeedNotice::Completed]);
assert!(store.pending.is_empty());
assert_eq!(store.items[0].token, "m_j1");
assert_eq!(store.items[0].thumbnail.as_deref(), Some("https://cdn/w=512/x.png"));
}
#[test]
fn failures_show_with_their_reason_and_can_be_dismissed() {
let (c, mut store) = (catalog(), FeedStore::default());
let t = now_rfc3339();
store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "pending", &t)], &c);
let notices = store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c);
assert_eq!(notices, vec![FeedNotice::Failed("Too spicy".into())]);
assert_eq!(store.failed[0].reason, "Text prompt violates content policy");
store.dismiss("j2");
store.apply_jobs(MediaKind::Image, &[job("j2", "nano_banana_pro", "complete_failure", &t)], &c);
assert!(store.failed.is_empty());
}
#[test]
fn jobs_go_to_the_feed_of_their_model() {
let (c, mut store) = (catalog(), FeedStore::default());
store.apply_jobs(MediaKind::Video, &[job("j3", "nano_banana_pro", "started", &now_rfc3339())], &c);
assert!(store.pending.is_empty());
store.apply_jobs(MediaKind::Video, &[job("j4", "seedance_2p0", "started", &now_rfc3339())], &c);
assert_eq!(store.pending.len(), 1);
}
#[test]
fn just_enqueued_jobs_survive_a_poll_that_predates_them() {
let (c, mut store) = (catalog(), FeedStore::default());
let meta = EnqueueMeta { prompt: "p".into(), model_id: "nano_banana_pro".into(), batch_count: 2, ref_image: None };
store.add_enqueued(MediaKind::Image, &["fresh".into()], &meta, &c);
store.apply_jobs(MediaKind::Image, &[], &c);
assert_eq!(store.pending.len(), 1);
assert_eq!(store.pending[0].batch_count, 2);
}
}
+107
View File
@@ -0,0 +1,107 @@
//! What the generation feed shows: jobs still running, jobs that failed, and finished media.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MediaKind {
Image,
Video,
}
impl MediaKind {
pub fn label(self) -> &'static str {
match self {
MediaKind::Image => "Image",
MediaKind::Video => "Video",
}
}
pub fn plural(self) -> &'static str {
match self {
MediaKind::Image => "images",
MediaKind::Video => "videos",
}
}
}
/// A finished image or video in the user's library.
#[derive(Clone, Debug)]
pub struct FeedItem {
/// The media file token.
pub token: String,
pub kind: MediaKind,
/// A thumbnail URL (512 px wide where the CDN can resize).
pub thumbnail: Option<String>,
/// The original file.
pub full_url: String,
/// Unix seconds.
pub created_at: i64,
/// The model that made it (`maybe_model_type`).
pub model_id: Option<String>,
pub prompt_token: Option<String>,
pub batch_token: Option<String>,
pub duration_secs: Option<f32>,
}
/// A generation the server is still working on.
#[derive(Clone, Debug)]
pub struct PendingJob {
pub job_token: String,
pub kind: MediaKind,
pub prompt: String,
pub model_id: Option<String>,
/// Unix seconds when it was enqueued.
pub created_at: i64,
/// The server's progress (0–100), when it reports any.
pub server_progress: Option<u8>,
/// How long this model usually takes, for the estimated progress bar.
pub expected_secs: f32,
pub batch_count: u32,
}
/// A generation that failed or was cancelled.
#[derive(Clone, Debug)]
pub struct FailedJob {
pub job_token: String,
pub prompt: String,
pub model_id: Option<String>,
pub updated_at: i64,
pub reason: String,
pub message: Option<String>,
/// The first reference image, shown faintly behind the error.
pub ref_image: Option<String>,
}
/// The progress and time-left labels for a pending job (`derivePendingStatus`).
pub fn pending_status(job: &PendingJob, now: i64) -> (u8, String) {
let elapsed = (now - job.created_at).max(0) as f32;
let estimated = ((elapsed / job.expected_secs.max(1.0)) * 100.0).min(95.0) as u8;
let progress = job.server_progress.filter(|p| *p > 0).unwrap_or(estimated).min(99);
if progress >= 95 {
return (progress, "Almost done...".to_owned());
}
let left = (job.expected_secs - elapsed).max(1.0) as i64;
let (h, m, s) = (left / 3600, (left % 3600) / 60, left % 60);
let label = if h > 0 && m > 0 {
format!("~{h}h {m}m")
} else if h > 0 {
format!("~{h}h")
} else if m > 0 {
format!("~{m}m")
} else {
format!("~{s}s")
};
(progress, label)
}
/// "just now", "5 minutes ago", … for list rows.
pub fn time_ago(then: i64, now: i64) -> String {
let d = (now - then).max(0);
let plural = |n: i64, unit: &str| format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" });
match d {
0..=59 => "just now".to_owned(),
60..=3599 => plural(d / 60, "minute"),
3600..=86_399 => plural(d / 3600, "hour"),
86_400..=2_591_999 => plural(d / 86_400, "day"),
2_592_000..=31_535_999 => plural(d / 2_592_000, "month"),
_ => plural(d / 31_536_000, "year"),
}
}
+33
View File
@@ -0,0 +1,33 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//! ArtCraft, natively: an egui desktop app over the ArtCraft API (no Tauri, no webview).
mod app;
mod backend;
mod feed;
mod models;
mod overlays;
mod pages;
mod prompt_box;
mod shell;
mod theme;
mod ui;
const APP_ID: &str = "ai.artcraft.native";
fn main() -> eframe::Result {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut viewport = egui::ViewportBuilder::default().with_title("ArtCraft").with_app_id(APP_ID).with_inner_size([1440.0, 900.0]).with_min_inner_size([960.0, 640.0]);
if let Some(icon) = app_icon() {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, persist_window: true, ..Default::default() };
eframe::run_native("ArtCraft", options, Box::new(|cc| Ok(Box::new(app::ArtcraftApp::new(cc)))))
}
/// The window and taskbar icon.
fn app_icon() -> Option<egui::IconData> {
const ICON: &[u8] = include_bytes!("../../crates/desktop/artcraft/icons/icon.png");
let rgba = image::load_from_memory(ICON).ok()?.into_rgba8();
Some(egui::IconData { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() })
}
+629
View File
@@ -0,0 +1,629 @@
//! The model catalog: capabilities come from the server's OmniGen listing (so new models and
//! options show up without an app update); names, descriptions, families and typical durations
//! are filled in here when the server doesn't say.
use crate::feed::grid::ModelNames;
use crate::feed::types::MediaKind;
/// Default models (`defaultModelForPage.ts`).
pub const DEFAULT_IMAGE_MODEL: &str = "nano_banana_pro";
pub const DEFAULT_VIDEO_MODEL: &str = "seedance_2p0";
/// The fallback prompt limit (`maxPromptLength ?? 1000`).
pub const DEFAULT_PROMPT_MAX: usize = 1000;
/// The model pickers group by family once they list this many models.
const GROUP_THRESHOLD: usize = 8;
/// One generation model and what it accepts.
#[derive(Clone, Debug, Default)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: String,
/// Snake-case maker (`google`, `bytedance`, …), for the logo.
pub creator: String,
pub family: String,
pub disabled: bool,
pub prompt_supported: bool,
/// `None` = unlimited.
pub prompt_max: Option<usize>,
pub aspect_ratios: Vec<String>,
pub aspect_default: Option<String>,
pub resolutions: Vec<String>,
pub resolution_default: Option<String>,
pub qualities: Vec<String>,
pub quality_default: Option<String>,
pub batch_sizes: Vec<u16>,
pub batch_default: u16,
pub image_refs_max: usize,
// Video only.
pub text_to_video: bool,
pub start_frame: bool,
pub start_frame_required: bool,
pub end_frame: bool,
pub sound_toggle: bool,
pub video_refs_max: usize,
pub video_refs_max_secs: Option<u16>,
pub audio_refs_max: usize,
pub audio_refs_max_secs: Option<u16>,
pub duration_options: Vec<u16>,
pub duration_min: Option<u16>,
pub duration_max: Option<u16>,
pub duration_max_with_refs: Option<u16>,
pub duration_default: Option<u16>,
pub bitrates: Vec<String>,
pub bitrate_default: Option<String>,
pub output_formats: Vec<String>,
pub output_format_default: Option<String>,
/// How long a generation usually takes (drives the estimated progress bar).
pub expected_secs: f32,
}
impl ModelInfo {
/// Batch sizes the count picker offers (`predefined` or `1..=max`).
pub fn batch_options(&self) -> Vec<u16> {
if self.batch_sizes.is_empty() {
vec![1]
} else {
self.batch_sizes.clone()
}
}
/// `count` if this model allows it, else its default.
pub fn valid_batch(&self, count: u16) -> u16 {
let options = self.batch_options();
if options.contains(&count) {
count
} else if options.contains(&self.batch_default) {
self.batch_default
} else {
options[0]
}
}
/// `current` if supported, else the default, else the first option (`resolveQuality` & co.).
pub fn resolve(current: Option<&str>, options: &[String], default: Option<&String>) -> Option<String> {
match current {
Some(c) if options.iter().any(|o| o == c) => Some(c.to_owned()),
_ => default.filter(|d| options.is_empty() || options.contains(d)).cloned().or_else(|| options.first().cloned()),
}
}
pub fn supports_reference_mode(&self) -> bool {
self.image_refs_max > 0 || self.video_refs_max > 0 || self.audio_refs_max > 0
}
/// Whether the duration picker shows (a range or several options).
pub fn duration_range(&self, with_refs: bool) -> Option<(u16, u16)> {
let max = if with_refs { self.duration_max_with_refs.or(self.duration_max) } else { self.duration_max };
match (self.duration_min, max) {
(Some(min), Some(max)) if max > min => Some((min, max)),
_ => {
let (lo, hi) = (self.duration_options.iter().min()?, self.duration_options.iter().max()?);
(hi > lo).then_some((*lo, *hi))
},
}
}
/// Snaps `current` into the model's duration range (preferring the higher neighbour), or its
/// default (`resolveVideoDuration`).
pub fn resolve_duration(&self, current: Option<u16>, with_refs: bool) -> Option<u16> {
let default = self.duration_default.or_else(|| self.duration_options.first().copied()).or(self.duration_min);
let current = current.or(default)?;
if !self.duration_options.is_empty() {
let cap = if with_refs { self.duration_max_with_refs } else { None };
let mut options: Vec<u16> = self.duration_options.iter().copied().filter(|o| cap.is_none_or(|c| *o <= c)).collect();
options.sort_unstable();
return options.iter().copied().find(|&o| o >= current).or(options.last().copied()).or(default);
}
match self.duration_range(with_refs) {
Some((min, max)) => Some(current.clamp(min, max)),
None => default,
}
}
}
/// Builds a model from the server's image listing.
pub fn image_model(d: &artcraft_client::endpoints::omni_gen::models::image::omni_gen_list_image_models::OmniGenImageModelDetails) -> ModelInfo {
let id = String::from(d.model.clone());
let mut m = base(&id, MediaKind::Image, d.full_name.as_deref(), d.model_creator.clone().map(String::from));
m.disabled = d.is_disabled.unwrap_or(false);
m.prompt_supported = d.text_prompt_supported.unwrap_or(true);
m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id));
m.aspect_ratios = strings(&d.aspect_ratio_options);
m.aspect_default = d.aspect_ratio_default.clone().map(String::from);
m.resolutions = strings(&d.resolution_options);
m.resolution_default = d.resolution_default.clone().map(String::from);
m.qualities = strings(&d.quality_options);
m.quality_default = d.default_quality.clone().map(String::from);
m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max);
m.batch_default = d.batch_size_default.unwrap_or(1);
m.image_refs_max = if d.image_refs_supported.unwrap_or(false) { d.image_refs_max.map_or(1, usize::from) } else { 0 };
m
}
/// Builds a model from the server's video listing.
pub fn video_model(d: &artcraft_client::endpoints::omni_gen::models::video::omni_gen_list_video_models::OmniGenVideoModelDetails) -> ModelInfo {
let id = String::from(d.model.clone());
let mut m = base(&id, MediaKind::Video, d.full_name.as_deref(), d.model_creator.clone().map(String::from));
if let Some(short) = d.extra_info_short.as_deref().filter(|s| !s.is_empty()) {
m.description = short.to_owned();
}
m.disabled = d.is_disabled.unwrap_or(false);
m.prompt_supported = d.text_prompt_supported.unwrap_or(true);
m.prompt_max = d.text_prompt_max_length.map(usize::from).or_else(|| default_prompt_max(&id));
m.text_to_video = d.text_to_video_supported.unwrap_or(true);
m.start_frame = d.starting_keyframe_supported.unwrap_or(false);
m.start_frame_required = d.starting_keyframe_required.unwrap_or(false);
m.end_frame = d.ending_keyframe_supported.unwrap_or(false);
m.sound_toggle = d.show_generate_with_sound_toggle.unwrap_or(false);
let max = |supported: Option<bool>, max: Option<u16>| if supported.unwrap_or(false) { max.map_or(1, usize::from) } else { 0 };
m.image_refs_max = max(d.image_references_supported, d.image_references_max);
m.video_refs_max = max(d.video_references_supported, d.video_references_max);
m.video_refs_max_secs = d.video_references_max_total_duration_seconds;
m.audio_refs_max = max(d.audio_references_supported, d.audio_references_max);
m.audio_refs_max_secs = d.audio_references_max_total_duration_seconds;
m.aspect_ratios = strings(&d.aspect_ratio_options);
m.aspect_default = d.aspect_ratio_default.clone().map(String::from);
m.resolutions = strings(&d.resolution_options);
m.resolution_default = d.resolution_default.clone().map(String::from);
m.qualities = strings(&d.quality_options);
m.quality_default = d.default_quality.clone().map(String::from);
m.bitrates = strings(&d.bitrate_options);
m.bitrate_default = d.bitrate_default.clone().map(String::from);
m.output_formats = d.output_format_options.clone().unwrap_or_default();
m.output_format_default = d.output_format_default.clone();
m.duration_options = d.duration_seconds_options.clone().unwrap_or_default();
m.duration_min = d.duration_seconds_min;
m.duration_max = d.duration_seconds_max;
m.duration_max_with_refs = d.duration_seconds_max_with_image_references;
m.duration_default = d.duration_seconds_default;
m.batch_sizes = batch_sizes(d.batch_size_options.as_deref(), d.batch_size_min, d.batch_size_max);
m.batch_default = d.batch_size_default.unwrap_or(1);
m
}
fn strings<T: Clone + Into<String>>(options: &Option<Vec<T>>) -> Vec<String> {
options.as_deref().unwrap_or_default().iter().cloned().map(Into::into).collect()
}
fn batch_sizes(options: Option<&[u16]>, min: Option<u16>, max: Option<u16>) -> Vec<u16> {
match options.filter(|o| !o.is_empty()) {
Some(options) => options.to_vec(),
None => (min.unwrap_or(1).max(1)..=max.unwrap_or(1).max(1)).collect(),
}
}
fn base(id: &str, kind: MediaKind, full_name: Option<&str>, creator: Option<String>) -> ModelInfo {
// Known families use the pickers' curated names ("Flux 1 Dev", not "FLUX.1 [dev]"); models
// the app doesn't know yet use the server's name.
let name = if has_curated_name(id) { display_name(id) } else { full_name.filter(|n| !n.is_empty()).map_or_else(|| display_name(id), str::to_owned) };
// The server reports "artcraft" for models it serves itself; show the actual maker when the id
// says who that is (Nano Banana → Google), like the webapp.
let creator = match creator.filter(|c| !c.is_empty() && c != "unknown") {
Some(c) if c == "artcraft" && creator_for(id) != "artcraft" => creator_for(id).to_owned(),
Some(c) => c,
None => creator_for(id).to_owned(),
};
ModelInfo { id: id.to_owned(), name, description: description(id).to_owned(), creator, family: family(id).to_owned(), prompt_supported: true, text_to_video: true, batch_default: 1, expected_secs: expected_secs(id, kind), ..Default::default() }
}
/// Families whose picker names the app curates (the static model lists' `selectorName`s).
fn has_curated_name(id: &str) -> bool {
const PREFIXES: [&str; 11] = ["flux_", "nano_banana", "gpt_image", "seedream", "seedance", "kling", "veo_", "sora", "happy_horse", "grok_imagine", "qwen"];
PREFIXES.iter().any(|p| id.starts_with(p))
}
/// A readable name for a model id: the irregular ones by table, the rest by rule
/// (`seedance_2p0_fast` → "Seedance 2.0 Fast").
pub fn display_name(id: &str) -> String {
let special = match id {
"nano_banana" | "gemini_25_flash" => "Nano Banana",
"grok_image" => "Grok Image",
"grok_imagine_image" => "Grok",
"grok_imagine_video" => "Grok Video",
"grok_imagine_image_q" => "Grok Imagine Quality",
"gpt_image_1" => "GPT Image 1 (GPT-4o)",
"flux_pro_1" => "Flux Pro (Inpainting)",
"seedance_2p0_bp" => "Seedance 2.0 Plus",
"seedance_2p0_bp_fast" => "Seedance 2.0 Plus Fast",
"seedance_2p0_bp_mini" => "Seedance 2.0 Plus Mini",
"seedance_2p0_bpu" => "Seedance 2.0 Plus Ultra",
"seedance_2p0_bpu_fast" => "Seedance 2.0 Plus Ultra Fast",
"seedance_2p0_bpu_mini" => "Seedance 2.0 Plus Ultra Mini",
"marble_0p1_mini" => "Marble Mini",
"marble_0p1_plus" => "Marble Plus",
"minimax_h3" => "MiniMax H3",
_ => "",
};
if !special.is_empty() {
return special.to_owned();
}
let words: Vec<String> = id
.split('_')
.filter(|w| !w.is_empty())
.map(|w| {
let bytes = w.as_bytes();
// "2p0" → "2.0", "1p5" → "1.5"
if bytes[0].is_ascii_digit() && w.contains('p') && w.chars().all(|c| c.is_ascii_digit() || c == 'p') {
return w.replace('p', ".");
}
match w {
"gpt" => "GPT".to_owned(),
"veo" => "Google Veo".to_owned(),
"u" => "Ultra".to_owned(),
"q" => "Quality".to_owned(),
_ => {
let mut c = w.chars();
c.next().map_or_else(String::new, |f| f.to_uppercase().chain(c).collect())
},
}
})
.collect();
words.join(" ")
}
/// A model's prompt limit when the listing doesn't give one (the static lists' `maxPromptLength`;
/// `None` = unlimited, unknown models get the desktop's 1000).
fn default_prompt_max(id: &str) -> Option<usize> {
const LIMITS: [(&str, Option<usize>); 15] = [("nano_banana", None), ("gpt_image", None), ("seedance_2", None), ("seedance_1", Some(3000)), ("grok_imagine_video", Some(4096)), ("grok_video", Some(4096)), ("grok", Some(8000)), ("midjourney", Some(6000)), ("flux", Some(4000)), ("seedream", Some(4000)), ("qwen", Some(800)), ("kling", Some(2500)), ("happy_horse", Some(2500)), ("sora", Some(2000)), ("veo", Some(5000))];
LIMITS.iter().find(|(prefix, _)| id.starts_with(prefix)).map_or(Some(DEFAULT_PROMPT_MAX), |(_, limit)| *limit)
}
/// The image picker's one-liners (`selectorDescription`).
fn description(id: &str) -> &'static str {
match id {
"grok_image" | "grok_imagine_image" => "Fast af",
"midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => "Stunning style and quality",
"flux_pro_1p1_ultra" => "Higher quality model",
"flux_pro_1p1" => "High quality model",
"flux_1_dev" => "Fast, but lower quality",
"flux_1_schnell" => "Fastest image gen, but lowest quality",
"nano_banana" | "nano_banana_2" => "Fast instructive editing",
"nano_banana_pro" => "Powerful instructive editing",
"gpt_image_1" => "Slow, but super smart",
"gpt_image_1p5" => "Faster, improved",
"gpt_image_2" => "Smart with great text support",
"gpt_image_2p5_flare" => "Precise edits, keeps subjects intact",
"gpt_image_2p5_sunburst" => "Premium detail, slower",
"seedream_4" | "seedream_4p5" | "seedream_5_lite" => "Fast",
"grok_video" | "grok_imagine_video" => "Fastest video model",
"sora_2" => "Smart video model",
"veo_3" => "Slow, high-quality model",
_ => "",
}
}
/// Typical generation time (the static list's `progressBarTime`).
fn expected_secs(id: &str, kind: MediaKind) -> f32 {
match id {
"grok_image" | "grok_imagine_image" | "flux_pro_1p1" | "flux_1_dev" | "flux_1_schnell" => 10.0,
"grok_video" | "grok_imagine_video" => 50.0,
"nano_banana" | "nano_banana_2" | "nano_banana_pro" => 25.0,
"flux_pro_1p1_ultra" => 35.0,
"midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8" => 45.0,
"gpt_image_1" | "gpt_image_1p5" | "gpt_image_2p5_flare" | "seedream_4" | "seedream_4p5" | "seedream_5_lite" => 60.0,
"gpt_image_2" | "gpt_image_2p5_sunburst" => 120.0,
_ if kind == MediaKind::Video => 900.0,
_ => 30.0,
}
}
/// The maker, by id prefix, when the server doesn't say.
pub fn creator_for(id: &str) -> &'static str {
const PREFIXES: [(&str, &str); 16] = [("flux", "black_forest_labs"), ("nano_banana", "google"), ("gemini", "google"), ("veo", "google"), ("imagen", "google"), ("gpt_image", "openai"), ("sora", "openai"), ("seedream", "bytedance"), ("seedance", "bytedance"), ("seededit", "bytedance"), ("kling", "kling"), ("grok", "grok"), ("midjourney", "midjourney"), ("qwen", "alibaba"), ("happy_horse", "alibaba"), ("minimax", "minimax")];
PREFIXES.iter().find(|(p, _)| id.starts_with(p)).map_or("artcraft", |(_, c)| c)
}
/// The picker family (`getModelFamilyName`).
fn family(id: &str) -> &'static str {
const FAMILIES: [(&str, &str); 22] = [("seedance", "Seedance"), ("kling", "Kling"), ("veo", "Veo"), ("sora", "Sora"), ("happy_horse", "Happy Horse"), ("wan", "Wan"), ("vidu", "Vidu"), ("preview", "Preview"), ("nano_banana", "Nano Banana"), ("gemini_25_flash", "Nano Banana"), ("seedream", "Seedream"), ("seededit", "SeedEdit"), ("gpt_image", "GPT Image"), ("flux", "Flux"), ("midjourney", "Midjourney"), ("grok", "Grok"), ("qwen", "Qwen"), ("recraft", "Recraft"), ("imagen", "Imagen"), ("minimax", "MiniMax"), ("marble", "World Labs"), ("suno", "Suno")];
FAMILIES.iter().find(|(p, _)| id.starts_with(p)).map_or("Other", |(_, f)| f)
}
/// Family order in the picker (`FAMILY_ORDER`): video families, then image families; the rest
/// alphabetical, "Other" last.
fn family_rank(family: &str) -> (usize, String) {
const ORDER: [&str; 17] = ["Seedance", "Kling", "Veo", "Sora", "Happy Horse", "Wan", "Vidu", "Grok", "Nano Banana", "Seedream", "GPT Image", "Flux", "Midjourney", "Qwen", "SeedEdit", "Recraft", "Imagen"];
match ORDER.iter().position(|f| *f == family) {
Some(i) => (i, String::new()),
None if family == "Other" => (usize::MAX, String::new()),
None => (ORDER.len(), family.to_owned()),
}
}
/// A group of models in the picker.
pub struct Family<'a> {
pub name: String,
pub models: Vec<&'a ModelInfo>,
}
/// Groups `models` by family (when there are enough of them), newest variant first.
pub fn grouped(models: &[ModelInfo]) -> Option<Vec<Family<'_>>> {
if models.len() < GROUP_THRESHOLD {
return None;
}
let mut families: Vec<Family<'_>> = Vec::new();
for m in models {
match families.iter_mut().find(|f| f.name == m.family) {
Some(f) => f.models.push(m),
None => families.push(Family { name: m.family.clone(), models: vec![m] }),
}
}
families.sort_by_key(|f| family_rank(&f.name));
for f in &mut families {
f.models.sort_by(|a, b| natural_desc(&a.name, &b.name));
}
Some(families)
}
/// Descending, comparing digit runs as numbers ("Seedream 4.5" before "Seedream 4").
fn natural_desc(a: &str, b: &str) -> std::cmp::Ordering {
fn key(s: &str) -> Vec<(String, f64)> {
s.split(|c: char| c.is_whitespace()).map(|w| (w.to_lowercase(), w.parse::<f64>().unwrap_or(-1.0))).collect()
}
let (ka, kb) = (key(a), key(b));
for (x, y) in ka.iter().zip(&kb) {
let ord = if x.1 >= 0.0 && y.1 >= 0.0 { y.1.total_cmp(&x.1) } else { y.0.cmp(&x.0) };
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
kb.len().cmp(&ka.len()).reverse()
}
/// The image page's list: text-to-image capable, enabled, sorted by name.
fn image_page_models(models: &[ModelInfo]) -> Vec<ModelInfo> {
const HIDDEN: [&str; 2] = ["minimax_h3_turbo", "minimax_h3_ultra"];
let mut list: Vec<ModelInfo> = models.iter().filter(|m| m.prompt_supported && !m.disabled && !HIDDEN.contains(&m.id.as_str())).cloned().collect();
list.sort_by_key(|m| m.name.to_lowercase());
list
}
/// The video page's list: enabled, without SwitchX, sorted by name.
fn video_page_models(models: &[ModelInfo]) -> Vec<ModelInfo> {
let mut list: Vec<ModelInfo> = models.iter().filter(|m| !m.disabled && m.id != "switch_x").cloned().collect();
list.sort_by_key(|m| m.name.to_lowercase());
list
}
/// Both catalogs, for looking up names in the feed, and each create page's picker list.
#[derive(Default)]
pub struct Catalog {
pub image: Vec<ModelInfo>,
pub video: Vec<ModelInfo>,
pub image_page: Vec<ModelInfo>,
pub video_page: Vec<ModelInfo>,
}
impl Catalog {
pub fn set_image(&mut self, models: Vec<ModelInfo>) {
self.image_page = image_page_models(&models);
self.image = models;
}
pub fn set_video(&mut self, models: Vec<ModelInfo>) {
self.video_page = video_page_models(&models);
self.video = models;
}
pub fn find(&self, id: &str) -> Option<&ModelInfo> {
self.image.iter().chain(&self.video).find(|m| m.id == id)
}
}
impl ModelNames for Catalog {
fn display_name(&self, model_id: &str) -> String {
self.find(model_id).map_or_else(|| display_name(model_id), |m| m.name.clone())
}
fn creator(&self, model_id: &str) -> String {
self.find(model_id).map_or_else(|| creator_for(model_id).to_owned(), |m| m.creator.clone())
}
}
/// Aspect ratio labels (`AspectRatioPicker`).
pub fn aspect_label(value: &str) -> String {
match value {
"auto" => "Auto",
"square" => "Square",
"wide_five_by_four" => "5:4 (Wide)",
"wide_four_by_three" => "4:3 (Wide)",
"wide_three_by_two" => "3:2 (Wide)",
"wide_sixteen_by_nine" => "16:9 (Wide)",
"wide_twenty_one_by_nine" => "21:9 (Wide)",
"tall_four_by_five" => "4:5 (Tall)",
"tall_three_by_four" => "3:4 (Tall)",
"tall_two_by_three" => "2:3 (Tall)",
"tall_nine_by_sixteen" => "9:16 (Tall)",
"tall_nine_by_twenty_one" => "9:21 (Tall)",
"auto_2k" => "Auto (2K)",
"auto_3k" => "Auto (3K)",
"auto_4k" => "Auto (4K)",
"square_hd" => "Square (HD)",
"wide" => "Wide",
"tall" => "Tall",
other => other,
}
.to_owned()
}
/// The proportions an aspect ratio's icon draws (`AspectRatioIcon`); `None` for the auto ones.
pub fn aspect_proportion(value: &str) -> Option<(f32, f32)> {
Some(match value {
"square" | "square_hd" => (1.0, 1.0),
"wide" => (16.0, 10.0),
"wide_five_by_four" => (5.0, 4.0),
"wide_four_by_three" => (4.0, 3.0),
"wide_three_by_two" => (3.0, 2.0),
"wide_sixteen_by_nine" => (16.0, 9.0),
"wide_twenty_one_by_nine" => (21.0, 9.0),
"tall" => (10.0, 16.0),
"tall_four_by_five" => (4.0, 5.0),
"tall_three_by_four" => (3.0, 4.0),
"tall_two_by_three" => (2.0, 3.0),
"tall_nine_by_sixteen" => (9.0, 16.0),
"tall_nine_by_twenty_one" => (9.0, 21.0),
_ => return None,
})
}
/// Resolution labels (`ResolutionPicker`).
pub fn resolution_label(value: &str) -> String {
match value {
"half_k" => "0.5K",
"four_eighty_p" => "480p",
"seven_twenty_p" => "720p",
"one_k" => "1K",
"ten_eighty_p" => "1080p",
"two_k" => "2K",
"three_k" => "3K",
"four_k" => "4K",
other => other,
}
.to_owned()
}
/// Whether a resolution is high definition (2K and up), for its SD/HD caption.
pub fn resolution_is_hd(value: &str) -> bool {
matches!(value, "two_k" | "three_k" | "four_k")
}
/// Quality labels (`QualityPicker`).
pub fn quality_label(value: &str) -> String {
match value {
"auto" => "Auto",
"max" => "Max",
"xhigh" => "Extra high",
"high" => "High",
"medium" => "Medium",
"low" => "Low",
other => other,
}
.to_owned()
}
/// Bitrate labels.
pub fn bitrate_label(value: &str) -> String {
match value {
"normal" => "Normal".to_owned(),
"high" => "High".to_owned(),
other => other.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn model(id: &str) -> ModelInfo {
ModelInfo { id: id.to_owned(), name: display_name(id), family: family(id).to_owned(), batch_default: 1, ..Default::default() }
}
mod naming {
use super::*;
#[test]
fn humanizes_versions_and_known_words() {
assert_eq!(display_name("seedance_2p0_fast"), "Seedance 2.0 Fast");
assert_eq!(display_name("veo_3p1"), "Google Veo 3.1");
assert_eq!(display_name("gpt_image_2p5_flare"), "GPT Image 2.5 Flare");
assert_eq!(display_name("kling_3p0_pro"), "Kling 3.0 Pro");
assert_eq!(display_name("nano_banana"), "Nano Banana");
assert_eq!(display_name("seedance_2p0_bpu"), "Seedance 2.0 Plus Ultra");
}
#[test]
fn prefers_curated_names_and_the_real_maker() {
let nb = base("nano_banana_pro", MediaKind::Image, Some("Nano Banana Pro"), Some("artcraft".into()));
assert_eq!((nb.name.as_str(), nb.creator.as_str()), ("Nano Banana Pro", "google"));
let flux = base("flux_1_dev", MediaKind::Image, Some("FLUX.1 [dev]"), Some("black_forest_labs".into()));
assert_eq!(flux.name, "Flux 1 Dev");
let mj = base("midjourney_7_niji", MediaKind::Image, Some("Midjourney v7 Niji (Anime)"), Some("midjourney".into()));
assert_eq!((mj.name.as_str(), mj.creator.as_str()), ("Midjourney v7 Niji (Anime)", "midjourney"));
}
#[test]
fn falls_back_to_creator_by_prefix() {
assert_eq!(creator_for("seedream_4p5"), "bytedance");
assert_eq!(creator_for("veo_3"), "google");
assert_eq!(creator_for("something_new"), "artcraft");
}
#[test]
fn prompt_limits_follow_the_static_lists() {
assert_eq!(default_prompt_max("nano_banana_pro"), None);
assert_eq!(default_prompt_max("seedance_2p0"), None);
assert_eq!(default_prompt_max("seedance_1p5_pro"), Some(3000));
assert_eq!(default_prompt_max("grok_imagine_video_1p5"), Some(4096));
assert_eq!(default_prompt_max("grok_imagine_image"), Some(8000));
assert_eq!(default_prompt_max("unknown_model"), Some(DEFAULT_PROMPT_MAX));
}
}
mod options {
use super::*;
#[test]
fn resolve_prefers_current_then_default_then_first() {
let opts = vec!["square".to_owned(), "wide_sixteen_by_nine".to_owned()];
let default = "wide_sixteen_by_nine".to_owned();
assert_eq!(ModelInfo::resolve(Some("square"), &opts, Some(&default)).as_deref(), Some("square"));
assert_eq!(ModelInfo::resolve(Some("tall"), &opts, Some(&default)).as_deref(), Some("wide_sixteen_by_nine"));
assert_eq!(ModelInfo::resolve(None, &opts, None).as_deref(), Some("square"));
assert_eq!(ModelInfo::resolve(None, &[], None), None);
}
#[test]
fn batch_counts_snap_to_valid_values() {
let mut m = model("grok_imagine_image");
m.batch_sizes = vec![6];
m.batch_default = 6;
assert_eq!(m.valid_batch(1), 6);
m.batch_sizes = vec![1, 2, 3, 4];
m.batch_default = 1;
assert_eq!(m.valid_batch(3), 3);
assert_eq!(m.valid_batch(9), 1);
}
#[test]
fn durations_clamp_to_ranges_and_snap_to_options() {
let mut m = model("seedance_2p0");
m.duration_min = Some(4);
m.duration_max = Some(15);
m.duration_max_with_refs = Some(10);
m.duration_default = Some(5);
assert_eq!(m.resolve_duration(None, false), Some(5));
assert_eq!(m.resolve_duration(Some(20), false), Some(15));
assert_eq!(m.resolve_duration(Some(14), true), Some(10));
assert_eq!(m.duration_range(false), Some((4, 15)));
let mut m = model("sora_2");
m.duration_options = vec![4, 8, 12];
m.duration_default = Some(4);
assert_eq!(m.resolve_duration(Some(6), false), Some(8));
assert_eq!(m.resolve_duration(Some(20), false), Some(12));
assert_eq!(m.duration_range(false), Some((4, 12)));
}
}
mod grouping {
use super::*;
#[test]
fn groups_large_lists_by_family_newest_first() {
let ids = ["nano_banana", "nano_banana_pro", "nano_banana_2", "seedream_4", "seedream_4p5", "flux_1_dev", "gpt_image_1", "gpt_image_2"];
let models: Vec<ModelInfo> = ids.iter().map(|id| model(id)).collect();
let families = grouped(&models).expect("8 models group");
let names: Vec<&str> = families.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["Nano Banana", "Seedream", "GPT Image", "Flux"]);
let seedream: Vec<&str> = families[1].models.iter().map(|m| m.name.as_str()).collect();
assert_eq!(seedream, ["Seedream 4.5", "Seedream 4"]);
}
#[test]
fn small_lists_stay_flat() {
let models: Vec<ModelInfo> = ["veo_3", "sora_2"].iter().map(|id| model(id)).collect();
assert!(grouped(&models).is_none());
}
}
}
+204
View File
@@ -0,0 +1,204 @@
//! The sign-in dialog and the settings dialog.
use egui::{Color32, Id, Sense, Stroke, Ui, pos2, vec2};
use crate::shell::{WEB_APP_URL, group_thousands};
use crate::theme;
use crate::ui::icons::Icon;
use crate::ui::widgets;
#[derive(Default)]
pub struct LoginDialog {
pub username: String,
pub password: String,
pub error: Option<String>,
pub busy: bool,
/// A browser sign-in waiting for approval: (page, confirmation code).
pub device: Option<(String, String)>,
}
#[derive(Debug, PartialEq)]
pub enum LoginAction {
Close,
Password,
Browser,
CancelBrowser,
}
impl LoginDialog {
pub fn show(&mut self, ctx: &egui::Context) -> Option<LoginAction> {
let mut action = None;
let close = widgets::modal(ctx, Id::new("login"), 420.0, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Sign in to ArtCraft", 24.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
action = Some(LoginAction::Close);
}
});
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Use your ArtCraft account to generate images and videos.").color(theme::MUTED));
ui.add_space(16.0);
let width = ui.available_width();
match &self.device {
Some((url, code)) => {
widgets::hud(ui, "Confirmation code", theme::FAINT);
ui.add_space(6.0);
ui.label(egui::RichText::new(code).size(28.0).family(theme::mono_bold()).color(theme::INK).extra_letter_spacing(4.0));
ui.add_space(6.0);
ui.label(egui::RichText::new("Approve the sign-in in your browser and check the code matches. This window updates by itself.").color(theme::MUTED));
ui.add_space(10.0);
ui.horizontal(|ui| {
if widgets::button(ui, Some(Icon::ExternalLink), "Open page again", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
action = Some(LoginAction::CancelBrowser);
}
});
},
None => {
if ui.add_enabled_ui(!self.busy, |ui| widgets::button(ui, Some(Icon::Globe), "Sign in with browser", widgets::Kind::Primary, width, 40.0)).inner.clicked() {
action = Some(LoginAction::Browser);
}
ui.add_space(14.0);
divider_with_label(ui, "or");
ui.add_space(14.0);
widgets::hud(ui, "Username or email", theme::FAINT);
ui.add_space(4.0);
let user_resp = text_field(ui, &mut self.username, false, "you@example.com");
ui.add_space(10.0);
widgets::hud(ui, "Password", theme::FAINT);
ui.add_space(4.0);
let pass_resp = text_field(ui, &mut self.password, true, "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}");
let submitted = (user_resp.lost_focus() || pass_resp.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.add_space(14.0);
let ready = !self.username.trim().is_empty() && !self.password.is_empty() && !self.busy;
let clicked = ui.add_enabled_ui(ready, |ui| widgets::button(ui, if self.busy { None } else { Some(Icon::LogIn) }, if self.busy { "Signing in\u{2026}" } else { "Sign in" }, widgets::Kind::Secondary, width, 40.0)).inner.clicked();
if ready && (clicked || submitted) {
action = Some(LoginAction::Password);
}
},
}
if let Some(error) = &self.error {
ui.add_space(10.0);
ui.label(egui::RichText::new(error).color(theme::BAD));
}
ui.add_space(16.0);
ui.horizontal(|ui| {
widgets::hud_link(ui, "Create account", &format!("{WEB_APP_URL}/signup"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::hud_link(ui, "Forgot password", &format!("{WEB_APP_URL}/forgot-password"));
});
});
});
if close && action.is_none() {
action = Some(if self.device.is_some() { LoginAction::CancelBrowser } else { LoginAction::Close });
}
action
}
}
#[derive(Debug, PartialEq)]
pub enum SettingsAction {
Close,
SignOut,
SignIn,
}
/// The settings dialog (Settings → Misc and Account in the webapp).
pub fn settings_dialog(ctx: &egui::Context, enter_to_generate: &mut bool, username: Option<&str>, credits: Option<u64>, data_root: &std::path::Path) -> Option<SettingsAction> {
let mut action = None;
let close = widgets::modal(ctx, Id::new("settings"), 520.0, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Settings", 24.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
action = Some(SettingsAction::Close);
}
});
});
ui.add_space(14.0);
widgets::hud(ui, "General", theme::FAINT);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(400.0);
ui.label(egui::RichText::new("Enter to generate").size(14.0).family(theme::medium()));
ui.label(egui::RichText::new("When on, pressing Enter submits the prompt and Shift+Enter adds a new line. When off (default), both Enter and Shift+Enter add a new line - use only the button to submit.").size(12.5).color(theme::MUTED));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if switch(ui, *enter_to_generate).clicked() {
*enter_to_generate = !*enter_to_generate;
}
});
});
ui.add_space(18.0);
widgets::hud(ui, "Account", theme::FAINT);
ui.add_space(6.0);
match username {
Some(user) => {
ui.label(egui::RichText::new(format!("Signed in as @{user}")).size(14.0));
if let Some(c) = credits {
ui.label(egui::RichText::new(format!("{} credits", group_thousands(c))).size(12.5).color(theme::MUTED));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if widgets::button(ui, Some(Icon::Gem), "Buy credits", widgets::Kind::Accent, 0.0, theme::CONTROL_H).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
if widgets::button(ui, Some(Icon::LogOut), "Log out", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
action = Some(SettingsAction::SignOut);
}
});
},
None => {
if widgets::button(ui, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {
action = Some(SettingsAction::SignIn);
}
},
}
ui.add_space(18.0);
widgets::hud(ui, "About", theme::FAINT);
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("ArtCraft native {}", env!("CARGO_PKG_VERSION"))).size(13.0).color(theme::MUTED));
if widgets::button(ui, Some(Icon::Folder), "Open data folder", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
if let Err(err) = open::that(data_root) {
log::warn!("Couldn't open {}: {err}", data_root.display());
}
}
});
if close && action.is_none() {
action = Some(SettingsAction::Close);
}
action
}
/// A single-line input on the sunken surface with a hairline (and a blue line while focused).
fn text_field(ui: &mut Ui, value: &mut String, password: bool, hint: &str) -> egui::Response {
let id = ui.next_auto_id();
let focused = ui.memory(|m| m.has_focus(id));
egui::Frame::new().fill(theme::SUNKEN).stroke(Stroke::new(1.0, if focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 8)).show(ui, |ui| ui.add(egui::TextEdit::singleline(value).id(id).password(password).frame(egui::Frame::NONE).desired_width(f32::INFINITY).hint_text(egui::RichText::new(hint).color(theme::FAINT)))).inner
}
/// An on/off switch.
fn switch(ui: &mut Ui, on: bool) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 20.0), Sense::click());
let t = ui.ctx().animate_bool(resp.id, on);
let fill = if on { theme::ACCENT } else { theme::WASH_HOVER };
ui.painter().rect(rect, egui::CornerRadius::same(10), fill, Stroke::new(1.0, theme::LINE), egui::StrokeKind::Inside);
let x = egui::lerp(rect.left() + 10.0..=rect.right() - 10.0, t);
ui.painter().circle_filled(pos2(x, rect.center().y), 7.0, Color32::WHITE);
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn divider_with_label(ui: &mut Ui, label: &str) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::hover());
let galley = widgets::caps(ui, label, 10.0, theme::mono(), 1.5, theme::FAINT);
let gap = galley.size().x / 2.0 + 10.0;
let p = ui.painter();
p.hline(rect.left()..=rect.center().x - gap, rect.center().y, theme::hairline());
p.hline(rect.center().x + gap..=rect.right(), rect.center().y, theme::hairline());
p.galley(rect.center() - galley.size() / 2.0, galley, theme::FAINT);
}
@@ -0,0 +1,197 @@
//! "Pick from library" (`GalleryModal mode="select"`): the user's images, videos or audio in a
//! grid, choosing up to the free reference slots.
use std::collections::HashSet;
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::backend::wire::MediaFile;
use crate::feed::types::MediaKind;
use crate::prompt_box::deck::cover_uv;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
const TILE_GAP: f32 = 8.0;
const COLUMNS: usize = 5;
pub struct LibraryPicker {
pub kind: RefKind,
pub slot: ImageSlot,
/// Which page asked (the picked media goes back there).
pub page: MediaKind,
pub max: usize,
pub items: Vec<MediaFile>,
pub loading: bool,
pub has_more: bool,
pub next_page: u32,
/// Already attached: shown but not selectable.
pub attached: HashSet<String>,
selected: Vec<String>,
}
#[derive(Debug)]
pub enum PickerAction {
Close,
Confirm(Vec<RefMedia>),
LoadMore,
}
impl LibraryPicker {
pub fn new(kind: RefKind, slot: ImageSlot, page: MediaKind, max: usize, attached: HashSet<String>) -> Self {
Self { kind, slot, page, max: max.max(1), items: Vec::new(), loading: false, has_more: true, next_page: 0, attached, selected: Vec::new() }
}
/// The library's media class filter for this picker.
pub fn media_class(&self) -> &'static str {
match self.kind {
RefKind::Image => "image",
RefKind::Video => "video",
RefKind::Audio => "audio",
}
}
pub fn apply_page(&mut self, page: u32, files: Vec<MediaFile>, total_pages: u32) {
self.loading = false;
if page != self.next_page {
return;
}
for f in files {
if !self.items.iter().any(|i| i.token == f.token) {
self.items.push(f);
}
}
self.next_page = page + 1;
self.has_more = self.next_page < total_pages;
}
pub fn show(&mut self, ctx: &egui::Context, cache: &mut MediaCache) -> Vec<PickerAction> {
let mut actions = Vec::new();
let screen = ctx.content_rect();
let width = (screen.width() - 80.0).min(960.0);
let height = (screen.height() - 120.0).min(680.0);
let noun = match self.kind {
RefKind::Image => "images",
RefKind::Video => "videos",
RefKind::Audio => "audio",
};
let close = widgets::modal(ctx, Id::new("library-picker"), width, |ui| {
ui.horizontal(|ui| {
ui.label(widgets::heading("Pick from library", 22.0));
ui.add_space(8.0);
widgets::hud(ui, noun, theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
actions.push(PickerAction::Close);
}
});
});
ui.add_space(10.0);
let grid_h = height - 120.0;
egui::ScrollArea::vertical().max_height(grid_h).min_scrolled_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
let w = ui.available_width();
let tile = (w - TILE_GAP * (COLUMNS - 1) as f32) / COLUMNS as f32;
let rows = self.items.len().div_ceil(COLUMNS);
let total = rows as f32 * (tile + TILE_GAP);
let origin = ui.min_rect().min;
ui.allocate_exact_size(vec2(w, total + 48.0), Sense::hover());
let mut toggled = None;
for (i, item) in self.items.iter().enumerate() {
let local = Rect::from_min_size(pos2((i % COLUMNS) as f32 * (tile + TILE_GAP), (i / COLUMNS) as f32 * (tile + TILE_GAP)), vec2(tile, tile));
if !local.intersects(viewport.expand(tile)) {
continue;
}
let rect = local.translate(origin.to_vec2());
let attached = self.attached.contains(&item.token);
let order = self.selected.iter().position(|t| *t == item.token);
if tile_ui(ui, rect, item, self.kind, attached, order, cache) {
toggled = Some(item.token.clone());
}
}
if let Some(token) = toggled {
self.toggle(token);
}
if self.items.is_empty() && !self.loading && !self.has_more {
ui.painter().text(origin + vec2(w / 2.0, 80.0), Align2::CENTER_CENTER, format!("No {noun} in your library yet"), FontId::new(14.0, egui::FontFamily::Proportional), theme::MUTED);
}
if self.loading {
icons::paint_spinner(ui, Rect::from_center_size(origin + vec2(w / 2.0, total + 24.0), vec2(24.0, 24.0)), theme::MUTED);
} else if self.has_more && viewport.max.y + 300.0 >= total {
actions.push(PickerAction::LoadMore);
}
});
ui.add_space(12.0);
ui.horizontal(|ui| {
widgets::hud(ui, &format!("{}/{} selected", self.selected.len(), self.max), theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let any = !self.selected.is_empty();
if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Use selected", widgets::Kind::Primary, 0.0, theme::CONTROL_H)).inner.clicked() {
actions.push(PickerAction::Confirm(self.picks()));
}
if ui.add_enabled_ui(any, |ui| widgets::button(ui, None, "Deselect all", widgets::Kind::Secondary, 0.0, theme::CONTROL_H)).inner.clicked() {
self.selected.clear();
}
});
});
});
if close {
actions.push(PickerAction::Close);
}
actions
}
fn toggle(&mut self, token: String) {
if let Some(i) = self.selected.iter().position(|t| *t == token) {
self.selected.remove(i);
} else if self.max == 1 {
self.selected = vec![token];
} else if self.selected.len() < self.max {
self.selected.push(token);
}
}
fn picks(&self) -> Vec<RefMedia> {
self.selected.iter().filter_map(|t| self.items.iter().find(|i| i.token == *t)).map(|f| RefMedia::from_library(self.kind, f.token.clone(), f.media_links.thumbnail(256), Some(f.media_links.cdn_url.clone()), f.maybe_duration_millis.map_or(0.0, |ms| ms as f32 / 1000.0))).collect()
}
}
/// One square tile; returns whether it was clicked (and is selectable).
fn tile_ui(ui: &mut egui::Ui, rect: Rect, item: &MediaFile, kind: RefKind, attached: bool, order: Option<usize>, cache: &mut MediaCache) -> bool {
let resp = ui.interact(rect, Id::new(("picker-tile", &item.token)), if attached { Sense::hover() } else { Sense::click() });
let p = ui.painter();
p.rect_filled(rect, 0.0, theme::fade(theme::CONTROLS, 0.6));
match item.media_links.thumbnail(256).map(|u| cache.get(ui.ctx(), &u)) {
Some(Lookup::Ready(t)) => {
let image = egui::Image::new(&t).uv(cover_uv(t.size_vec2()));
let image = if attached { image.tint(Color32::from_gray(90)) } else { image };
image.paint_at(ui, rect);
},
_ => {
let icon = match kind {
RefKind::Image => Icon::Image,
RefKind::Video => Icon::Video,
RefKind::Audio => Icon::Music,
};
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(26.0, 26.0)), icon, theme::FAINT);
},
}
if let Some(ms) = item.maybe_duration_millis.filter(|_| kind != RefKind::Image) {
let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 18.0), rect.max);
ui.painter().rect_filled(strip, 0.0, Color32::from_black_alpha(178));
ui.painter().text(strip.center(), Align2::CENTER_CENTER, format!("{:.0}s", ms as f32 / 1000.0), FontId::new(11.0, theme::semibold()), Color32::WHITE);
}
let p = ui.painter();
if attached {
p.text(rect.center(), Align2::CENTER_CENTER, "Attached", FontId::new(11.0, theme::semibold()), theme::fade(Color32::WHITE, 0.7));
} else if let Some(i) = order {
p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside);
let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(22.0, 22.0));
p.rect_filled(chip, theme::RADIUS, theme::ACCENT_400);
p.text(chip.center(), Align2::CENTER_CENTER, (i + 1).to_string(), FontId::new(11.0, theme::semibold()), Color32::WHITE);
} else if resp.hovered() {
p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)), egui::StrokeKind::Inside);
}
!attached && resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
+277
View File
@@ -0,0 +1,277 @@
//! The lightbox (`lightbox-modal`): the media large on black, prev/next through the feed, and a
//! side column with the prompt, details and actions.
use egui::{Align2, Color32, FontId, Id, Key, Rect, Sense, Ui, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::backend::wire::Prompt;
use crate::feed::grid::ModelNames;
use crate::feed::types::{FeedItem, MediaKind};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::{creator_icons, widgets};
const SIDE_WIDTH: f32 = 280.0;
pub struct Lightbox {
pub kind: MediaKind,
pub token: String,
confirm_delete: bool,
prompt_expanded: bool,
}
#[derive(Debug, PartialEq)]
pub enum LightboxAction {
Close,
Navigate(String),
Recreate,
MakeVideo,
Download,
Share,
Delete,
CopyPrompt(String),
Play(String),
}
impl Lightbox {
pub fn new(kind: MediaKind, token: String) -> Self {
Self { kind, token, confirm_delete: false, prompt_expanded: false }
}
pub fn show(&mut self, ctx: &egui::Context, item: &FeedItem, order: &[String], prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache) -> Vec<LightboxAction> {
let mut actions = Vec::new();
let screen = ctx.content_rect();
let size = vec2((screen.width() - 80.0).min(1200.0), (screen.height() - 80.0).min(760.0));
let index = order.iter().position(|t| *t == item.token);
let prev = index.and_then(|i| i.checked_sub(1)).map(|i| order[i].clone());
let next = index.and_then(|i| order.get(i + 1)).cloned();
let typing = ctx.memory(|m| m.focused().is_some());
if !typing && !self.confirm_delete {
let (left, right) = ctx.input(|i| (i.key_pressed(Key::ArrowLeft) || i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowRight) || i.key_pressed(Key::ArrowDown)));
if let (true, Some(t)) = (left, &prev) {
actions.push(LightboxAction::Navigate(t.clone()));
}
if let (true, Some(t)) = (right, &next) {
actions.push(LightboxAction::Navigate(t.clone()));
}
}
let close = widgets::modal(ctx, Id::new("lightbox"), size.x, |ui| {
ui.set_height(size.y - 32.0);
ui.horizontal_top(|ui| {
let media_rect = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width() - SIDE_WIDTH - 16.0, size.y - 32.0));
ui.allocate_rect(media_rect, Sense::hover());
self.media(ui, media_rect, item, cache, &prev, &next, &mut actions);
ui.add_space(16.0);
ui.vertical(|ui| {
ui.set_width(SIDE_WIDTH);
self.side(ui, item, prompt, names, cache, &mut actions);
});
});
});
if close && !self.confirm_delete {
actions.push(LightboxAction::Close);
}
if self.confirm_delete {
self.confirm(ctx, &mut actions);
}
actions
}
#[allow(clippy::too_many_arguments)]
fn media(&self, ui: &mut Ui, rect: Rect, item: &FeedItem, cache: &mut MediaCache, prev: &Option<String>, next: &Option<String>, actions: &mut Vec<LightboxAction>) {
ui.painter().rect_filled(rect, 0.0, Color32::BLACK);
let url = if item.kind == MediaKind::Image { Some(item.full_url.as_str()) } else { item.thumbnail.as_deref() };
match url.map(|u| cache.get(ui.ctx(), u)) {
Some(Lookup::Ready(texture)) => {
let fit = fit_inside(texture.size_vec2(), rect.shrink(8.0).size());
egui::Image::new(&texture).paint_at(ui, Rect::from_center_size(rect.center(), fit));
},
Some(Lookup::Loading) => {
// Show the thumbnail while the full image loads.
if let Some(Lookup::Ready(t)) = item.thumbnail.as_deref().map(|u| cache.get(ui.ctx(), u)) {
let fit = fit_inside(t.size_vec2(), rect.shrink(8.0).size());
egui::Image::new(&t).paint_at(ui, Rect::from_center_size(rect.center(), fit));
}
icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(32.0, 32.0)), theme::MUTED);
},
_ => {
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "Image not available", FontId::new(14.0, egui::FontFamily::Proportional), theme::FAINT);
},
}
if item.kind == MediaKind::Video {
let play = Rect::from_center_size(rect.center(), vec2(72.0, 72.0));
let resp = ui.interact(play, Id::new(("lightbox-play", &item.token)), Sense::click()).on_hover_text("Play in your video player").on_hover_cursor(egui::CursorIcon::PointingHand);
ui.painter().circle_filled(play.center(), 36.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 }));
icons::paint(ui.painter(), play.shrink(22.0).translate(vec2(3.0, 0.0)), Icon::Play, Color32::WHITE);
if resp.clicked() {
actions.push(LightboxAction::Play(item.full_url.clone()));
}
}
let hovering = ui.rect_contains_pointer(rect);
for (target, left, tip) in [(prev, true, "Previous item"), (next, false, "Next item")] {
let Some(token) = target else {
continue;
};
let x = if left { rect.left() + 28.0 } else { rect.right() - 28.0 };
let button = Rect::from_center_size(pos2(x, rect.center().y), vec2(40.0, 40.0));
let resp = ui.interact(button, Id::new(("lightbox-nav", tip)), Sense::click()).on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand);
if hovering || resp.hovered() {
ui.painter().circle_filled(button.center(), 20.0, Color32::from_black_alpha(if resp.hovered() { 210 } else { 140 }));
icons::paint(ui.painter(), button.shrink(11.0), if left { Icon::ChevronLeft } else { Icon::ChevronRight }, Color32::WHITE);
}
if resp.clicked() {
actions.push(LightboxAction::Navigate(token.clone()));
}
}
}
fn side(&mut self, ui: &mut Ui, item: &FeedItem, prompt: Option<&Prompt>, names: &dyn ModelNames, cache: &mut MediaCache, actions: &mut Vec<LightboxAction>) {
ui.horizontal(|ui| {
widgets::hud(ui, item.kind.label(), theme::MUTED);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::X, None, "Close", false).clicked() {
actions.push(LightboxAction::Close);
}
});
});
ui.add_space(8.0);
let text = prompt.and_then(|p| p.maybe_positive_prompt.clone()).filter(|t| !t.is_empty());
ui.horizontal(|ui| {
widgets::hud(ui, "Prompt", theme::FAINT);
if let Some(t) = &text {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new("Copy").size(11.0)).frame(false)).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(LightboxAction::CopyPrompt(t.clone()));
}
});
}
});
match (&text, item.prompt_token.is_some(), prompt.is_some()) {
(Some(t), _, _) => {
let mut job = egui::text::LayoutJob::simple(t.clone(), FontId::new(13.0, egui::FontFamily::Proportional), theme::INK, ui.available_width());
if !self.prompt_expanded {
job.wrap.max_rows = 4;
}
let galley = ui.painter().layout_job(job);
let clipped = galley.elided;
ui.label(galley);
if clipped || self.prompt_expanded {
let label = if self.prompt_expanded { "Hide" } else { "See all" };
if ui.add(egui::Button::new(egui::RichText::new(label).size(11.5).color(theme::ACCENT_INK)).frame(false)).clicked() {
self.prompt_expanded = !self.prompt_expanded;
}
}
},
(None, true, false) => {
let (rect, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint_spinner(ui, rect, theme::FAINT);
},
_ => {
ui.label(egui::RichText::new("No prompt").size(13.0).color(theme::FAINT));
},
}
ui.add_space(14.0);
widgets::hud(ui, "Information", theme::FAINT);
ui.add_space(4.0);
if let Some(model) = item.model_id.as_deref() {
info_row(ui, "Model", |ui| {
let tex = creator_icons::texture(ui.ctx(), &names.creator(model));
ui.add(egui::Image::new(&tex).tint(theme::INK).fit_to_exact_size(vec2(13.0, 13.0)));
ui.label(egui::RichText::new(names.display_name(model)).size(12.5));
});
}
if let Some(p) = prompt {
if let Some(ar) = &p.maybe_aspect_ratio {
info_row(ui, "Aspect Ratio", |ui| {
ui.label(egui::RichText::new(crate::models::aspect_label(ar)).size(12.5));
});
}
if let Some(res) = &p.maybe_resolution {
info_row(ui, "Resolution", |ui| {
ui.label(egui::RichText::new(crate::models::resolution_label(res)).size(12.5));
});
}
}
if let Some(d) = item.duration_secs {
info_row(ui, "Duration", |ui| {
ui.label(egui::RichText::new(format!("{d:.1}s")).size(12.5));
});
}
if let Some(Lookup::Ready(t)) = (item.kind == MediaKind::Image).then(|| cache.get(ui.ctx(), &item.full_url)) {
let s = t.size();
info_row(ui, "Size", |ui| {
ui.label(egui::RichText::new(format!("{} \u{d7} {}", s[0], s[1])).size(12.5));
});
}
if let Some(created) = chrono::DateTime::from_timestamp(item.created_at, 0) {
let local = created.with_timezone(&chrono::Local);
info_row(ui, "Created", |ui| {
ui.label(egui::RichText::new(local.format("%b %-d, %Y %-I:%M:%S %p").to_string()).size(12.5));
});
}
ui.add_space(16.0);
let w = (ui.available_width() - 8.0) / 2.0;
if item.prompt_token.is_some() && widgets::button(ui, Some(Icon::RotateCw), "Recreate", widgets::Kind::Primary, ui.available_width(), theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Recreate);
}
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
if item.kind == MediaKind::Image && widgets::button(ui, Some(Icon::Video), "Make Video", widgets::Kind::Accent, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::MakeVideo);
}
if widgets::button(ui, Some(Icon::Download), "Download", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Download);
}
if widgets::button(ui, Some(Icon::Link), "Share", widgets::Kind::Secondary, w, theme::CONTROL_H).clicked() {
actions.push(LightboxAction::Share);
}
if widgets::button(ui, Some(Icon::Trash), "Delete", widgets::Kind::Danger, w, theme::CONTROL_H).clicked() {
self.confirm_delete = true;
}
});
}
fn confirm(&mut self, ctx: &egui::Context, actions: &mut Vec<LightboxAction>) {
let mut choice = None;
let close = widgets::modal(ctx, Id::new("lightbox-delete"), 420.0, |ui| {
ui.label(widgets::heading("Delete this media?", 20.0));
ui.add_space(8.0);
ui.label(egui::RichText::new("This will permanently remove the media from your library. This action cannot be undone.").color(theme::MUTED));
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Delete", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {
choice = Some(true);
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
choice = Some(false);
}
});
});
if close || choice.is_some() {
self.confirm_delete = false;
}
if choice == Some(true) {
actions.push(LightboxAction::Delete);
}
}
}
fn info_row(ui: &mut Ui, label: &str, value: impl FnOnce(&mut Ui)) {
ui.horizontal(|ui| {
let (rect, _) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::hover());
ui.painter().text(rect.left_center(), Align2::LEFT_CENTER, label, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED);
value(ui);
});
}
/// The largest size with `content`'s aspect that fits in `bounds`.
pub fn fit_inside(content: egui::Vec2, bounds: egui::Vec2) -> egui::Vec2 {
if content.x <= 0.0 || content.y <= 0.0 {
return bounds;
}
let scale = (bounds.x / content.x).min(bounds.y / content.y);
content * scale
}
+3
View File
@@ -0,0 +1,3 @@
pub mod account;
pub mod library_picker;
pub mod lightbox;
+345
View File
@@ -0,0 +1,345 @@
//! What the create pages share: the page shell (`DesktopCreatePageShell`), uploading references,
//! cost-estimate bookkeeping and the app-level requests pages make.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use egui::{Align2, Color32, Id, Mesh, Order, Rect, Ui, pos2, vec2};
use serde_json::{Map, Value};
use crate::backend::media_cache::MediaCache;
use crate::backend::{Backend, Modality, UploadedMedia};
use crate::feed::grid::{self, FeedAction, FeedView, RatioCache, ViewMode};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::Catalog;
use crate::prompt_box;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References};
use crate::theme;
use crate::ui::toast::Toasts;
use crate::ui::widgets;
/// How long settings must sit still before a cost estimate is requested.
const COST_DEBOUNCE_SECS: f64 = 0.3;
/// Gap between the prompt box and the window's bottom edge (`bottom-4`).
const BOX_BOTTOM: f32 = 16.0;
/// The app services a page can use this frame.
pub struct Env<'a> {
pub ctx: egui::Context,
pub backend: &'a Backend,
pub cache: &'a mut MediaCache,
pub toasts: &'a mut Toasts,
pub catalog: &'a Catalog,
pub signed_in: bool,
pub enter_to_generate: bool,
pub view_mode: ViewMode,
pub ratios: &'a mut RatioCache,
pub prompts: &'a std::collections::HashMap<String, String>,
pub requests: &'a mut Vec<AppRequest>,
}
/// Things only the app can do.
#[derive(Debug)]
pub enum AppRequest {
SignIn,
Open { kind: MediaKind, token: String },
PickFromLibrary { kind: RefKind, slot: ImageSlot, max: usize, page: MediaKind },
MakeVideo { token: String },
Recreate { kind: MediaKind, token: String },
Share(String),
Download(Vec<String>),
CopyText(String),
Preview(String),
LoadMoreLibrary(FeedKey),
}
/// Which feed a library page is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FeedKey {
Image,
Video,
Library,
}
impl FeedKey {
pub fn of(kind: MediaKind) -> Self {
match kind {
MediaKind::Image => FeedKey::Image,
MediaKind::Video => FeedKey::Video,
}
}
/// The library's media class filter.
pub fn media_classes(self) -> &'static str {
match self {
FeedKey::Image => "image",
FeedKey::Video => "video",
FeedKey::Library => "image,video",
}
}
}
/// The page around the feed: the splash empty state (or the feed), a fade behind the prompt box,
/// and the prompt box (drawn by `prompt_box`) floating at the bottom centre.
#[allow(clippy::too_many_arguments)]
pub fn create_shell(ui: &mut Ui, env: &mut Env<'_>, kind: MediaKind, title: &str, subtitle: &str, feed: &mut FeedStore, box_height: &mut f32, prompt_box: impl FnOnce(&mut Ui, &mut Env<'_>)) {
let area = ui.max_rect();
let bottom_offset = *box_height + 40.0;
if feed.has_content() {
let view = FeedView { id: Id::new(("feed", kind.label())), mode: env.view_mode, pending: &feed.pending, failed: &feed.failed, items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: kind == MediaKind::Image, bottom_padding: bottom_offset + 24.0 };
let mut feed_ui = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0))));
let actions = grid::show(&mut feed_ui, &view, env.cache, env.ratios, env.catalog);
handle_feed_actions(env, kind, feed, actions);
bottom_fade(ui, area);
} else {
empty_state(ui, env, area, bottom_offset, title, subtitle);
}
if env.signed_in && !feed.library_started() {
env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind)));
}
if feed.selecting {
selection_bar(ui, area, *box_height + 32.0, feed, kind, env.requests);
}
let width = (area.width() - 32.0).min(prompt_box::MAX_WIDTH);
let pos = pos2(area.center().x, area.bottom() - BOX_BOTTOM);
let response = egui::Area::new(Id::new(("prompt-box", kind.label()))).order(Order::Middle).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| {
ui.set_width(width);
prompt_box(ui, env);
});
*box_height = response.response.rect.height();
}
fn handle_feed_actions(env: &mut Env<'_>, kind: MediaKind, feed: &mut FeedStore, actions: Vec<FeedAction>) {
for action in actions {
match action {
FeedAction::Open(token) => {
feed.last_viewed = Some(token.clone());
env.requests.push(AppRequest::Open { kind, token });
},
FeedAction::ToggleSelect(token) => {
if !feed.selected.remove(&token) {
feed.selected.insert(token);
}
},
FeedAction::DismissFailed(job) => feed.dismiss(&job),
FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::of(kind))),
FeedAction::Recreate(token) => env.requests.push(AppRequest::Recreate { kind, token }),
FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }),
FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)),
FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])),
FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)),
}
}
}
/// The floating "N selected · Download · ×" pill (`SelectionActionBar`), `bottom` above the
/// window's bottom edge.
pub fn selection_bar(ui: &Ui, area: Rect, bottom: f32, feed: &mut FeedStore, kind: MediaKind, requests: &mut Vec<AppRequest>) {
let pos = pos2(area.center().x, area.bottom() - bottom);
egui::Area::new(Id::new(("selection-bar", kind.label()))).order(Order::Foreground).pivot(Align2::CENTER_BOTTOM).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::new().fill(theme::fade(theme::PANEL, 0.95)).stroke(theme::hairline()).corner_radius(egui::CornerRadius::same(24)).inner_margin(egui::Margin::symmetric(10, 8)).shadow(theme::shadow()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
ui.add_space(6.0);
ui.label(egui::RichText::new(format!("{} selected", feed.selected.len())).size(13.0).family(theme::medium()));
let any = !feed.selected.is_empty();
if ui.add_enabled_ui(any, |ui| widgets::button(ui, Some(crate::ui::icons::Icon::Download), "Download", widgets::Kind::Secondary, 0.0, 30.0)).inner.clicked() {
requests.push(AppRequest::Download(feed.selected.iter().cloned().collect()));
}
if widgets::ghost(ui, crate::ui::icons::Icon::X, None, "Exit selection", false).clicked() {
feed.selecting = false;
feed.selected.clear();
}
});
});
});
}
/// The splash: a bordered section with corner marks, the page title and a sign-in prompt for
/// visitors (`CreateEmptyState`).
fn empty_state(ui: &mut Ui, env: &mut Env<'_>, area: Rect, bottom_offset: f32, title: &str, subtitle: &str) {
let usable = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - bottom_offset));
let width = (usable.width() - 64.0).min(768.0);
let height = if env.signed_in { 190.0 } else { 250.0 };
let section = Rect::from_center_size(usable.center(), vec2(width, height));
let p = ui.painter();
p.rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside);
widgets::corner_marks(p, section);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 52.0))));
child.label(widgets::heading(title, 48.0));
child.add_space(12.0);
child.label(egui::RichText::new(subtitle).size(16.0).color(theme::MUTED));
if !env.signed_in {
child.add_space(24.0);
if widgets::button(&mut child, Some(crate::ui::icons::Icon::Sparkles), "Sign in to create", widgets::Kind::Primary, 0.0, 44.0).clicked() {
env.requests.push(AppRequest::SignIn);
}
}
}
/// The 192 px fade behind the floating prompt box (`h-48 from-ui-background`).
fn bottom_fade(ui: &Ui, area: Rect) {
let band = Rect::from_min_max(pos2(area.left(), area.bottom() - 192.0), area.max);
let mut mesh = Mesh::default();
let (top, bottom) = (Color32::TRANSPARENT, theme::BG);
mesh.colored_vertex(band.left_top(), top);
mesh.colored_vertex(band.right_top(), top);
mesh.colored_vertex(band.right_bottom(), bottom);
mesh.colored_vertex(band.left_bottom(), bottom);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
// Same layer as the feed (painted after it), so it stays under the floating prompt box.
ui.painter().add(mesh);
}
/// The selected model, else the page default, else the first.
pub fn selected_model<'a>(models: &'a [crate::models::ModelInfo], selected: Option<&str>, default_id: &str) -> Option<&'a crate::models::ModelInfo> {
selected.and_then(|id| models.iter().find(|m| m.id == id)).or_else(|| models.iter().find(|m| m.id == default_id)).or_else(|| models.first())
}
// --- References -------------------------------------------------------------------------------
/// Starts uploading `path` into `slot` (or the deck for videos and audio).
pub fn upload_path(env: &mut Env<'_>, refs: &mut References, path: &Path, kind: RefKind, slot: ImageSlot) {
if kind == RefKind::Video && !path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("mp4")) {
env.toasts.error("Failed to upload video. Please upload an MP4 file.");
return;
}
let mut item = RefMedia::uploading(kind, None);
if kind == RefKind::Image {
let key = format!("local://{}", item.id);
match std::fs::read(path) {
Ok(bytes) => {
env.cache.insert_bytes(&env.ctx, &key, Arc::from(bytes));
item.preview = Some(key);
},
Err(err) => {
env.toasts.error(format!("Couldn't read {}: {err}", file_name(path)));
return;
},
}
}
env.backend.upload_file(item.id, kind, path.to_path_buf());
place(refs, item, slot);
}
/// Uploads a pasted image (PNG bytes) into `slot`.
pub fn upload_png(env: &mut Env<'_>, refs: &mut References, png: Vec<u8>, slot: ImageSlot) {
let mut item = RefMedia::uploading(RefKind::Image, None);
let key = format!("local://{}", item.id);
env.cache.insert_bytes(&env.ctx, &key, Arc::from(png.clone()));
item.preview = Some(key);
env.backend.upload_png(item.id, png);
place(refs, item, slot);
}
fn place(refs: &mut References, item: RefMedia, slot: ImageSlot) {
match (item.kind, slot) {
(RefKind::Image, ImageSlot::FirstFrame) => refs.first_frame = Some(item),
(RefKind::Image, ImageSlot::LastFrame) => refs.last_frame = Some(item),
(kind, _) => refs.list_mut(kind).push(item),
}
}
/// Applies a finished upload. Returns `false` (and drops the reference) when it failed.
pub fn apply_upload(toasts: &mut Toasts, refs: &mut References, ref_id: u64, result: Result<UploadedMedia, String>) -> bool {
match result {
Ok(media) => {
if let Some(r) = refs.find_mut(ref_id) {
r.status = RefStatus::Ready;
r.token = Some(media.token);
r.duration_secs = media.duration_secs;
r.full_url = media.full_url;
if r.preview.is_none() {
r.preview = media.thumbnail;
}
}
true
},
Err(message) => {
let kind = refs.find_mut(ref_id).map_or("file", |r| r.kind.noun());
refs.remove(ref_id);
toasts.error(format!("Failed to upload {kind}: {message}"));
false
},
}
}
/// Picked library media, ready to attach.
pub fn attach_from_library(refs: &mut References, kind: RefKind, slot: ImageSlot, picks: Vec<RefMedia>) {
for pick in picks {
let already = refs.list(kind).iter().chain(refs.first_frame.iter()).chain(refs.last_frame.iter()).any(|r| r.token == pick.token);
if !already {
place(refs, RefMedia { kind, ..pick }, slot);
}
}
}
/// Asks the user for files of `kinds` (`multiple` for decks).
pub fn pick_files(kinds: &[RefKind], multiple: bool) -> Vec<PathBuf> {
let mut dialog = rfd::FileDialog::new();
for kind in kinds {
let (name, exts): (&str, &[&str]) = match kind {
RefKind::Image => ("Images", &["png", "jpg", "jpeg", "webp", "gif", "bmp", "avif"]),
RefKind::Video => ("Videos (MP4)", &["mp4"]),
RefKind::Audio => ("Audio", &["mp3", "wav", "ogg", "flac", "aac", "m4a", "opus"]),
};
dialog = dialog.add_filter(name, exts);
}
if multiple {
dialog.pick_files().unwrap_or_default()
} else {
dialog.pick_file().into_iter().collect()
}
}
pub fn file_name(path: &Path) -> String {
path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_owned()
}
// --- Cost estimates ---------------------------------------------------------------------------
/// The latest cost estimate for a page, requested when the settings settle.
#[derive(Default)]
pub struct CostState {
/// The request the estimate below is for.
key: String,
pub credits: Option<u64>,
pending_key: Option<(String, f64)>,
}
impl CostState {
/// Requests an estimate for `fields` once they've been stable for a moment.
pub fn update(&mut self, ui: &Ui, backend: &Backend, modality: Modality, fields: &Map<String, Value>) {
let key = Value::Object(fields.clone()).to_string();
if key == self.key {
return;
}
let now = ui.input(|i| i.time);
match &self.pending_key {
Some((k, since)) if *k == key => {
if now - since >= COST_DEBOUNCE_SECS {
self.key = key.clone();
self.credits = None;
self.pending_key = None;
backend.estimate_cost(modality, key, fields.clone());
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS));
}
},
_ => {
self.pending_key = Some((key, now));
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(COST_DEBOUNCE_SECS));
},
}
}
/// Takes an estimate that arrived for `key`.
pub fn apply(&mut self, key: &str, credits: Option<u64>) {
if key == self.key {
self.credits = credits;
}
}
}
+283
View File
@@ -0,0 +1,283 @@
//! Create Image (`TextToImage` + `PromptBoxImage`): prompt, reference images, model, aspect
//! ratio, resolution, quality and count; the cost; and the image feed.
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::wire::Prompt;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::{DEFAULT_IMAGE_MODEL, DEFAULT_PROMPT_MAX, ModelInfo};
use crate::pages::common::{self, AppRequest, CostState, Env};
use crate::pages::toolbar;
use crate::prompt_box::deck::{DeckAction, DeckLimits};
use crate::prompt_box::model_selector::model_selector;
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};
use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState, editor};
const PLACEHOLDER: &str = "Describe what you want in the image...";
/// What the page remembers between launches.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ImageSettings {
pub prompt: String,
pub model: Option<String>,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub quality: Option<String>,
pub count: u16,
}
#[derive(Default)]
pub struct CreateImagePage {
pub settings: ImageSettings,
pub refs: References,
pub feed: FeedStore,
pub cost: CostState,
pub generating: bool,
box_state: PromptBoxState,
box_height: f32,
}
impl CreateImagePage {
pub fn new(settings: ImageSettings) -> Self {
let mut page = Self { settings, box_height: 140.0, ..Default::default() };
page.box_state.editor.focus_requested = true;
page
}
pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) {
let models = &env.catalog.image_page;
let model = common::selected_model(models, self.settings.model.as_deref(), DEFAULT_IMAGE_MODEL).cloned();
if let Some(m) = &model {
self.reconcile(m);
let fields = self.fields(m, true);
self.cost.update(ui, env.backend, Modality::Image, &fields);
}
let mut feed = std::mem::take(&mut self.feed);
let mut box_height = self.box_height;
common::create_shell(ui, env, MediaKind::Image, "Create Image", "Add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref()));
self.feed = feed;
self.box_height = box_height;
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) {
let max_images = model.map_or(0, |m| m.image_refs_max);
let deck = if max_images > 0 { DeckMode::References(DeckLimits { max_images, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: env.signed_in }) } else { DeckMode::None };
let accepts: &[RefKind] = if max_images > 0 { &[RefKind::Image] } else { &[] };
let prompt_max = model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max);
let props = PromptBoxProps { id: Id::new("image-prompt-box"), placeholder: PLACEHOLDER, enter_to_generate: env.enter_to_generate, mentions: &[], max_length: Some(prompt_max), deck, accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some(), generating: self.generating, generate_tooltip: "Generate", warning: None, banner: None };
let models = &env.catalog.image_page;
let settings = &mut self.settings;
let mut picked_model = None;
let mut left = |ui: &mut Ui| {
if let Some(id) = model_selector(ui, Id::new("image-model"), models, model, false) {
picked_model = Some(id);
}
let Some(m) = model else {
return;
};
let aspect = ModelInfo::resolve(settings.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref());
if let Some(v) = toolbar::aspect_ratio(ui, Id::new("image-aspect"), m, aspect.as_deref().unwrap_or("")) {
settings.aspect_ratio = Some(v);
}
let resolution = ModelInfo::resolve(settings.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref());
if let Some(v) = toolbar::resolution(ui, Id::new("image-resolution"), m, resolution.as_deref().unwrap_or("")) {
settings.resolution = Some(v);
}
let quality = ModelInfo::resolve(settings.quality.as_deref(), &m.qualities, m.quality_default.as_ref());
if let Some(v) = toolbar::quality(ui, Id::new("image-quality"), m, quality.as_deref().unwrap_or("")) {
settings.quality = Some(v);
}
};
let count = settings.count;
let mut picked_count = None;
let mut right = |ui: &mut Ui| {
if let Some(m) = model {
picked_count = toolbar::count(ui, Id::new("image-count"), m, count, "images");
}
};
let actions = prompt_box::show(ui, &mut settings.prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right);
if let Some(id) = picked_model {
self.settings.model = Some(id);
}
if let Some(n) = picked_count {
self.settings.count = n;
}
for action in actions {
self.handle(action, env, model, max_images);
}
}
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, max_images: usize) {
match action {
PromptBoxAction::Generate => self.generate(env, model),
PromptBoxAction::ClearAll => {
self.settings.prompt.clear();
self.refs.clear();
},
PromptBoxAction::Deck(deck) => match deck {
DeckAction::Upload { .. } => {
let free = max_images.saturating_sub(self.refs.images.len());
for path in common::pick_files(&[RefKind::Image], free > 1).into_iter().take(free) {
common::upload_path(env, &mut self.refs, &path, RefKind::Image, ImageSlot::Reference);
}
},
DeckAction::Library { kind, slot } => {
env.requests.push(AppRequest::PickFromLibrary { kind, slot, max: max_images.saturating_sub(self.refs.images.len()).max(1), page: MediaKind::Image });
},
DeckAction::Remove(id) => self.refs.remove(id),
DeckAction::ClearAll => self.refs.images.clear(),
DeckAction::ReorderImages { from, to } => {
let item = self.refs.images.remove(from);
self.refs.images.insert(to.min(self.refs.images.len()), item);
},
DeckAction::Preview(id) => {
if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) {
env.requests.push(AppRequest::Preview(url));
}
},
DeckAction::SwapFrames => {},
},
PromptBoxAction::DroppedFiles(paths) => self.add_dropped(env, &paths, max_images),
PromptBoxAction::PastedImage(png) => {
if self.refs.images.len() >= max_images {
env.toasts.error(max_images_message(max_images));
} else {
common::upload_png(env, &mut self.refs, png, ImageSlot::Reference);
}
},
}
}
/// Routes dropped files: images into the deck (up to the model's limit); others get a note.
fn add_dropped(&mut self, env: &mut Env<'_>, paths: &[std::path::PathBuf], max_images: usize) {
let kinds: Vec<Option<RefKind>> = paths.iter().map(|p| RefKind::from_path(p)).collect();
let images: Vec<&std::path::PathBuf> = paths.iter().zip(&kinds).filter(|(_, k)| **k == Some(RefKind::Image)).map(|(p, _)| p).collect();
let has_video = kinds.contains(&Some(RefKind::Video));
let has_audio = kinds.contains(&Some(RefKind::Audio));
if has_video || has_audio {
let what = match (has_video, has_audio) {
(true, true) => "Video or audio",
(true, false) => "Video",
_ => "Audio",
};
env.toasts.error(format!("{what} references aren't available here"));
} else if images.is_empty() {
env.toasts.error("Only image files can be added here");
}
if images.is_empty() {
return;
}
let free = max_images.saturating_sub(self.refs.images.len());
if free == 0 {
env.toasts.error(max_images_message(max_images));
return;
}
for path in images.into_iter().take(free) {
common::upload_path(env, &mut self.refs, path, RefKind::Image, ImageSlot::Reference);
}
}
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) {
if self.generating || self.settings.prompt.trim().is_empty() {
return;
}
let Some(model) = model else {
return;
};
if !env.signed_in {
env.requests.push(AppRequest::SignIn);
return;
}
let limit = model.prompt_max;
if editor::is_over_limit(&self.settings.prompt, limit) {
env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));
return;
}
if self.refs.any_uploading() {
env.toasts.error("Please wait for your references to finish uploading");
return;
}
self.generating = true;
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(self.settings.count.max(1)), ref_image: self.refs.images.first().and_then(|r| r.preview.clone()) };
env.backend.generate(Modality::Image, self.fields(model, false), meta);
}
/// The OmniGen request (`GenerateImageRequest` after `api_fields`). `estimate` swaps
/// still-uploading references for placeholders so the cost reflects them.
fn fields(&self, model: &ModelInfo, estimate: bool) -> Map<String, Value> {
let mut f = Map::new();
f.insert("model".into(), json!(model.id));
f.insert("prompt".into(), json!(self.settings.prompt));
f.insert("image_batch_count".into(), json!(model.valid_batch(self.settings.count)));
if let Some(v) = ModelInfo::resolve(self.settings.aspect_ratio.as_deref(), &model.aspect_ratios, model.aspect_default.as_ref()) {
f.insert("aspect_ratio".into(), json!(v));
}
if !model.resolutions.is_empty() {
if let Some(v) = ModelInfo::resolve(self.settings.resolution.as_deref(), &model.resolutions, model.resolution_default.as_ref()) {
f.insert("resolution".into(), json!(v));
}
}
if !model.qualities.is_empty() {
if let Some(v) = ModelInfo::resolve(self.settings.quality.as_deref(), &model.qualities, model.quality_default.as_ref()) {
f.insert("quality".into(), json!(v));
}
}
if model.image_refs_max > 0 && !self.refs.images.is_empty() {
let tokens: Vec<String> = self.refs.images.iter().enumerate().filter_map(|(i, r)| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}")))).collect();
if !tokens.is_empty() {
f.insert("image_media_tokens".into(), json!(tokens));
}
}
if estimate {
f.remove("prompt");
}
f
}
/// Keeps the count valid for the model (`isValidGenerationCount`).
fn reconcile(&mut self, model: &ModelInfo) {
let count = model.valid_batch(self.settings.count);
if count != self.settings.count {
self.settings.count = count;
}
}
pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) {
self.generating = false;
self.feed.add_enqueued(MediaKind::Image, job_tokens, meta, env.catalog);
env.toasts.success("Image generation enqueued!");
}
pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) {
self.generating = false;
env.toasts.error(if message.is_empty() { "Failed to start image generation. Please try again." } else { message });
}
/// Recreate: the original prompt, model, settings and reference images.
pub fn apply_prompt(&mut self, prompt: &Prompt) {
self.settings.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default();
if let Some(model) = &prompt.maybe_model_type {
self.settings.model = Some(model.clone());
}
if prompt.maybe_aspect_ratio.is_some() {
self.settings.aspect_ratio = prompt.maybe_aspect_ratio.clone();
}
if prompt.maybe_resolution.is_some() {
self.settings.resolution = prompt.maybe_resolution.clone();
}
if let Some(count) = prompt.maybe_batch_count {
self.settings.count = count;
}
self.refs.images = prompt.maybe_context_images.iter().flatten().filter(|c| !matches!(c.semantic.as_str(), "vid_end_frame" | "vid_ref" | "audioref")).map(|c| RefMedia::from_library(RefKind::Image, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0)).collect();
self.box_state.editor.focus_requested = true;
}
}
fn max_images_message(max: usize) -> String {
format!("Max {max} image reference{}", if max == 1 { "" } else { "s" })
}
+541
View File
@@ -0,0 +1,541 @@
//! Create Video (`ImageToVideo` + `PromptBoxVideo`): keyframe or omni-reference input, the
//! model's pickers (aspect ratio, resolution, bitrate, format, duration, sound, count), the cost,
//! and the video feed.
use std::path::PathBuf;
use egui::{Id, Ui};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::backend::wire::Prompt;
use crate::backend::{EnqueueMeta, Modality};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::models::{self, DEFAULT_PROMPT_MAX, DEFAULT_VIDEO_MODEL, ModelInfo};
use crate::pages::common::{self, AppRequest, CostState, Env};
use crate::pages::toolbar;
use crate::prompt_box::deck::{DeckAction, DeckLimits};
use crate::prompt_box::editor::{self, MentionItem, MentionKind};
use crate::prompt_box::model_selector::model_selector;
use crate::prompt_box::pickers::{self, PickOption, Trigger};
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};
use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState};
use crate::ui::icons::Icon;
use crate::ui::widgets::Leading;
const PLACEHOLDER: &str = "Describe what you want to happen in the video...";
const PLACEHOLDER_REFERENCE: &str = "Use @Image1, @Video1, @Audio1... to reference uploads in prompt...";
const TEXT_ONLY_BANNER: &str = "This model can't generate from text alone - add a starting frame to animate your prompt.";
/// How the user steers the video: first/last frames, or a mix of reference media.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputMode {
Keyframe,
#[default]
Reference,
}
/// What the page remembers between launches.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoSettings {
pub prompt: String,
pub model: Option<String>,
pub aspect_ratio: Option<String>,
pub resolution: Option<String>,
pub bitrate: Option<String>,
pub output_format: Option<String>,
pub duration: Option<u16>,
pub generate_audio: bool,
pub input_mode: InputMode,
pub count: u16,
}
impl Default for VideoSettings {
fn default() -> Self {
Self { prompt: String::new(), model: None, aspect_ratio: None, resolution: None, bitrate: None, output_format: None, duration: None, generate_audio: true, input_mode: InputMode::Reference, count: 1 }
}
}
#[derive(Default)]
pub struct CreateVideoPage {
pub settings: VideoSettings,
pub refs: References,
pub feed: FeedStore,
pub cost: CostState,
pub generating: bool,
box_state: PromptBoxState,
box_height: f32,
}
impl CreateVideoPage {
pub fn new(settings: VideoSettings) -> Self {
let mut page = Self { settings, box_height: 140.0, ..Default::default() };
page.box_state.editor.focus_requested = true;
page
}
pub fn ui(&mut self, ui: &mut Ui, env: &mut Env<'_>) {
let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned();
if let Some(m) = &model {
self.reconcile(m);
let fields = self.fields(m, true);
self.cost.update(ui, env.backend, Modality::Video, &fields);
}
let mut feed = std::mem::take(&mut self.feed);
let mut box_height = self.box_height;
common::create_shell(ui, env, MediaKind::Video, "Create Video", "Choose an image, add a prompt, then generate", &mut feed, &mut box_height, |ui, env| self.prompt_box(ui, env, model.as_ref()));
self.feed = feed;
self.box_height = box_height;
}
fn prompt_box(&mut self, ui: &mut Ui, env: &mut Env<'_>, model: Option<&ModelInfo>) {
let mode = self.settings.input_mode;
let reference = mode == InputMode::Reference;
let limits = model.map(|m| deck_limits(m, env.signed_in));
let deck = match (model, mode) {
(Some(m), InputMode::Keyframe) if m.start_frame => DeckMode::Keyframes { show_last: m.end_frame, library: env.signed_in },
(Some(_), InputMode::Reference) => limits.clone().map_or(DeckMode::None, DeckMode::References),
_ => DeckMode::None,
};
let accepts: Vec<RefKind> = match (&deck, model) {
(DeckMode::Keyframes { .. }, _) => vec![RefKind::Image],
(DeckMode::References(l), _) => [(RefKind::Image, l.max_images), (RefKind::Video, l.max_videos), (RefKind::Audio, l.max_audios)].into_iter().filter(|(_, n)| *n > 0).map(|(k, _)| k).collect(),
_ => Vec::new(),
};
let mentions = if reference { self.mentions() } else { Vec::new() };
let needs_start = model.is_some_and(|m| self.needs_start_frame(m));
let banner = model.filter(|m| !m.text_to_video && !self.has_image_input()).map(|_| TEXT_ONLY_BANNER);
let props = PromptBoxProps { id: Id::new("video-prompt-box"), placeholder: if reference { PLACEHOLDER_REFERENCE } else { PLACEHOLDER }, enter_to_generate: env.enter_to_generate, mentions: &mentions, max_length: Some(model.map_or(Some(DEFAULT_PROMPT_MAX), |m| m.prompt_max)), deck, accepts: &accepts, credits: self.cost.credits, generate_enabled: !self.settings.prompt.trim().is_empty() && model.is_some() && !needs_start, generating: self.generating, generate_tooltip: if needs_start { "Add a starting image before generating" } else { "Generate" }, warning: needs_start.then_some("Starting frame required"), banner };
let models = &env.catalog.video_page;
let mut prompt = std::mem::take(&mut self.settings.prompt);
let count = self.settings.count;
let settings = &mut self.settings;
let with_refs = reference && !self.refs.images.is_empty();
let mut picked_model = None;
let mut picked_mode = None;
let mut left = |ui: &mut Ui| {
if let Some(id) = model_selector(ui, Id::new("video-model"), models, model, true) {
picked_model = Some(id);
}
if let Some(m) = model {
picked_mode = video_toolbar(ui, m, settings, with_refs).or(picked_mode);
}
};
let mut picked_count = None;
let mut right = |ui: &mut Ui| {
if let Some(m) = model {
picked_count = toolbar::count(ui, Id::new("video-count"), m, count, "videos");
}
};
let actions = prompt_box::show(ui, &mut prompt, &self.refs, &mut self.box_state, &props, env.cache, &mut left, &mut right);
self.settings.prompt = prompt;
if let Some(id) = picked_model {
self.settings.model = Some(id);
}
if let Some(n) = picked_count {
self.settings.count = n;
}
if let Some(mode) = picked_mode {
switch_mode(&mut self.settings, &mut self.refs, mode);
}
for action in actions {
self.handle(action, env, model, limits.as_ref());
}
}
fn handle(&mut self, action: PromptBoxAction, env: &mut Env<'_>, model: Option<&ModelInfo>, limits: Option<&DeckLimits>) {
match action {
PromptBoxAction::Generate => self.generate(env, model),
PromptBoxAction::ClearAll => {
self.settings.prompt.clear();
self.refs.clear();
},
PromptBoxAction::Deck(deck) => match deck {
DeckAction::Upload { kinds, slot } => {
let multiple = slot == ImageSlot::Reference;
let paths = common::pick_files(&kinds, multiple);
self.add_files(env, &paths, slot, limits);
},
DeckAction::Library { kind, slot } => {
let max = match slot {
ImageSlot::Reference => limits.map_or(1, |l| free_slots(&self.refs, kind, l)).max(1),
_ => 1,
};
env.requests.push(AppRequest::PickFromLibrary { kind, slot, max, page: MediaKind::Video });
},
DeckAction::Remove(id) => self.refs.remove(id),
DeckAction::ClearAll => {
self.refs.images.clear();
self.refs.videos.clear();
self.refs.audios.clear();
},
DeckAction::ReorderImages { from, to } => {
let item = self.refs.images.remove(from);
self.refs.images.insert(to.min(self.refs.images.len()), item);
},
DeckAction::Preview(id) => {
if let Some(url) = self.refs.find_mut(id).and_then(|r| r.full_url.clone().or_else(|| r.preview.clone())) {
env.requests.push(AppRequest::Preview(url));
}
},
DeckAction::SwapFrames => self.refs.swap_frames(),
},
PromptBoxAction::DroppedFiles(paths) => self.add_files(env, &paths, ImageSlot::Reference, limits),
PromptBoxAction::PastedImage(png) => match self.settings.input_mode {
InputMode::Keyframe => match self.free_keyframe(model) {
Some(slot) => common::upload_png(env, &mut self.refs, png, slot),
None => env.toasts.error(frames_full_message(model)),
},
InputMode::Reference => {
if limits.is_some_and(|l| free_slots(&self.refs, RefKind::Image, l) > 0) {
common::upload_png(env, &mut self.refs, png, ImageSlot::Reference);
} else {
env.toasts.error(max_message(RefKind::Image, limits.map_or(0, |l| l.max_images), None));
}
},
},
}
}
/// Routes picked or dropped files (`handleDroppedFiles`): keyframe mode fills the first frame,
/// then the last; reference mode adds each kind up to its limit.
fn add_files(&mut self, env: &mut Env<'_>, paths: &[PathBuf], slot: ImageSlot, limits: Option<&DeckLimits>) {
let model = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL).cloned();
for path in paths {
let Some(kind) = RefKind::from_path(path) else {
env.toasts.error(format!("{} isn't a supported file", common::file_name(path)));
continue;
};
match (self.settings.input_mode, kind) {
(InputMode::Keyframe, RefKind::Image) => {
let target = if slot == ImageSlot::Reference { self.free_keyframe(model.as_ref()) } else { Some(slot) };
match target {
Some(slot) => common::upload_path(env, &mut self.refs, path, kind, slot),
None => env.toasts.error(frames_full_message(model.as_ref())),
}
},
(InputMode::Keyframe, _) => env.toasts.error(format!("{} references aren't available here", if kind == RefKind::Video { "Video" } else { "Audio" })),
(InputMode::Reference, _) => {
let Some(l) = limits else {
continue;
};
let max = match kind {
RefKind::Image => l.max_images,
RefKind::Video => l.max_videos,
RefKind::Audio => l.max_audios,
};
if max == 0 {
env.toasts.error(format!("{}{} references aren't available here", kind.noun()[..1].to_uppercase(), &kind.noun()[1..]));
} else if free_slots(&self.refs, kind, l) == 0 {
let secs = if kind == RefKind::Video { l.max_video_secs } else { l.max_audio_secs };
env.toasts.error(max_message(kind, max, secs.filter(|_| kind != RefKind::Image)));
} else {
common::upload_path(env, &mut self.refs, path, kind, ImageSlot::Reference);
}
},
}
}
}
/// After an upload lands: enforce the model's total-duration limits.
pub fn check_durations(&mut self, env: &mut Env<'_>, ref_id: u64) {
let Some(model) = common::selected_model(&env.catalog.video_page, self.settings.model.as_deref(), DEFAULT_VIDEO_MODEL) else {
return;
};
for (kind, max) in [(RefKind::Video, model.video_refs_max_secs), (RefKind::Audio, model.audio_refs_max_secs)] {
let Some(max) = max else {
continue;
};
if self.refs.list(kind).iter().any(|r| r.id == ref_id) && self.refs.total_secs(kind) > f32::from(max) + 0.5 {
self.refs.remove(ref_id);
env.toasts.error(format!("Total {} duration cannot exceed {max}s", kind.noun()));
}
}
}
fn generate(&mut self, env: &mut Env<'_>, model: Option<&ModelInfo>) {
if self.generating {
return;
}
if self.settings.prompt.trim().is_empty() {
env.toasts.error("Please enter a prompt to generate video");
return;
}
let Some(model) = model else {
env.toasts.error("Please select a model to generate video");
return;
};
if !env.signed_in {
env.requests.push(AppRequest::SignIn);
return;
}
let limit = model.prompt_max;
if editor::is_over_limit(&self.settings.prompt, limit) {
env.toasts.error(format!("Prompt exceeds the {} character limit for this model", limit.unwrap_or_default()));
return;
}
if self.needs_start_frame(model) {
env.toasts.error("Please add a starting frame image to generate video");
return;
}
if self.refs.any_uploading() {
env.toasts.error("Please wait for your references to finish uploading");
return;
}
self.generating = true;
let ref_image = self.refs.first_frame.as_ref().or(self.refs.images.first()).and_then(|r| r.preview.clone());
let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image };
env.backend.generate(Modality::Video, self.fields(model, false), meta);
}
/// The OmniGen request (`GenerateVideoRequest` after `api_fields`).
fn fields(&self, m: &ModelInfo, estimate: bool) -> Map<String, Value> {
let s = &self.settings;
let mut f = Map::new();
f.insert("model".into(), json!(m.id));
if !estimate {
f.insert("prompt".into(), json!(s.prompt));
}
let resolved = [("aspect_ratio", ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref())), ("resolution", ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref()).filter(|_| !m.resolutions.is_empty())), ("bitrate", ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).filter(|_| !m.bitrates.is_empty())), ("output_format", ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).filter(|_| !m.output_formats.is_empty()))];
for (key, value) in resolved {
if let Some(v) = value {
f.insert(key.into(), json!(v));
}
}
let reference = s.input_mode == InputMode::Reference;
if let Some(d) = m.resolve_duration(s.duration, reference && !self.refs.images.is_empty()) {
f.insert("duration_seconds".into(), json!(d));
}
if m.sound_toggle {
f.insert("generate_audio".into(), json!(s.generate_audio));
}
// Only models that list batch sizes take a count (older ones reject the field).
if !m.batch_sizes.is_empty() {
f.insert("video_batch_count".into(), json!(m.valid_batch(s.count)));
}
let token = |r: &RefMedia, i: usize| r.token.clone().or_else(|| estimate.then(|| format!("mf_estimate_{i}")));
if reference {
for (key, list) in [("reference_image_media_tokens", &self.refs.images), ("reference_video_media_tokens", &self.refs.videos), ("reference_audio_media_tokens", &self.refs.audios)] {
let tokens: Vec<String> = list.iter().enumerate().filter_map(|(i, r)| token(r, i)).collect();
if !tokens.is_empty() {
f.insert(key.into(), json!(tokens));
}
}
if estimate {
let millis = |kind| (self.refs.total_secs(kind) * 1000.0) as u32;
f.insert("estimate_only".into(), json!({ "total_input_video_duration_millis": millis(RefKind::Video), "total_input_audio_duration_millis": millis(RefKind::Audio) }));
}
} else {
if let Some(t) = self.refs.first_frame.as_ref().and_then(|r| token(r, 0)) {
f.insert("start_frame_image_media_token".into(), json!(t));
}
if let Some(t) = self.refs.last_frame.as_ref().filter(|_| m.end_frame).and_then(|r| token(r, 1)) {
f.insert("end_frame_image_media_token".into(), json!(t));
}
}
f
}
/// Model-change reconciliation: input mode, end frame and count.
fn reconcile(&mut self, m: &ModelInfo) {
let wanted = match self.settings.input_mode {
InputMode::Reference if !m.supports_reference_mode() => InputMode::Keyframe,
InputMode::Keyframe if !m.start_frame && m.supports_reference_mode() => InputMode::Reference,
mode => mode,
};
if wanted != self.settings.input_mode {
switch_mode(&mut self.settings, &mut self.refs, wanted);
}
if !m.end_frame && self.refs.last_frame.is_some() {
self.refs.last_frame = None;
}
self.settings.count = m.valid_batch(self.settings.count);
}
fn needs_start_frame(&self, m: &ModelInfo) -> bool {
let keyframe = self.settings.input_mode == InputMode::Keyframe;
let required = (keyframe && m.start_frame_required) || (!m.text_to_video && m.start_frame);
required && !self.has_image_input()
}
fn has_image_input(&self) -> bool {
match self.settings.input_mode {
InputMode::Keyframe => self.refs.first_frame.is_some(),
InputMode::Reference => !self.refs.images.is_empty(),
}
}
/// The first empty keyframe slot, if any.
fn free_keyframe(&self, model: Option<&ModelInfo>) -> Option<ImageSlot> {
if self.refs.first_frame.is_none() {
Some(ImageSlot::FirstFrame)
} else if self.refs.last_frame.is_none() && model.is_some_and(|m| m.end_frame) {
Some(ImageSlot::LastFrame)
} else {
None
}
}
/// `@Image1…`, `@Video1…`, `@Audio1…` for the attached references.
fn mentions(&self) -> Vec<MentionItem> {
let mut items = editor::mention_items(MentionKind::Image, self.refs.images.iter().map(|r| r.preview.clone()));
items.extend(editor::mention_items(MentionKind::Video, self.refs.videos.iter().map(|r| r.preview.clone())));
items.extend(editor::mention_items(MentionKind::Audio, self.refs.audios.iter().map(|_| None)));
items
}
/// "Make Video" from an image: it becomes the first frame (or the first reference).
pub fn set_start_image(&mut self, image: RefMedia) {
match self.settings.input_mode {
InputMode::Keyframe => self.refs.first_frame = Some(image),
InputMode::Reference => self.refs.images = vec![image],
}
self.box_state.editor.focus_requested = true;
}
pub fn on_enqueued(&mut self, env: &mut Env<'_>, job_tokens: &[String], meta: &EnqueueMeta) {
self.generating = false;
self.feed.add_enqueued(MediaKind::Video, job_tokens, meta, env.catalog);
env.toasts.success("Video generation enqueued!");
}
pub fn on_enqueue_failed(&mut self, env: &mut Env<'_>, message: &str) {
self.generating = false;
env.toasts.error(if message.is_empty() { "Failed to start video generation. Please try again." } else { message });
}
/// Recreate: the original prompt, model, settings and references.
pub fn apply_prompt(&mut self, prompt: &Prompt) {
let s = &mut self.settings;
s.prompt = prompt.maybe_positive_prompt.clone().unwrap_or_default();
if prompt.maybe_model_type.is_some() {
s.model = prompt.maybe_model_type.clone();
}
if prompt.maybe_aspect_ratio.is_some() {
s.aspect_ratio = prompt.maybe_aspect_ratio.clone();
}
if prompt.maybe_resolution.is_some() {
s.resolution = prompt.maybe_resolution.clone();
}
if prompt.maybe_duration_seconds.is_some() {
s.duration = prompt.maybe_duration_seconds;
}
if let Some(audio) = prompt.maybe_generate_audio {
s.generate_audio = audio;
}
self.refs.clear();
for c in prompt.maybe_context_images.iter().flatten() {
let kind = match c.semantic.as_str() {
"vid_ref" => RefKind::Video,
"audioref" => RefKind::Audio,
_ => RefKind::Image,
};
let item = RefMedia::from_library(kind, c.media_token.clone(), c.media_links.thumbnail(256), Some(c.media_links.cdn_url.clone()), 0.0);
match (c.semantic.as_str(), kind) {
("vid_end_frame", _) => self.refs.last_frame = Some(item),
(_, RefKind::Image) if s.input_mode == InputMode::Keyframe && self.refs.first_frame.is_none() => self.refs.first_frame = Some(item),
(_, kind) => self.refs.list_mut(kind).push(item),
}
}
self.box_state.editor.focus_requested = true;
}
}
/// The video-only pickers: aspect ratio through input mode (left toolbar after the model).
/// Returns a newly picked input mode (applied by the caller, since it moves references).
fn video_toolbar(ui: &mut Ui, m: &ModelInfo, s: &mut VideoSettings, with_refs: bool) -> Option<InputMode> {
let aspect = ModelInfo::resolve(s.aspect_ratio.as_deref(), &m.aspect_ratios, m.aspect_default.as_ref());
if let Some(v) = toolbar::aspect_ratio(ui, Id::new("video-aspect"), m, aspect.as_deref().unwrap_or("")) {
s.aspect_ratio = Some(v);
}
let resolution = ModelInfo::resolve(s.resolution.as_deref(), &m.resolutions, m.resolution_default.as_ref());
if let Some(v) = toolbar::resolution(ui, Id::new("video-resolution"), m, resolution.as_deref().unwrap_or("")) {
s.resolution = Some(v);
}
if !m.bitrates.is_empty() {
let current = ModelInfo::resolve(s.bitrate.as_deref(), &m.bitrates, m.bitrate_default.as_ref()).unwrap_or_default();
let options: Vec<PickOption<String>> = m.bitrates.iter().map(|v| PickOption::new(v.clone(), models::bitrate_label(v))).collect();
if let Some(v) = pickers::option_picker(ui, Id::new("video-bitrate"), Trigger::Pill(Leading::None), "Bitrate", "Bitrate", &current, &options) {
s.bitrate = Some(v);
}
}
if !m.output_formats.is_empty() {
let current = ModelInfo::resolve(s.output_format.as_deref(), &m.output_formats, m.output_format_default.as_ref()).unwrap_or_default();
let options: Vec<PickOption<String>> = m.output_formats.iter().map(|v| PickOption::new(v.clone(), v.to_uppercase())).collect();
if let Some(v) = pickers::option_picker(ui, Id::new("video-format"), Trigger::Pill(Leading::None), "Output Format", "Output format", &current, &options) {
s.output_format = Some(v);
}
}
if let (Some((min, max)), Some(current)) = (m.duration_range(with_refs), m.resolve_duration(s.duration, with_refs)) {
if let Some(v) = pickers::duration_picker(ui, Id::new("video-duration"), u32::from(current), u32::from(min), u32::from(max)) {
s.duration = Some(v as u16);
}
}
if m.sound_toggle && pickers::toggle(ui, Id::new("video-sound"), if s.generate_audio { Icon::Volume } else { Icon::VolumeOff }, "Sound", s.generate_audio, if s.generate_audio { "Sound: ON" } else { "Sound: OFF" }) {
s.generate_audio = !s.generate_audio;
}
if m.start_frame && m.supports_reference_mode() {
let options = [PickOption::new(InputMode::Keyframe, "Keyframe").subtitle("First/Last frame"), PickOption::new(InputMode::Reference, "Omni Reference").subtitle("Multi-media ref")];
return pickers::option_picker(ui, Id::new("video-mode"), Trigger::Pill(Leading::None), "Input Mode", "Input mode", &s.input_mode, &options);
}
None
}
/// Switches input mode, carrying the first image across: keyframe → reference moves the first
/// frame to the front of the deck (and drops the last frame); reference → keyframe takes the
/// deck's first image as the first frame (and drops videos and audio).
fn switch_mode(s: &mut VideoSettings, refs: &mut References, mode: InputMode) {
match mode {
InputMode::Reference => {
if let Some(first) = refs.first_frame.take() {
refs.images.insert(0, first);
}
refs.last_frame = None;
},
InputMode::Keyframe => {
if refs.first_frame.is_none() && !refs.images.is_empty() {
refs.first_frame = Some(refs.images.remove(0));
}
refs.videos.clear();
refs.audios.clear();
},
}
s.input_mode = mode;
}
fn deck_limits(m: &ModelInfo, library: bool) -> DeckLimits {
DeckLimits { max_images: m.image_refs_max, max_videos: m.video_refs_max, max_video_secs: m.video_refs_max_secs.map(f32::from), max_audios: m.audio_refs_max, max_audio_secs: m.audio_refs_max_secs.map(f32::from), library }
}
fn free_slots(refs: &References, kind: RefKind, l: &DeckLimits) -> usize {
let max = match kind {
RefKind::Image => l.max_images,
RefKind::Video => l.max_videos,
RefKind::Audio => l.max_audios,
};
max.saturating_sub(refs.list(kind).len())
}
fn max_message(kind: RefKind, max: usize, secs: Option<f32>) -> String {
let noun = match kind {
RefKind::Image => "image reference",
RefKind::Video => "video",
RefKind::Audio => "audio clip",
};
let plural = if max == 1 { "" } else { "s" };
match secs {
Some(s) => format!("Max {max} {noun}{plural} / {}s total", s.round() as i64),
None => format!("Max {max} {noun}{plural}"),
}
}
fn frames_full_message(model: Option<&ModelInfo>) -> &'static str {
if model.is_some_and(|m| m.end_frame) {
"First and last frames are already set"
} else {
"The first frame is already set"
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod common;
pub mod create_image;
pub mod create_video;
pub mod other;
pub mod toolbar;
+129
View File
@@ -0,0 +1,129 @@
//! The pages around the create tools: Home, the Library, and a placeholder for tools that are
//! still web-only.
use egui::{Color32, Id, Rect, Sense, Ui, pos2, vec2};
use crate::feed::grid::{self, FeedAction, FeedView};
use crate::feed::store::FeedStore;
use crate::feed::types::MediaKind;
use crate::pages::common::{AppRequest, Env, FeedKey};
use crate::shell::{Page, WEB_APP_URL};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
/// Home: what ArtCraft does and where to start. Returns a page to open.
pub fn home(ui: &mut Ui, signed_in: bool, requests: &mut Vec<AppRequest>) -> Option<Page> {
let mut open = None;
let area = ui.max_rect();
let width = (area.width() - 64.0).min(960.0);
let mut col = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(area.center().x - width / 2.0, area.top() + 56.0), vec2(width, area.height() - 56.0))));
let ui = &mut col;
widgets::hud(ui, "01 / ArtCraft", theme::FAINT);
ui.add_space(10.0);
ui.label(widgets::heading("Capable tools for artists.", 52.0));
ui.add_space(10.0);
ui.label(egui::RichText::new("Generate images and videos with the best models, steer them with references and keyframes, and keep everything in your library.").size(17.0).color(theme::MUTED));
ui.add_space(28.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
let card_w = (width - 16.0) / 2.0;
for (page, icon, title, text) in [(Page::CreateImage, Icon::Image, "Create Image", "Describe anything. See it in seconds."), (Page::CreateVideo, Icon::Video, "Create Video", "Describe a scene. See it in motion.")] {
if card(ui, card_w, icon, title, text) {
open = Some(page);
}
}
});
if !signed_in {
ui.add_space(24.0);
if widgets::button(ui, Some(Icon::LogIn), "Sign in to start", widgets::Kind::Primary, 0.0, 44.0).clicked() {
requests.push(AppRequest::SignIn);
}
}
open
}
fn card(ui: &mut Ui, width: f32, icon: Icon, title: &str, text: &str) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(width, 180.0), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
p.rect(rect, 0.0, if hovered { Color32::from_rgba_unmultiplied(242, 241, 238, 8) } else { Color32::from_rgba_unmultiplied(242, 241, 238, 4) }, egui::Stroke::new(1.0, if hovered { theme::LINE_STRONG } else { theme::LINE }), egui::StrokeKind::Inside);
widgets::corner_marks(p, rect);
icons::paint(p, Rect::from_min_size(rect.min + vec2(24.0, 24.0), vec2(26.0, 26.0)), icon, theme::INK);
let body = Rect::from_min_max(pos2(rect.left() + 24.0, rect.top() + 76.0), pos2(rect.right() - 24.0, rect.bottom() - 16.0));
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(body).layout(egui::Layout::top_down(egui::Align::Min)));
inner.label(widgets::heading(title, 26.0));
inner.add_space(4.0);
inner.label(egui::RichText::new(text).size(14.0).color(theme::MUTED));
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.right() - 28.0, rect.top() + 36.0), vec2(16.0, 16.0)), Icon::ChevronRight, if hovered { theme::INK } else { theme::FAINT });
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// The Library: everything generated, newest first.
pub fn library(ui: &mut Ui, env: &mut Env<'_>, feed: &mut FeedStore) {
let area = ui.max_rect();
if !env.signed_in {
placeholder(ui, area, "Library", "Sign in to see everything you've generated.", None);
return;
}
if !feed.library_started() {
env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library));
}
if !feed.has_content() && !feed.loading {
placeholder(ui, area, "Library", "Your generations will appear here.", None);
return;
}
let view = FeedView { id: Id::new("library-feed"), mode: env.view_mode, pending: &[], failed: &[], items: &feed.items, has_more: feed.has_more, loading: feed.loading, selecting: feed.selecting, selected: &feed.selected, last_viewed: feed.last_viewed.as_deref(), prompts: env.prompts, make_video: true, bottom_padding: 24.0 };
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area.shrink2(vec2(12.0, 0.0)).with_min_y(area.top() + 2.0)));
for action in grid::show(&mut child, &view, env.cache, env.ratios, env.catalog) {
match action {
FeedAction::Open(token) => {
let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind);
feed.last_viewed = Some(token.clone());
env.requests.push(AppRequest::Open { kind, token });
},
FeedAction::ToggleSelect(token) => {
if !feed.selected.remove(&token) {
feed.selected.insert(token);
}
},
FeedAction::LoadMore => env.requests.push(AppRequest::LoadMoreLibrary(FeedKey::Library)),
FeedAction::Recreate(token) => {
let kind = feed.find(&token).map_or(MediaKind::Image, |i| i.kind);
env.requests.push(AppRequest::Recreate { kind, token });
},
FeedAction::MakeVideo(token) => env.requests.push(AppRequest::MakeVideo { token }),
FeedAction::Share(token) => env.requests.push(AppRequest::Share(token)),
FeedAction::Download(token) => env.requests.push(AppRequest::Download(vec![token])),
FeedAction::CopyPrompt(text) => env.requests.push(AppRequest::CopyText(text)),
FeedAction::DismissFailed(_) => {},
}
}
if feed.selecting {
crate::pages::common::selection_bar(ui, area, 24.0, feed, MediaKind::Image, env.requests);
}
}
/// A tool that hasn't come to the native app yet.
pub fn coming_soon(ui: &mut Ui, page: Page) {
let area = ui.max_rect();
placeholder(ui, area, page.label(), "This tool is coming to the native app. You can use it in the ArtCraft web app in the meantime.", Some(format!("{WEB_APP_URL}{}", page.web_path())));
}
/// The bordered, corner-marked splash used for empty and placeholder pages.
fn placeholder(ui: &mut Ui, area: Rect, title: &str, text: &str, web_url: Option<String>) {
let width = (area.width() - 64.0).min(768.0);
let section = Rect::from_center_size(area.center(), vec2(width, if web_url.is_some() { 240.0 } else { 180.0 }));
ui.painter().rect(section, 0.0, Color32::from_rgba_unmultiplied(242, 241, 238, 4), egui::Stroke::new(1.0, Color32::from_rgba_unmultiplied(242, 241, 238, 51)), egui::StrokeKind::Inside);
widgets::corner_marks(ui.painter(), section);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(section.shrink2(vec2(40.0, 44.0))));
child.label(widgets::heading(title, 44.0));
child.add_space(10.0);
child.label(egui::RichText::new(text).size(15.0).color(theme::MUTED));
if let Some(url) = web_url {
child.add_space(20.0);
if widgets::button(&mut child, Some(Icon::ExternalLink), "Open in web app", widgets::Kind::Primary, 0.0, 40.0).clicked() {
child.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Toolbar pickers both create pages use, each shown only when the model offers a choice.
use egui::{Id, Ui};
use crate::models::{self, ModelInfo};
use crate::prompt_box::pickers::{self, OptionLead, PickOption, Trigger};
use crate::ui::icons::Icon;
use crate::ui::widgets::Leading;
/// The aspect ratio picker (`AspectRatioPicker`). `current` is the effective value.
pub fn aspect_ratio(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.aspect_ratios.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.aspect_ratios.iter().map(|v| PickOption::new(v.clone(), models::aspect_label(v)).lead(aspect_lead(v))).collect();
let lead = match models::aspect_proportion(current) {
Some(ratio) => Leading::Aspect(Some(ratio)),
None => Leading::Icon(Icon::Wand),
};
pickers::option_picker(ui, id, Trigger::Pill(lead), "Aspect Ratio", "Aspect Ratio", &current.to_owned(), &options)
}
fn aspect_lead(value: &str) -> OptionLead {
match models::aspect_proportion(value) {
Some(ratio) => OptionLead::Aspect(Some(ratio)),
None => OptionLead::Icon(Icon::Wand),
}
}
/// The resolution picker (`ResolutionPicker`), its pill captioned SD or HD.
pub fn resolution(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.resolutions.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.resolutions.iter().map(|v| PickOption::new(v.clone(), models::resolution_label(v))).collect();
let caption = if models::resolution_is_hd(current) { "HD" } else { "SD" };
pickers::option_picker(ui, id, Trigger::Pill(Leading::Caption(caption)), "Resolution", "Resolution", &current.to_owned(), &options)
}
/// The quality picker (`QualityPicker`).
pub fn quality(ui: &mut Ui, id: Id, model: &ModelInfo, current: &str) -> Option<String> {
if model.qualities.is_empty() {
return None;
}
let options: Vec<PickOption<String>> = model.qualities.iter().map(|v| PickOption::new(v.clone(), models::quality_label(v))).collect();
pickers::option_picker(ui, id, Trigger::Pill(Leading::Icon(Icon::Gem)), "Quality", "Quality", &current.to_owned(), &options)
}
/// The number of generations (`GenerationCountPicker`): a quiet copy-icon button.
pub fn count(ui: &mut Ui, id: Id, model: &ModelInfo, current: u16, noun: &str) -> Option<u16> {
let options: Vec<PickOption<u16>> = model.batch_options().into_iter().map(|n| PickOption::new(n, n.to_string())).collect();
if options.len() <= 1 {
ui.add_enabled_ui(false, |ui| crate::ui::widgets::ghost(ui, Icon::Copy, Some(&current.to_string()), "Number of generations", false));
return None;
}
pickers::option_picker(ui, id, Trigger::Ghost(Icon::Copy), &format!("No. of {noun}"), "Number of generations", &current, &options)
}
+529
View File
@@ -0,0 +1,529 @@
//! The reference widgets left of the prompt: the fanned reference deck (images, videos, audio)
//! and the tilted first/last keyframe slots.
use egui::emath::Rot2;
use egui::load::SizedTexture;
use egui::{Align2, Color32, CornerRadius, FontId, Id, Order, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, Vec2, pos2, vec2};
use crate::backend::media_cache::{Lookup, MediaCache};
use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, RefStatus, References};
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
/// `w-14`.
const CARD: f32 = 56.0;
/// Rotations of the collapsed fan's cards (`FAN_TRANSFORMS`), with their x offsets.
const FAN: [(f32, f32); 3] = [(-8.0, 0.0), (2.0, 12.0), (9.0, 24.0)];
const OPEN_DELAY: f64 = 0.17;
const CLOSE_DELAY: f64 = 0.15;
const MENU_DELAY: f64 = 0.1;
/// Something the user asked the deck to do; the prompt box carries it out.
#[derive(Clone, Debug, PartialEq)]
pub enum DeckAction {
/// Open the file picker for these kinds into `slot` (images only use the slot).
Upload {
kinds: Vec<RefKind>,
slot: ImageSlot,
},
/// Open the library picker for `kind` into `slot`.
Library {
kind: RefKind,
slot: ImageSlot,
},
Remove(u64),
ClearAll,
ReorderImages {
from: usize,
to: usize,
},
Preview(u64),
SwapFrames,
}
/// What the deck may offer, from the model's limits.
#[derive(Clone, Debug)]
pub struct DeckLimits {
pub max_images: usize,
pub max_videos: usize,
pub max_video_secs: Option<f32>,
pub max_audios: usize,
pub max_audio_secs: Option<f32>,
/// Whether "From library" is offered (always, once signed in).
pub library: bool,
}
impl DeckLimits {
fn kinds(&self) -> Vec<RefKind> {
[(RefKind::Image, self.max_images), (RefKind::Video, self.max_videos), (RefKind::Audio, self.max_audios)].into_iter().filter(|(_, max)| *max > 0).map(|(k, _)| k).collect()
}
fn max(&self, kind: RefKind) -> usize {
match kind {
RefKind::Image => self.max_images,
RefKind::Video => self.max_videos,
RefKind::Audio => self.max_audios,
}
}
fn max_secs(&self, kind: RefKind) -> Option<f32> {
match kind {
RefKind::Image => None,
RefKind::Video => self.max_video_secs,
RefKind::Audio => self.max_audio_secs,
}
}
}
/// The fanned reference deck. Returns the user's actions this frame.
pub fn reference_deck(ui: &mut Ui, id: Id, refs: &References, limits: &DeckLimits, cache: &mut MediaCache, always_expanded: bool) -> Vec<DeckAction> {
let mut actions = Vec::new();
let items: Vec<&RefMedia> = refs.images.iter().chain(&refs.videos).chain(&refs.audios).collect();
let open_kinds: Vec<RefKind> = limits.kinds().into_iter().filter(|k| refs.list(*k).len() < limits.max(*k)).collect();
if items.is_empty() && open_kinds.is_empty() {
return actions;
}
let menu = add_menu_entries(refs, limits, &open_kinds, ImageSlot::Reference);
if always_expanded {
expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions);
return actions;
}
if items.is_empty() {
let resp = add_tile(ui, id.with("empty"), "Reference", -6.0);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("empty-menu"), &resp, &menu, &mut actions);
return actions;
}
// Collapsed fan: footprint tracks the card count plus the + button's overhang.
let shown = items.len().min(3);
let stack_w = (shown - 1) as f32 * 12.0 + CARD;
let (rect, _) = ui.allocate_exact_size(vec2(stack_w + 12.0, CARD), Sense::hover());
let stack = Rect::from_min_size(rect.min, vec2(stack_w, CARD));
let expanded = hover_latch(ui, id.with("expand"), stack, OPEN_DELAY);
let painter = ui.painter().clone();
if !expanded.open {
for (i, item) in items.iter().take(3).enumerate().rev() {
let (deg, dx) = FAN[i];
let card = Rect::from_min_size(stack.min + vec2(dx, 0.0), vec2(CARD, CARD));
paint_card(ui, &painter, card, deg, item, cache, false);
}
if items.len() > 3 {
let badge_text = items.len().to_string();
let badge = Rect::from_center_size(pos2(stack.right() + 2.0, stack.top() + 2.0), vec2(8.0 + 7.0 * badge_text.len() as f32, 18.0));
painter.rect_filled(badge, CornerRadius::same(9), theme::ACCENT);
painter.text(badge.center(), Align2::CENTER_CENTER, badge_text, FontId::new(10.0, theme::semibold()), Color32::WHITE);
}
if !open_kinds.is_empty() {
let plus = Rect::from_min_size(pos2(stack.right() - 12.0, stack.bottom() - 20.0), vec2(24.0, 24.0));
let resp = ui.interact(plus, id.with("plus"), Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
painter.circle(plus.center(), if hovered { 13.0 } else { 12.0 }, widgets::blend(theme::CONTROLS, if hovered { theme::WASH_HOVER } else { Color32::TRANSPARENT }), Stroke::new(1.0, theme::LINE));
icons::paint(&painter, plus.shrink(6.0), Icon::Plus, theme::INK);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.clone(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("plus-menu"), &resp, &menu, &mut actions);
}
}
if expanded.open {
// The panel overlays the deck from its bottom-left corner, growing up and right.
let area = egui::Area::new(id.with("panel")).order(Order::Foreground).pivot(Align2::LEFT_BOTTOM).fixed_pos(stack.left_bottom()).show(ui.ctx(), |ui| {
widgets::popover_frame().inner_margin(8).show(ui, |ui| {
ui.set_max_width(ui.ctx().content_rect().width() * 0.5);
expanded_panel(ui, id, refs, &items, &open_kinds, &menu, cache, &mut actions);
});
});
expanded.keep_open_while(ui, area.response.rect.union(stack));
}
actions
}
/// The first and last keyframe slots (the last only when `show_last`).
pub fn keyframe_cards(ui: &mut Ui, id: Id, refs: &References, show_last: bool, library: bool, cache: &mut MediaCache) -> Vec<DeckAction> {
let mut actions = Vec::new();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
slot_card(ui, id.with("first"), refs.first_frame.as_ref(), "First frame", if show_last { -6.0 } else { 0.0 }, ImageSlot::FirstFrame, library, cache, &mut actions);
if show_last {
let both = refs.first_frame.is_some() && refs.last_frame.is_some();
let (rect, _) = ui.allocate_exact_size(vec2(8.0, CARD), Sense::hover());
let swap = Rect::from_center_size(rect.center() - vec2(0.0, 6.0), vec2(20.0, 20.0));
let resp = ui.interact(swap, id.with("swap"), if both { Sense::click() } else { Sense::hover() });
let hovered = resp.hovered() && both;
let p = ui.painter();
p.circle(
swap.center(),
if hovered { 11.0 } else { 10.0 },
Color32::from_black_alpha(if hovered {
204
} else if both {
153
} else {
102
}),
Stroke::new(1.0, theme::fade(Color32::WHITE, if both { 0.2 } else { 0.1 })),
);
icons::paint(p, swap.shrink(5.0), Icon::ArrowLeftRight, theme::fade(Color32::WHITE, if both { 1.0 } else { 0.5 }));
if both && resp.on_hover_text("Swap frames").on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(DeckAction::SwapFrames);
}
slot_card(ui, id.with("last"), refs.last_frame.as_ref(), "Last frame", 6.0, ImageSlot::LastFrame, library, cache, &mut actions);
}
});
actions
}
/// One labelled keyframe slot: a dashed add card, or the frame once set.
#[allow(clippy::too_many_arguments)]
fn slot_card(ui: &mut Ui, id: Id, item: Option<&RefMedia>, label: &str, tilt: f32, slot: ImageSlot, library: bool, cache: &mut MediaCache, actions: &mut Vec<DeckAction>) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
match item {
Some(item) => {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click());
let hovered = resp.hovered();
let painter = ui.painter().clone();
paint_card(ui, &painter, rect, if hovered { 0.0 } else { tilt }, item, cache, hovered);
if item.status == RefStatus::Ready {
if remove_button(ui, id.with("x"), rect, hovered) {
actions.push(DeckAction::Remove(item.id));
} else if resp.on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() {
actions.push(DeckAction::Preview(item.id));
}
}
let (label_rect, _) = ui.allocate_exact_size(vec2(CARD, 10.0), Sense::hover());
ui.painter().text(label_rect.center(), Align2::CENTER_CENTER, label, FontId::new(9.0, theme::medium()), theme::MUTED);
},
None => {
let resp = add_tile(ui, id, label, tilt);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: vec![RefKind::Image], slot });
}
{
let menu = vec![MenuGroup { kind: RefKind::Image, hint: None, slot, library }];
hover_menu(ui, id.with("menu"), &resp, &menu, actions);
}
},
}
});
}
/// The expanded deck: a header with "Clear all", the cards (reorderable images) and a + tile.
#[allow(clippy::too_many_arguments)]
fn expanded_panel(ui: &mut Ui, id: Id, refs: &References, items: &[&RefMedia], open_kinds: &[RefKind], menu: &[MenuGroup], cache: &mut MediaCache, actions: &mut Vec<DeckAction>) {
if items.len() > 1 {
ui.horizontal(|ui| {
let n = items.len();
ui.label(egui::RichText::new(format!("{n} reference{}", if n == 1 { "" } else { "s" })).size(11.0).family(theme::medium()).color(theme::MUTED));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let resp = ui.add(egui::Button::new(egui::RichText::new("Clear all").size(11.0)).frame(false));
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
actions.push(DeckAction::ClearAll);
}
});
});
}
let drag_id = id.with("dragging");
let mut dragging: Option<usize> = ui.data(|d| d.get_temp(drag_id));
let mut image_rects = Vec::with_capacity(refs.images.len());
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
for item in items {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click_and_drag());
let hovered = resp.hovered();
let painter = ui.painter().clone();
let image_index = refs.images.iter().position(|r| r.id == item.id);
if let Some(i) = image_index {
image_rects.push((i, rect));
if resp.drag_started() && refs.images.len() > 1 {
dragging = Some(i);
}
}
paint_card(ui, &painter, rect, 0.0, item, cache, hovered && dragging.is_none());
if dragging.is_some() && dragging == image_index {
painter.rect_filled(rect, theme::RADIUS, Color32::from_black_alpha(128));
}
if item.status == RefStatus::Ready && dragging.is_none() {
if remove_button(ui, id.with(("x", item.id)), rect, hovered) {
actions.push(DeckAction::Remove(item.id));
} else if resp.clicked() {
actions.push(DeckAction::Preview(item.id));
}
}
}
if !open_kinds.is_empty() {
let (rect, resp) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::click());
paint_dashed_tile(ui.painter(), rect, 0.0, resp.hovered(), None);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
actions.push(DeckAction::Upload { kinds: open_kinds.to_vec(), slot: ImageSlot::Reference });
}
hover_menu(ui, id.with("panel-menu"), &resp, menu, actions);
}
});
// Drag to reorder images: drop onto another image's card.
if let Some(from) = dragging {
let released = ui.input(|i| i.pointer.any_released());
if released {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if let Some((to, _)) = image_rects.iter().find(|(_, r)| r.contains(pos)) {
if *to != from {
actions.push(DeckAction::ReorderImages { from, to: *to });
}
}
}
dragging = None;
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
ui.data_mut(|d| match dragging {
Some(i) => {
d.insert_temp(drag_id, i);
},
None => d.remove::<usize>(drag_id),
});
}
/// A 56 px card: the thumbnail (cover-fit), a kind badge for video/audio, a duration strip, and a
/// spinner while uploading. `tilt` is in degrees.
fn paint_card(ui: &Ui, painter: &egui::Painter, rect: Rect, tilt: f32, item: &RefMedia, cache: &mut MediaCache, hovered: bool) {
let rot = Rot2::from_angle(tilt.to_radians());
let corners = rotated_corners(rect, rot);
painter.add(Shape::convex_polygon(corners.to_vec(), theme::fade(theme::CONTROLS, 0.95), Stroke::NONE));
let texture = item.preview.as_deref().and_then(|key| match cache.get(ui.ctx(), key) {
Lookup::Ready(t) => Some(t),
_ => None,
});
match texture {
Some(texture) => {
let uv = cover_uv(texture.size_vec2());
let image = egui::Image::from_texture(SizedTexture::from_handle(&texture)).uv(uv).rotate(rot.angle(), Vec2::splat(0.5));
let image = if item.status == RefStatus::Uploading { image.tint(Color32::from_gray(140)) } else { image };
image.paint_at(ui, rect.shrink(2.0));
},
None => {
let icon = match item.kind {
RefKind::Image => Icon::Image,
RefKind::Video => Icon::Video,
RefKind::Audio => Icon::Music,
};
icons::paint(painter, Rect::from_center_size(rect.center(), vec2(20.0, 20.0)), icon, theme::MUTED);
},
}
if item.kind != RefKind::Image && tilt == 0.0 {
let badge = Rect::from_min_size(rect.min + vec2(3.0, 3.0), vec2(16.0, 16.0));
painter.rect_filled(badge, 0.0, Color32::from_black_alpha(153));
icons::paint(painter, badge.shrink(3.0), if item.kind == RefKind::Video { Icon::Video } else { Icon::Music }, Color32::WHITE);
if item.duration_secs > 0.0 {
let strip = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 14.0), rect.max);
painter.rect_filled(strip, 0.0, Color32::from_black_alpha(178));
painter.text(strip.center(), Align2::CENTER_CENTER, format!("{}s", item.duration_secs.round() as i64), FontId::new(10.0, theme::semibold()), Color32::WHITE);
}
}
if item.status == RefStatus::Uploading {
icons::paint_spinner(ui, Rect::from_center_size(rect.center(), vec2(24.0, 24.0)), Color32::WHITE);
}
let border = theme::fade(Color32::WHITE, if hovered { 0.8 } else { 0.3 });
painter.add(Shape::closed_line(corners.to_vec(), Stroke::new(2.0, border)));
}
/// The hover-revealed round × in a card's top-right corner. Returns whether it was clicked.
fn remove_button(ui: &mut Ui, id: Id, card: Rect, card_hovered: bool) -> bool {
let rect = Rect::from_min_size(pos2(card.right() - 22.0, card.top() + 2.0), vec2(20.0, 20.0));
let resp = ui.interact(rect, id, Sense::click());
if !(card_hovered || resp.hovered()) {
return false;
}
let fill = if resp.hovered() { theme::fade(theme::DANGER, 0.7) } else { Color32::from_black_alpha(128) };
ui.painter().circle_filled(rect.center(), 10.0, fill);
icons::paint(ui.painter(), rect.shrink(5.0), Icon::X, Color32::WHITE);
resp.on_hover_text("Remove").on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// The dashed "+" card that adds a reference (straightens and grows on hover).
fn add_tile(ui: &mut Ui, id: Id, label: &str, tilt: f32) -> Response {
let (rect, _) = ui.allocate_exact_size(vec2(CARD, CARD), Sense::hover());
let resp = ui.interact(rect, id, Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
let hovered = resp.hovered();
paint_dashed_tile(ui.painter(), if hovered { rect.expand(1.4) } else { rect }, if hovered { 0.0 } else { tilt }, hovered, Some(label));
resp
}
fn paint_dashed_tile(painter: &egui::Painter, rect: Rect, tilt: f32, hovered: bool, label: Option<&str>) {
let rot = Rot2::from_angle(tilt.to_radians());
let corners = rotated_corners(rect, rot);
let fill = theme::fade(theme::CONTROLS, if hovered { 0.6 } else { 0.4 });
painter.add(Shape::convex_polygon(corners.to_vec(), fill, Stroke::NONE));
let mut path = corners.to_vec();
path.push(corners[0]);
painter.extend(Shape::dashed_line(&path, Stroke::new(2.0, theme::LINE_DASHED), 4.0, 3.0));
let c = rect.center();
match label {
Some(label) => {
icons::paint(painter, Rect::from_center_size(c - vec2(0.0, 6.0), vec2(18.0, 18.0)), Icon::Plus, theme::fade(theme::INK, 0.8));
painter.text(c + vec2(0.0, 12.0), Align2::CENTER_CENTER, label, FontId::new(8.5, theme::medium()), theme::fade(theme::INK, 0.7));
},
None => icons::paint(painter, Rect::from_center_size(c, vec2(20.0, 20.0)), Icon::Plus, theme::fade(theme::INK, 0.8)),
}
}
fn rotated_corners(rect: Rect, rot: Rot2) -> [Pos2; 4] {
let c = rect.center();
[rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| c + rot * (p - c))
}
/// The UV rect that crops a texture of `size` to a centred square (`object-cover`).
pub fn cover_uv(size: Vec2) -> Rect {
if size.x <= 0.0 || size.y <= 0.0 {
return Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0));
}
if size.x > size.y {
let f = size.y / size.x;
Rect::from_min_max(pos2((1.0 - f) / 2.0, 0.0), pos2((1.0 + f) / 2.0, 1.0))
} else {
let f = size.x / size.y;
Rect::from_min_max(pos2(0.0, (1.0 - f) / 2.0), pos2(1.0, (1.0 + f) / 2.0))
}
}
/// One kind's group in an add menu: its "Upload" / "From library" rows and a usage hint.
struct MenuGroup {
kind: RefKind,
hint: Option<String>,
slot: ImageSlot,
library: bool,
}
fn add_menu_entries(refs: &References, limits: &DeckLimits, open_kinds: &[RefKind], slot: ImageSlot) -> Vec<MenuGroup> {
open_kinds
.iter()
.map(|&kind| {
let count = format!("{}/{}", refs.list(kind).len(), limits.max(kind));
let hint = match limits.max_secs(kind) {
Some(max) => format!("{count} \u{b7} {}/{}s", refs.total_secs(kind).round() as i64, max.round() as i64),
None => count,
};
MenuGroup { kind, hint: Some(hint), slot, library: limits.library }
})
.collect()
}
/// The add menu that appears above a trigger after a short hover (the webapp's `DeckAddMenu`).
fn hover_menu(ui: &Ui, id: Id, trigger: &Response, groups: &[MenuGroup], actions: &mut Vec<DeckAction>) {
// A lone "Upload" is what clicking does already: no menu (the webapp's `enabledActions > 1`).
let entries: usize = groups.iter().map(|g| 1 + usize::from(g.library)).sum();
if entries <= 1 {
return;
}
let latch = hover_latch(ui, id, trigger.rect, MENU_DELAY);
if !latch.open {
return;
}
let show_groups = groups.len() > 1;
let area = egui::Area::new(id.with("area")).order(Order::Tooltip).pivot(Align2::CENTER_BOTTOM).fixed_pos(trigger.rect.center_top() - vec2(0.0, 2.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().inner_margin(6).show(ui, |ui| {
ui.set_width(192.0);
ui.spacing_mut().item_spacing.y = 0.0;
for group in groups {
if show_groups {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(group.kind.noun().to_uppercase()).size(10.0).family(theme::semibold()).color(theme::FAINT));
if let Some(hint) = &group.hint {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(hint).size(10.0).family(theme::medium()).color(theme::FAINT));
});
}
});
}
if menu_entry(ui, Icon::Upload, "Upload") {
actions.push(DeckAction::Upload { kinds: vec![group.kind], slot: group.slot });
latch.close(ui);
}
if group.library && menu_entry(ui, Icon::Library, if group.slot == ImageSlot::Reference { "From library" } else { "Pick from library" }) {
actions.push(DeckAction::Library { kind: group.kind, slot: group.slot });
latch.close(ui);
}
}
});
});
latch.keep_open_while(ui, area.response.rect.union(trigger.rect));
}
fn menu_entry(ui: &mut Ui, icon: Icon, label: &str) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let p = ui.painter();
if resp.hovered() {
p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER);
}
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 17.0, rect.center().y), vec2(14.0, 14.0)), icon, theme::fade(theme::INK, 0.6));
p.text(pos2(rect.left() + 34.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(13.0, theme::medium()), theme::INK);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
/// Open-on-hover with a delay, staying open while the pointer is over the trigger or what it
/// opened (plus a grace period), like the webapp's hover menus.
struct HoverLatch {
id: Id,
open: bool,
}
#[derive(Clone, Copy, Default)]
struct LatchState {
hover_since: Option<f64>,
left_at: Option<f64>,
open: bool,
}
fn hover_latch(ui: &Ui, id: Id, trigger: Rect, delay: f64) -> HoverLatch {
let now = ui.input(|i| i.time);
let pointer = ui.input(|i| i.pointer.hover_pos());
let over = pointer.is_some_and(|p| trigger.contains(p));
let mut st: LatchState = ui.data(|d| d.get_temp(id).unwrap_or_default());
if over {
st.left_at = None;
let since = *st.hover_since.get_or_insert(now);
if !st.open {
if now - since >= delay {
st.open = true;
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(delay - (now - since)));
}
}
} else {
st.hover_since = None;
}
ui.data_mut(|d| d.insert_temp(id, st));
HoverLatch { id, open: st.open }
}
impl HoverLatch {
/// Keeps the latch open while the pointer is inside `region`; closes it `CLOSE_DELAY` after
/// the pointer leaves.
fn keep_open_while(&self, ui: &Ui, region: Rect) {
let now = ui.input(|i| i.time);
let inside = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| region.expand(4.0).contains(p));
let mut st: LatchState = ui.data(|d| d.get_temp(self.id).unwrap_or_default());
if inside {
st.left_at = None;
} else {
let left = *st.left_at.get_or_insert(now);
if now - left >= CLOSE_DELAY {
st = LatchState::default();
} else {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(CLOSE_DELAY));
}
}
ui.data_mut(|d| d.insert_temp(self.id, st));
}
fn close(&self, ui: &Ui) {
ui.data_mut(|d| d.insert_temp(self.id, LatchState::default()));
}
}
+414
View File
@@ -0,0 +1,414 @@
//! The prompt text field: auto-grows to ~4 lines then scrolls, expands or resizes on demand,
//! highlights `@Image1`-style mentions and offers them in an autocomplete dropdown, and shows a
//! length counter when the model has a limit.
use egui::text::{CCursor, CCursorRange, LayoutJob};
use egui::{Align2, Color32, FontFamily, FontId, Id, Key, KeyboardShortcut, Modifiers, Order, Rect, Sense, Stroke, TextBuffer, TextFormat, Ui, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
/// `text-md`.
const FONT_SIZE: f32 = 16.0;
/// `min-h-[2.5em]`.
const MIN_HEIGHT: f32 = FONT_SIZE * 2.5;
/// `max-h-[5.5em]` while collapsed.
const COLLAPSED_MAX_HEIGHT: f32 = FONT_SIZE * 5.5;
/// `max-h-[500px]`.
const MAX_HEIGHT: f32 = 500.0;
/// `pr-8`: room for the fullscreen button.
const RIGHT_GUTTER: f32 = 32.0;
const IMAGE_COLORS: [Color32; 5] = [Color32::from_rgb(96, 165, 250), Color32::from_rgb(251, 146, 60), Color32::from_rgb(167, 139, 250), Color32::from_rgb(52, 211, 153), Color32::from_rgb(251, 113, 133)];
const VIDEO_COLORS: [Color32; 3] = [Color32::from_rgb(250, 204, 21), Color32::from_rgb(245, 158, 11), Color32::from_rgb(74, 222, 128)];
const AUDIO_COLORS: [Color32; 2] = [Color32::from_rgb(192, 132, 252), Color32::from_rgb(232, 121, 249)];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MentionKind {
Image,
Video,
Audio,
}
/// Something the prompt can reference by name, e.g. `@Image2`.
#[derive(Clone, Debug)]
pub struct MentionItem {
pub label: String,
pub kind: MentionKind,
/// The 1-based index within its kind (picks the colour).
pub index: usize,
/// Media cache key of a thumbnail, if there is one.
pub preview: Option<String>,
}
impl MentionItem {
pub fn color(&self) -> Color32 {
let i = self.index.saturating_sub(1);
match self.kind {
MentionKind::Image => IMAGE_COLORS[i % IMAGE_COLORS.len()],
MentionKind::Video => VIDEO_COLORS[i % VIDEO_COLORS.len()],
MentionKind::Audio => AUDIO_COLORS[i % AUDIO_COLORS.len()],
}
}
}
/// Per-editor UI state that outlives a frame.
#[derive(Default)]
pub struct EditorState {
/// The chevron's "expanded" mode: a tall, fixed-height field.
pub expanded: bool,
/// A height the user dragged the resize grip to.
pub manual_height: Option<f32>,
mention: Option<MentionQuery>,
/// Focus the field on the next frame (autofocus, after inserting a mention, after closing
/// fullscreen).
pub focus_requested: bool,
}
struct MentionQuery {
/// Char index of the `@`.
anchor: usize,
/// What's typed after the `@` (without it).
filter: String,
selected: usize,
}
pub struct EditorOptions<'a> {
pub id: Id,
pub placeholder: &'a str,
pub enter_to_generate: bool,
pub mentions: &'a [MentionItem],
/// `Some` shows an `n / max` counter; `Some(None)` shows `n / ∞`.
pub max_length: Option<Option<usize>>,
/// Fill a fixed height (fullscreen mode) instead of auto-growing.
pub fixed_height: Option<f32>,
/// Draw the fullscreen button in the top right.
pub fullscreen_button: bool,
/// Draw the resize grip in the bottom right.
pub resizable: bool,
}
#[derive(Default)]
pub struct EditorOutput {
pub submit: bool,
pub open_fullscreen: bool,
pub focused: bool,
}
/// Draws the editor in the available width.
pub fn show(ui: &mut Ui, prompt: &mut String, state: &mut EditorState, opts: &EditorOptions<'_>, thumbnails: &mut dyn FnMut(&Ui, &str) -> Option<egui::TextureHandle>) -> EditorOutput {
let mut out = EditorOutput::default();
let id = opts.id;
let had_focus = ui.memory(|m| m.has_focus(id));
let filtered = filtered_mentions(state, opts.mentions);
// Keys the field mustn't see: dropdown navigation, and Enter when it generates.
if had_focus {
if !filtered.is_empty() {
handle_dropdown_keys(ui, id, prompt, state, &filtered);
} else if opts.enter_to_generate && consume_plain_enter(ui) {
out.submit = true;
}
}
let width = ui.available_width();
let height = editor_height(ui, state, opts);
let (outer, _) = ui.allocate_exact_size(vec2(width, height.unwrap_or(MIN_HEIGHT)), Sense::hover());
let text_width = (width - RIGHT_GUTTER).max(40.0);
let mentions = opts.mentions;
let mut layouter = |ui: &Ui, buf: &dyn TextBuffer, wrap_width: f32| {
let mut job = highlight(buf.as_str(), mentions);
job.wrap.max_width = wrap_width;
ui.fonts_mut(|f| f.layout_job(job))
};
let return_key = if opts.enter_to_generate { KeyboardShortcut::new(Modifiers::SHIFT, Key::Enter) } else { KeyboardShortcut::new(Modifiers::NONE, Key::Enter) };
let max_h = height.unwrap_or(COLLAPSED_MAX_HEIGHT);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(outer.min, vec2(width, max_h))));
let scroll = egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(max_h).auto_shrink([false, true]);
let scroll = if height.is_some() { scroll.min_scrolled_height(max_h) } else { scroll };
let output = scroll
.show(&mut child, |ui| {
let min_h = height.unwrap_or(MIN_HEIGHT);
egui::TextEdit::multiline(prompt).id(id).frame(egui::Frame::NONE).font(FontId::new(FONT_SIZE, FontFamily::Proportional)).hint_text(egui::RichText::new(opts.placeholder).color(theme::MUTED).size(FONT_SIZE)).desired_width(text_width).desired_rows(1).min_size(vec2(text_width, min_h)).return_key(return_key).layouter(&mut layouter).show(ui)
})
.inner;
let used_h = child.min_rect().height().clamp(MIN_HEIGHT, max_h);
// Grow the reserved space to what the field actually used (auto-grow).
if height.is_none() && used_h > outer.height() {
ui.allocate_exact_size(vec2(width, used_h - outer.height()), Sense::hover());
}
let field_rect = Rect::from_min_size(outer.min, vec2(width, if height.is_some() { max_h } else { used_h }));
if state.focus_requested {
state.focus_requested = false;
ui.memory_mut(|m| m.request_focus(id));
let end = CCursor::new(prompt.chars().count());
let mut st = output.state.clone();
st.cursor.set_char_range(Some(CCursorRange::one(end)));
st.store(ui.ctx(), id);
}
out.focused = output.response.response.has_focus();
if output.response.response.changed() || (out.focused && output.cursor_range.is_some()) {
update_mention_query(state, prompt, output.cursor_range, opts.mentions);
}
if !out.focused && !had_focus {
state.mention = None;
}
if opts.fullscreen_button {
let button = Rect::from_min_size(pos2(field_rect.right() - 24.0, field_rect.top()), vec2(24.0, 24.0));
let resp = ui.interact(button, id.with("fullscreen"), Sense::click()).on_hover_text("Focus mode").on_hover_cursor(egui::CursorIcon::PointingHand);
let p = ui.painter();
p.rect_filled(button, theme::RADIUS, if resp.hovered() { theme::WASH_HOVER } else { theme::WASH });
icons::paint(p, button.shrink(6.0), Icon::Maximize, if resp.hovered() { theme::INK } else { theme::DIM });
out.open_fullscreen = resp.clicked();
}
if opts.resizable {
resize_grip(ui, id, field_rect, state);
}
if let Some(limit) = opts.max_length {
let count = prompt.chars().count();
let over = limit.is_some_and(|max| count > max);
let text = match limit {
Some(max) => format!("{count} / {max}"),
None => format!("{count} / \u{221e}"),
};
let color = if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT };
ui.painter().text(pos2(field_rect.right() - 16.0, field_rect.bottom() + 4.0), Align2::RIGHT_BOTTOM, text, FontId::new(10.0, FontFamily::Monospace), color);
}
if !filtered.is_empty() && out.focused {
mention_dropdown(ui, id, field_rect, prompt, state, &filtered, thumbnails);
}
out
}
/// Whether the prompt exceeds `limit` characters.
pub fn is_over_limit(prompt: &str, limit: Option<usize>) -> bool {
limit.is_some_and(|max| prompt.chars().count() > max)
}
/// The height the field takes: `None` auto-grows (collapsed), `Some` is fixed.
fn editor_height(ui: &Ui, state: &EditorState, opts: &EditorOptions<'_>) -> Option<f32> {
if let Some(h) = opts.fixed_height {
return Some(h);
}
if let Some(h) = state.manual_height {
return Some(h.clamp(MIN_HEIGHT, MAX_HEIGHT));
}
// `clamp(120px, calc(100vh - 700px), 500px)`.
state.expanded.then(|| (ui.ctx().content_rect().height() - 700.0).clamp(120.0, MAX_HEIGHT))
}
/// Consumes a plain (unshifted) Enter press; returns whether there was one.
fn consume_plain_enter(ui: &Ui) -> bool {
ui.input_mut(|i| {
let before = i.events.len();
i.events.retain(|e| !matches!(e, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if !modifiers.shift && !modifiers.command && !modifiers.alt));
before != i.events.len()
})
}
fn handle_dropdown_keys(ui: &Ui, id: Id, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem]) {
let n = filtered.len();
let (down, up, accept, escape) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Tab), i.consume_key(Modifiers::NONE, Key::Escape)));
let Some(query) = state.mention.as_mut() else {
return;
};
if down {
query.selected = (query.selected + 1) % n;
}
if up {
query.selected = (query.selected + n - 1) % n;
}
if escape {
state.mention = None;
return;
}
if accept {
let label = filtered[query.selected.min(n - 1)].label.clone();
insert_mention(ui.ctx(), id, prompt, state, &label);
}
}
/// Opens, updates or closes the dropdown from the text before the cursor.
fn update_mention_query(state: &mut EditorState, prompt: &str, cursor: Option<CCursorRange>, mentions: &[MentionItem]) {
// Keep the highlighted row while the same query stays open (arrow keys move it).
let previous = state.mention.take();
if mentions.is_empty() {
return;
}
let Some(cursor) = cursor else {
return;
};
let at = cursor.primary.index.0;
let before: String = prompt.chars().take(at).collect();
let Some(anchor_byte) = before.rfind('@') else {
return;
};
let after = &before[anchor_byte + 1..];
if after.contains(char::is_whitespace) {
return;
}
let anchor = before[..anchor_byte].chars().count();
let selected = previous.filter(|q| q.anchor == anchor && q.filter == after).map_or(0, |q| q.selected);
state.mention = Some(MentionQuery { anchor, filter: after.to_owned(), selected });
}
fn filtered_mentions<'a>(state: &EditorState, mentions: &'a [MentionItem]) -> Vec<&'a MentionItem> {
let Some(query) = &state.mention else {
return Vec::new();
};
let needle = format!("@{}", query.filter).to_lowercase();
mentions.iter().filter(|m| m.label.to_lowercase().contains(&needle)).collect()
}
/// Replaces `@filter` before the cursor with `label` and a space, and puts the cursor after it.
fn insert_mention(ctx: &egui::Context, id: Id, prompt: &mut String, state: &mut EditorState, label: &str) {
let Some(query) = state.mention.take() else {
return;
};
let chars: Vec<char> = prompt.chars().collect();
let end = (query.anchor + 1 + query.filter.chars().count()).min(chars.len());
let before: String = chars[..query.anchor].iter().collect();
let after: String = chars[end..].iter().collect();
*prompt = format!("{before}{label} {after}");
if let Some(mut st) = egui::TextEdit::load_state(ctx, id) {
st.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(query.anchor + label.chars().count() + 1))));
st.store(ctx, id);
}
}
fn mention_dropdown(ui: &Ui, id: Id, field: Rect, prompt: &mut String, state: &mut EditorState, filtered: &[&MentionItem], thumbnails: &mut dyn FnMut(&Ui, &str) -> Option<egui::TextureHandle>) {
let selected = state.mention.as_ref().map_or(0, |q| q.selected);
let row_h = 44.0;
let h = 28.0 + row_h * filtered.len() as f32;
let rect = Rect::from_min_size(pos2(field.left(), field.top() - h - 4.0), vec2(256.0, h));
let mut picked = None;
let mut hovered = None;
egui::Area::new(id.with("mentions")).order(Order::Foreground).fixed_pos(rect.min).show(ui.ctx(), |ui| {
let p = ui.painter();
p.rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
p.text(pos2(rect.left() + 12.0, rect.top() + 14.0), Align2::LEFT_CENTER, "MENTIONS", FontId::new(11.0, theme::mono_bold()), theme::fade(theme::INK, 0.5));
for (i, item) in filtered.iter().enumerate() {
let row = Rect::from_min_size(pos2(rect.left(), rect.top() + 28.0 + row_h * i as f32), vec2(rect.width(), row_h));
let resp = ui.interact(row, id.with(("mention", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, i == selected, &item.label));
if resp.hovered() {
hovered = Some(i);
}
let p = ui.painter();
if i == selected {
p.rect_filled(row, 0.0, theme::WASH_HOVER);
}
let tile = Rect::from_min_size(pos2(row.left() + 12.0, row.center().y - 16.0), vec2(32.0, 32.0));
p.rect(tile, 0.0, theme::fade(Color32::BLACK, 0.2), theme::hairline(), egui::StrokeKind::Inside);
match item.preview.as_deref().and_then(|key| thumbnails(ui, key)) {
Some(tex) => {
egui::Image::new(&tex).maintain_aspect_ratio(false).paint_at(ui, tile.shrink(1.0));
},
None => {
let icon = match item.kind {
MentionKind::Image => Icon::Image,
MentionKind::Video => Icon::Video,
MentionKind::Audio => Icon::Music,
};
icons::paint(ui.painter(), tile.shrink(9.0), icon, theme::fade(Color32::WHITE, 0.6));
},
}
ui.painter().text(pos2(tile.right() + 10.0, row.center().y), Align2::LEFT_CENTER, &item.label, FontId::new(14.0, theme::medium()), item.color());
if resp.clicked() {
picked = Some(item.label.clone());
}
}
});
if let (Some(i), Some(q)) = (hovered, state.mention.as_mut()) {
q.selected = i;
}
if let Some(label) = picked {
insert_mention(ui.ctx(), id, prompt, state, &label);
ui.memory_mut(|m| m.request_focus(id));
}
}
/// The three-line grip that resizes the field (`.promptbox-resize-wrap::after`).
fn resize_grip(ui: &mut Ui, id: Id, field: Rect, state: &mut EditorState) {
let grip = Rect::from_min_size(pos2(field.right() - 12.0, field.bottom() - 12.0), vec2(12.0, 12.0));
let resp = ui.interact(grip, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeVertical);
if resp.dragged() {
let current = state.manual_height.unwrap_or(field.height());
state.manual_height = Some((current + resp.drag_delta().y).clamp(MIN_HEIGHT, MAX_HEIGHT));
state.expanded = false;
}
let ink = theme::fade(Color32::WHITE, if resp.hovered() || resp.dragged() { 0.8 } else { 0.5 });
let p = ui.painter();
let c = grip.right_bottom() - vec2(2.0, 2.0);
for k in [3.0, 6.0, 9.0] {
p.line_segment([c - vec2(k, 0.0), c - vec2(0.0, k)], Stroke::new(1.0, ink));
}
}
/// The prompt as a layout job with each known mention coloured and bold.
fn highlight(text: &str, mentions: &[MentionItem]) -> LayoutJob {
let plain = TextFormat { font_id: FontId::new(FONT_SIZE, FontFamily::Proportional), color: theme::INK, ..Default::default() };
let mut job = LayoutJob::default();
if mentions.is_empty() || !text.contains('@') {
job.append(text, 0.0, plain);
return job;
}
let mut sorted: Vec<&MentionItem> = mentions.iter().collect();
sorted.sort_by_key(|m| std::cmp::Reverse(m.label.len()));
let bytes = text.as_bytes();
let (mut start, mut i) = (0, 0);
while i < text.len() {
let hit = (bytes[i] == b'@').then(|| sorted.iter().find(|m| text.get(i..i + m.label.len()).is_some_and(|s| s.eq_ignore_ascii_case(&m.label)))).flatten();
match hit {
Some(m) => {
job.append(&text[start..i], 0.0, plain.clone());
let end = i + m.label.len();
job.append(&text[i..end], 0.0, TextFormat { font_id: FontId::new(FONT_SIZE, theme::semibold()), color: m.color(), ..Default::default() });
start = end;
i = end;
},
None => i += text[i..].chars().next().map_or(1, char::len_utf8),
}
}
job.append(&text[start..], 0.0, plain);
job
}
/// Mention labels for `n` items of `kind` (`@Image1`, `@Image2`, …).
pub fn mention_items(kind: MentionKind, previews: impl Iterator<Item = Option<String>>) -> Vec<MentionItem> {
let name = match kind {
MentionKind::Image => "Image",
MentionKind::Video => "Video",
MentionKind::Audio => "Audio",
};
previews.enumerate().map(|(i, preview)| MentionItem { label: format!("@{name}{}", i + 1), kind, index: i + 1, preview }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn colours_known_mentions_case_insensitively() {
let mentions = mention_items(MentionKind::Image, [None, None].into_iter());
let job = highlight("put @image2 next to @Image1, not @Image3", &mentions);
let coloured: Vec<&str> = job.sections.iter().filter(|s| s.format.color != theme::INK).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert_eq!(coloured, ["@image2", "@Image1"]);
assert_eq!(mentions[1].color(), IMAGE_COLORS[1]);
}
#[test]
fn counts_characters_not_bytes_for_the_limit() {
assert!(!is_over_limit("\u{65e5}\u{672c}\u{8a9e}", Some(3)));
assert!(is_over_limit("\u{65e5}\u{672c}\u{8a9e}!", Some(3)));
assert!(!is_over_limit("anything", None));
}
}
+346
View File
@@ -0,0 +1,346 @@
//! The prompt box shared by the create pages (`PromptBox` in the webapp): the reference deck or
//! keyframe slots beside the prompt, a toolbar of page-specific pickers, clear-all, the credit
//! cost and the generate button; plus drag & drop, pasted images and the focus-mode modal.
pub mod deck;
pub mod editor;
pub mod model_selector;
pub mod pickers;
pub mod refs;
#[cfg(test)]
mod ui_tests;
use std::path::PathBuf;
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::backend::media_cache::MediaCache;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
use deck::{DeckAction, DeckLimits};
use editor::{EditorOptions, EditorState, MentionItem};
use refs::{RefKind, References};
/// The box's widest (`max-w-5xl`).
pub const MAX_WIDTH: f32 = 1024.0;
/// What sits left of the prompt.
pub enum DeckMode {
None,
/// The fanned reference deck.
References(DeckLimits),
/// First (and maybe last) frame slots.
Keyframes {
show_last: bool,
library: bool,
},
}
/// The page's settings for one frame of the box.
pub struct PromptBoxProps<'a> {
pub id: Id,
pub placeholder: &'a str,
pub enter_to_generate: bool,
pub mentions: &'a [MentionItem],
/// `Some` shows the length counter (`Some(None)` = unlimited).
pub max_length: Option<Option<usize>>,
pub deck: DeckMode,
/// Which kinds a drop or paste may add right now.
pub accepts: &'a [RefKind],
/// The cost of generating, when known.
pub credits: Option<u64>,
pub generate_enabled: bool,
pub generating: bool,
/// Shown on the generate button.
pub generate_tooltip: &'a str,
/// A red note left of the generate button ("Starting frame required").
pub warning: Option<&'a str>,
/// An info strip above the prompt.
pub banner: Option<&'a str>,
}
/// Something the page should do.
#[derive(Debug)]
pub enum PromptBoxAction {
Generate,
Deck(DeckAction),
ClearAll,
DroppedFiles(Vec<PathBuf>),
PastedImage(Vec<u8>),
}
/// UI state that outlives a frame.
#[derive(Default)]
pub struct PromptBoxState {
pub editor: EditorState,
pub fullscreen: bool,
confirm_clear: bool,
focused: bool,
}
/// Draws the box in `ui` (which should be as wide as the box). `toolbar_left` holds the model
/// selector and pickers; `toolbar_right` the count picker. Both may be drawn twice in a frame
/// (box and focus mode), each under its own id.
#[allow(clippy::too_many_arguments)]
pub fn show(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), toolbar_right: &mut dyn FnMut(&mut Ui)) -> Vec<PromptBoxAction> {
let mut actions = Vec::new();
let frame = egui::Frame::new().fill(theme::fade(theme::CONTROLS, 0.95)).stroke(Stroke::new(1.0, if state.focused { theme::ACCENT } else { theme::LINE })).corner_radius(theme::RADIUS).inner_margin(16);
let response = frame.show(ui, |ui| {
if let Some(banner) = props.banner {
info_banner(ui, banner);
}
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 12.0;
deck_widget(ui, props, refs, cache, false, &mut actions);
ui.vertical(|ui| {
let out = editor_ui(ui, prompt, state, props, cache, None);
state.focused = out.focused;
if out.submit {
actions.push(PromptBoxAction::Generate);
}
if out.open_fullscreen {
state.fullscreen = true;
}
});
});
ui.add_space(14.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
toolbar_left(ui);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 6.0;
generate_cluster(ui, props, &mut actions);
clear_all_button(ui, prompt, refs, state, &mut actions);
toolbar_right(ui);
if let Some(warning) = props.warning {
warning_label(ui, warning);
}
});
});
});
let rect = response.response.rect;
expand_toggle(ui, props.id, rect, state);
drop_target(ui, props, rect, &mut actions);
paste_images(ui, props, &mut actions);
if state.fullscreen {
focus_mode(ui.ctx(), prompt, refs, state, props, cache, toolbar_left, &mut actions);
}
if state.confirm_clear {
confirm_clear(ui.ctx(), props.id, state, &mut actions);
}
actions
}
fn editor_ui(ui: &mut Ui, prompt: &mut String, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, fixed_height: Option<f32>) -> editor::EditorOutput {
let fullscreen = fixed_height.is_some();
let opts = EditorOptions { id: if fullscreen { props.id.with("focus-editor") } else { props.id.with("editor") }, placeholder: props.placeholder, enter_to_generate: props.enter_to_generate, mentions: props.mentions, max_length: if fullscreen { None } else { props.max_length }, fixed_height, fullscreen_button: !fullscreen, resizable: !fullscreen };
let ctx = ui.ctx().clone();
editor::show(ui, prompt, &mut state.editor, &opts, &mut |_, key| match cache.get(&ctx, key) {
crate::backend::media_cache::Lookup::Ready(t) => Some(t),
_ => None,
})
}
fn deck_widget(ui: &mut Ui, props: &PromptBoxProps<'_>, refs: &References, cache: &mut MediaCache, always_expanded: bool, actions: &mut Vec<PromptBoxAction>) {
let deck_actions = match &props.deck {
DeckMode::None => Vec::new(),
DeckMode::References(limits) => deck::reference_deck(ui, props.id.with(("deck", always_expanded)), refs, limits, cache, always_expanded),
DeckMode::Keyframes { show_last, library } => deck::keyframe_cards(ui, props.id.with(("keyframes", always_expanded)), refs, *show_last, *library, cache),
};
actions.extend(deck_actions.into_iter().map(PromptBoxAction::Deck));
}
/// The coins + cost and the round generate button (`GenerateIconButton`).
fn generate_cluster(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec<PromptBoxAction>) {
if widgets::generate_button(ui, props.generate_enabled && !props.generating, props.generating, props.generate_tooltip).clicked() {
actions.push(PromptBoxAction::Generate);
}
if let Some(credits) = props.credits {
let ink = theme::fade(theme::INK, if props.generate_enabled { 0.8 } else { 0.4 });
let galley = ui.painter().layout_no_wrap(credits.to_string(), FontId::new(13.0, theme::semibold()), ink);
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 22.0, 36.0), Sense::hover());
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 7.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, ink);
ui.painter().galley(pos2(rect.left() + 20.0, rect.center().y - galley.size().y / 2.0), galley, ink);
resp.on_hover_text(format!("{credits} credit{} cost", if credits == 1 { "" } else { "s" }));
}
}
/// The eraser button and the hairline after it (`PromptClearAllButton`).
fn clear_all_button(ui: &mut Ui, prompt: &mut String, refs: &References, state: &mut PromptBoxState, actions: &mut Vec<PromptBoxAction>) {
let (rule, _) = ui.allocate_exact_size(vec2(1.0, 20.0), Sense::hover());
ui.painter().rect_filled(rule, 0.0, theme::fade(theme::INK, 0.15));
let clearable = !prompt.is_empty() || !refs.is_empty();
let resp = ui.add_enabled_ui(clearable, |ui| widgets::ghost(ui, Icon::Eraser, None, "Clear all", false)).inner;
if resp.clicked() {
if refs.is_empty() {
prompt.clear();
actions.push(PromptBoxAction::ClearAll);
} else {
state.confirm_clear = true;
}
}
}
fn confirm_clear(ctx: &egui::Context, id: Id, state: &mut PromptBoxState, actions: &mut Vec<PromptBoxAction>) {
let mut choice = None;
let resp = widgets::modal(ctx, id.with("confirm-clear"), 420.0, |ui| {
ui.label(widgets::heading("Clear all?", 20.0));
ui.add_space(8.0);
ui.label(egui::RichText::new("This clears the prompt and also removes the attached references.").color(theme::MUTED));
ui.add_space(16.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Clear all", widgets::Kind::Danger, 0.0, theme::CONTROL_H).clicked() {
choice = Some(true);
}
if widgets::button(ui, None, "Cancel", widgets::Kind::Secondary, 0.0, theme::CONTROL_H).clicked() {
choice = Some(false);
}
});
});
if resp || choice.is_some() {
state.confirm_clear = false;
}
if choice == Some(true) {
actions.push(PromptBoxAction::ClearAll);
}
}
/// The chevron centred on the box's bottom edge that toggles the tall editor.
fn expand_toggle(ui: &mut Ui, id: Id, rect: Rect, state: &mut PromptBoxState) {
let button = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 6.0), vec2(38.0, 16.0));
let resp = ui.interact(button, id.with("expand"), Sense::click()).on_hover_text(if state.editor.expanded { "Collapse" } else { "Expand" }).on_hover_cursor(egui::CursorIcon::PointingHand);
let ink = theme::fade(Color32::WHITE, if resp.hovered() { 0.9 } else { 0.3 });
icons::paint(ui.painter(), Rect::from_center_size(button.center(), vec2(12.0, 12.0)), if state.editor.expanded { Icon::ChevronUp } else { Icon::ChevronDown }, ink);
if resp.clicked() {
state.editor.expanded = !state.editor.expanded;
state.editor.manual_height = None;
}
}
fn warning_label(ui: &mut Ui, text: &str) {
let pulse = (ui.input(|i| i.time) * 2.0).sin() as f32 * 0.25 + 0.75;
ui.label(egui::RichText::new(text).size(12.0).color(theme::BAD));
let (rect, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), rect, Icon::Info, theme::fade(theme::BAD, pulse));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(50));
}
fn info_banner(ui: &mut Ui, text: &str) {
egui::Frame::new().fill(theme::fade(theme::ACCENT, 0.12)).stroke(Stroke::new(1.0, theme::fade(theme::ACCENT, 0.35))).corner_radius(theme::RADIUS).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| {
ui.horizontal(|ui| {
icons::icon(ui, Icon::Info, 14.0, theme::ACCENT_INK);
ui.label(egui::RichText::new(text).size(12.5).color(theme::INK));
});
});
ui.add_space(10.0);
}
/// Files dragged over the window: an overlay on the box, and the drop when released.
fn drop_target(ui: &mut Ui, props: &PromptBoxProps<'_>, rect: Rect, actions: &mut Vec<PromptBoxAction>) {
let (hovered, dropped) = ui.input(|i| (i.raw.hovered_files.clone(), i.raw.dropped_files.clone()));
if !dropped.is_empty() {
let paths: Vec<PathBuf> = dropped.iter().map(|f| f.path().to_path_buf()).filter(|p| !p.as_os_str().is_empty()).collect();
if !paths.is_empty() {
actions.push(PromptBoxAction::DroppedFiles(paths));
}
return;
}
if hovered.is_empty() {
return;
}
let kinds: Vec<Option<RefKind>> = hovered.iter().map(|f| f.path.as_deref().and_then(RefKind::from_path)).collect();
// Without paths (some platforms) we can't tell yet: assume it fits.
let accepted = kinds.iter().all(|k| k.is_none() || k.is_some_and(|k| props.accepts.contains(&k))) && !props.accepts.is_empty();
let keyframes = matches!(props.deck, DeckMode::Keyframes { .. });
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, props.id.with("drop-overlay")));
p.rect_filled(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.85));
let border = if accepted { theme::fade(Color32::WHITE, 0.6) } else { theme::fade(theme::BAD, 0.8) };
icons::dashed_rect(&p, rect.shrink(1.0), Stroke::new(2.0, border), 6.0, 4.0);
let chip = Rect::from_center_size(rect.center() - vec2(0.0, 22.0), vec2(36.0, 36.0));
p.rect(chip, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
icons::paint(&p, chip.shrink(9.0), if accepted { Icon::Image } else { Icon::AlertCircle }, if accepted { theme::INK } else { theme::BAD });
let names: Vec<&str> = props
.accepts
.iter()
.map(|k| match k {
RefKind::Image => "Images",
RefKind::Video => "Videos",
RefKind::Audio => "Audio",
})
.collect();
let (title, subtitle) = if !accepted {
("That file type isn't supported".to_owned(), format!("Accepts {} files", names.join(", ").to_lowercase()))
} else if keyframes {
("Drop to set your frames".to_owned(), "Frames".to_owned())
} else {
("Drop to add references".to_owned(), names.join(", "))
};
p.text(rect.center() + vec2(0.0, 10.0), Align2::CENTER_CENTER, title, FontId::new(14.0, theme::semibold()), theme::INK);
p.text(rect.center() + vec2(0.0, 28.0), Align2::CENTER_CENTER, subtitle, FontId::new(12.0, egui::FontFamily::Proportional), theme::MUTED);
}
/// Ctrl/Cmd+V with an image on the clipboard. egui only forwards text pastes, so this watches for
/// the V key's release and reads the image itself.
fn paste_images(ui: &mut Ui, props: &PromptBoxProps<'_>, actions: &mut Vec<PromptBoxAction>) {
if props.accepts.is_empty() {
return;
}
let released = ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } if modifiers.command)));
if !released {
return;
}
let Ok(mut clipboard) = arboard::Clipboard::new() else {
return;
};
let Ok(image) = clipboard.get_image() else {
return;
};
let Some(rgba) = image::RgbaImage::from_raw(image.width as u32, image.height as u32, image.bytes.into_owned()) else {
return;
};
let mut png = Vec::new();
if image::DynamicImage::ImageRgba8(rgba).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() {
actions.push(PromptBoxAction::PastedImage(png));
}
}
/// Focus mode: a large editor in a modal, with the deck and toolbar at hand.
#[allow(clippy::too_many_arguments)]
fn focus_mode(ctx: &egui::Context, prompt: &mut String, refs: &References, state: &mut PromptBoxState, props: &PromptBoxProps<'_>, cache: &mut MediaCache, toolbar_left: &mut dyn FnMut(&mut Ui), actions: &mut Vec<PromptBoxAction>) {
let height = ctx.content_rect().height() * 0.7;
let mut done = false;
let closed = widgets::modal(ctx, props.id.with("focus-mode"), 896.0, |ui| {
ui.set_height(height);
ui.label(widgets::heading("Prompt", 22.0));
ui.add_space(10.0);
let editor_h = (height - 160.0 - if refs.is_empty() { 0.0 } else { 80.0 }).max(120.0);
let out = editor_ui(ui, prompt, state, props, cache, Some(editor_h));
if out.submit {
actions.push(PromptBoxAction::Generate);
}
if let Some(limit) = props.max_length {
let count = prompt.chars().count();
let over = editor::is_over_limit(prompt, limit);
let text = limit.map_or_else(|| format!("{count} / \u{221e}"), |max| format!("{count} / {max}"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
ui.label(egui::RichText::new(text).size(11.0).monospace().color(if over { Color32::from_rgb(0xef, 0x44, 0x44) } else { theme::FAINT }));
});
}
ui.push_id("focus-deck", |ui| deck_widget(ui, props, refs, cache, true, actions));
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.push_id("focus-toolbar", |ui| ui.horizontal(|ui| toolbar_left(ui)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, None, "Done", widgets::Kind::Primary, 0.0, theme::CONTROL_H).clicked() {
done = true;
}
ui.push_id("focus-clear", |ui| clear_all_button(ui, prompt, refs, state, actions));
});
});
});
if closed || done {
state.fullscreen = false;
state.editor.focus_requested = true;
}
}
@@ -0,0 +1,130 @@
//! The embedded model selector (`ClassyModelSelector variant="embedded"`): a pill with the
//! maker's logo and the model's name, opening "Select Model". Long lists group by family, each
//! family opening a flyout of its variants on hover.
use egui::{Align2, Id, Order, Popup, Rect, Ui, pos2};
use crate::models::{self, ModelInfo};
use crate::prompt_box::pickers;
use crate::ui::widgets::{self, Leading, RowLead};
use crate::ui::{creator_icons, icons};
const PANEL_WIDTH: f32 = 280.0;
const FLYOUT_WIDTH: f32 = 300.0;
/// Draws the selector; returns the id of a newly picked model.
pub fn model_selector(ui: &mut Ui, id: Id, models: &[ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let label = selected.map_or("Select model", |m| m.name.as_str());
let logo = selected.map(|m| creator_icons::texture(ui.ctx(), &m.creator));
let resp = ui.push_id(id, |ui| {
ui.set_max_width(192.0 + 40.0);
match &logo {
Some(t) => widgets::pill(ui, Leading::Logo(t), label, open),
None => widgets::pill(ui, Leading::Icon(icons::Icon::Sparkles), label, open),
}
});
let resp = resp.inner.on_hover_text("Model");
let mut picked = None;
let hovered_family_id = id.with("family");
pickers::popover(&resp, popup_id, |ui| {
ui.set_width(PANEL_WIDTH);
widgets::menu_header(ui, "Select Model");
let max_h = ui.ctx().content_rect().height() * 0.6;
egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| match models::grouped(models) {
Some(families) => {
let mut hovered: Option<String> = ui.data(|d| d.get_temp(hovered_family_id));
for family in &families {
let contains_selected = selected.is_some_and(|s| family.models.iter().any(|m| m.id == s.id));
let first = family.models[0];
let tex = creator_icons::texture(ui.ctx(), &first.creator);
let subtitle = if contains_selected { selected.map(|s| s.name.as_str()) } else { None };
let is_open = hovered.as_deref() == Some(family.name.as_str());
if family.models.len() == 1 {
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &first.name, non_empty(&first.description), contains_selected, false, false);
if row.hovered() {
hovered = None;
}
if row.clicked() {
picked = Some(first.id.clone());
}
continue;
}
let row = widgets::menu_row(ui, RowLead::Logo(&tex), &family.name, subtitle, false, true, is_open);
if row.hovered() || row.clicked() {
hovered = Some(family.name.clone());
}
if hovered.as_deref() == Some(family.name.as_str()) {
if let Some(id) = flyout(ui, id, row.rect, &family.name, &family.models, selected, show_badges) {
picked = Some(id);
}
}
}
ui.data_mut(|d| d.insert_temp(hovered_family_id, hovered));
},
None => {
for m in models {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
}
}
},
});
});
if picked.is_some() {
Popup::close_id(ui.ctx(), popup_id);
ui.data_mut(|d| d.remove::<Option<String>>(hovered_family_id));
}
picked.filter(|p| selected.is_none_or(|s| &s.id != p))
}
/// A family's variants, to the right of its row.
fn flyout(ui: &Ui, id: Id, row: Rect, family: &str, list: &[&ModelInfo], selected: Option<&ModelInfo>, show_badges: bool) -> Option<String> {
let mut picked = None;
egui::Area::new(id.with(("flyout", family))).order(Order::Tooltip).pivot(Align2::LEFT_TOP).fixed_pos(pos2(row.right() + 10.0, row.top() - 32.0)).show(ui.ctx(), |ui| {
widgets::popover_frame().show(ui, |ui| {
ui.set_width(FLYOUT_WIDTH);
widgets::menu_header(ui, family);
for m in list {
if model_row(ui, m, selected, show_badges) {
picked = Some(m.id.clone());
}
}
});
});
picked
}
/// One model: logo tile, name, description (and capability badges for video).
fn model_row(ui: &mut Ui, m: &ModelInfo, selected: Option<&ModelInfo>, show_badges: bool) -> bool {
let tex = creator_icons::texture(ui.ctx(), &m.creator);
let is_selected = selected.is_some_and(|s| s.id == m.id);
let badges = if show_badges { capability_badges(m) } else { String::new() };
let subtitle = match (m.description.is_empty(), badges.is_empty()) {
(true, true) => None,
(false, true) => Some(m.description.clone()),
(true, false) => Some(badges),
(false, false) => Some(format!("{} \u{b7} {badges}", m.description)),
};
widgets::menu_row(ui, RowLead::Logo(&tex), &m.name, subtitle.as_deref(), is_selected, false, false).clicked()
}
/// The video picker's capability badges ("Audio Support", "Start/End", "Reference").
fn capability_badges(m: &ModelInfo) -> String {
let mut badges = Vec::new();
if m.sound_toggle {
badges.push("Audio Support");
}
if m.start_frame && m.end_frame {
badges.push("Start/End");
}
if m.supports_reference_mode() {
badges.push("Reference");
}
badges.join(" \u{b7} ")
}
fn non_empty(s: &str) -> Option<&str> {
(!s.is_empty()).then_some(s)
}
+128
View File
@@ -0,0 +1,128 @@
//! Toolbar pickers: a pill (or quiet icon button) that opens a popover above it with a titled
//! option list, the duration slider, and on/off toggles.
use egui::{Popup, PopupCloseBehavior};
use egui::{Id, RectAlign, Response, Ui};
use crate::theme;
use crate::ui::icons::Icon;
use crate::ui::widgets::{self, Leading, RowLead};
/// What an option shows at its left.
#[derive(Clone, Debug, PartialEq)]
pub enum OptionLead {
None,
Icon(Icon),
Aspect(Option<(f32, f32)>),
}
#[derive(Clone, Debug)]
pub struct PickOption<T> {
pub value: T,
pub label: String,
pub subtitle: Option<String>,
pub lead: OptionLead,
}
impl<T> PickOption<T> {
pub fn new(value: T, label: impl Into<String>) -> Self {
Self { value, label: label.into(), subtitle: None, lead: OptionLead::None }
}
pub fn lead(mut self, lead: OptionLead) -> Self {
self.lead = lead;
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
}
/// How a picker's trigger looks.
pub enum Trigger<'a> {
/// A toolbar pill with a lead and the selected label.
Pill(Leading<'a>),
/// A quiet 36 px icon button followed by the selected label (the count picker).
Ghost(Icon),
}
/// A picker whose popover lists `options`; returns the newly chosen value.
pub fn option_picker<T: Clone + PartialEq>(ui: &mut Ui, id: Id, trigger: Trigger<'_>, title: &str, tooltip: &str, current: &T, options: &[PickOption<T>]) -> Option<T> {
let selected = options.iter().find(|o| o.value == *current);
let label = selected.map_or("", |o| o.label.as_str());
// Scoped to the parent, so the same picker in the box and in focus mode stay separate.
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let resp = ui.push_id(id, |ui| match trigger {
Trigger::Pill(lead) => widgets::pill(ui, lead, label, open),
Trigger::Ghost(icon) => widgets::ghost(ui, icon, Some(label), tooltip, open),
});
let resp = resp.inner.on_hover_text(tooltip);
let mut picked = None;
popover(&resp, popup_id, |ui| {
ui.set_min_width(180.0);
widgets::menu_header(ui, title);
for option in options {
let lead = match option.lead {
OptionLead::None => RowLead::None,
OptionLead::Icon(icon) => RowLead::Icon(icon),
OptionLead::Aspect(ratio) => RowLead::Aspect(ratio),
};
let is_selected = option.value == *current;
if widgets::menu_row(ui, lead, &option.label, option.subtitle.as_deref(), is_selected, false, false).clicked() {
picked = Some(option.value.clone());
}
}
});
if picked.is_some() {
Popup::close_id(ui.ctx(), popup_id);
}
picked.filter(|v| v != current)
}
/// Opens a popover above `anchor` (toggled by clicking it) and fills it with `content`.
pub fn popover(anchor: &Response, popup_id: Id, content: impl FnOnce(&mut Ui)) {
Popup::from_toggle_button_response(anchor).id(popup_id).align(RectAlign::TOP_START).align_alternatives(&[RectAlign::BOTTOM_START, RectAlign::TOP_END]).gap(6.0).frame(widgets::popover_frame()).close_behavior(PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
content(ui);
});
}
/// The duration popover: a clock pill reading `5s`, opening a 1-second-step slider between the
/// model's minimum and maximum. Returns the new value.
pub fn duration_picker(ui: &mut Ui, id: Id, current: u32, min: u32, max: u32) -> Option<u32> {
let popup_id = ui.make_persistent_id(id).with("popup");
let open = Popup::is_id_open(ui.ctx(), popup_id);
let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(Icon::Clock), &format!("{current}s"), open)).inner.on_hover_text("Duration");
let mut value = current;
popover(&resp, popup_id, |ui| {
ui.set_width(240.0);
widgets::menu_header(ui, "Duration");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.style_mut().spacing.slider_width = 210.0;
ui.add(egui::Slider::new(&mut value, min..=max).step_by(1.0).suffix("s").show_value(false));
});
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new(format!("{min}s")).size(11.0).color(theme::FAINT));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new(format!("{max}s")).size(11.0).color(theme::FAINT));
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new(format!("{value}s")).size(13.0).family(theme::semibold()).color(theme::INK));
});
});
});
});
(value != current).then_some(value)
}
/// An on/off toolbar toggle (sound): a pill that's highlighted while on.
pub fn toggle(ui: &mut Ui, id: Id, icon: Icon, label: &str, on: bool, tooltip: &str) -> bool {
let resp = ui.push_id(id, |ui| widgets::pill(ui, Leading::Icon(icon), label, on)).inner.on_hover_text(tooltip);
resp.clicked()
}
+169
View File
@@ -0,0 +1,169 @@
//! Reference media attached to a prompt: images, videos and audio clips, either uploaded from
//! disk or picked from the library.
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum RefKind {
Image,
Video,
Audio,
}
impl RefKind {
pub fn noun(self) -> &'static str {
match self {
RefKind::Image => "image",
RefKind::Video => "video",
RefKind::Audio => "audio",
}
}
/// Which kind a file is, by extension (the webapp's drop routing lists).
pub fn from_path(path: &std::path::Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "avif" => Some(RefKind::Image),
"mp4" | "mov" | "webm" | "mkv" | "avi" | "m4v" => Some(RefKind::Video),
"mp3" | "wav" | "ogg" | "flac" | "aac" | "m4a" | "opus" => Some(RefKind::Audio),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum RefStatus {
Uploading,
Ready,
}
/// One attached reference.
#[derive(Clone, Debug)]
pub struct RefMedia {
pub id: u64,
pub kind: RefKind,
pub status: RefStatus,
/// The server's media file token, once uploaded (or picked from the library).
pub token: Option<String>,
/// Media cache key of the thumbnail (a CDN URL or a `local://` key).
pub preview: Option<String>,
/// The full-size URL, for the preview modal.
pub full_url: Option<String>,
/// Seconds, for videos and audio (counts toward the model's total-duration limit).
pub duration_secs: f32,
}
impl RefMedia {
pub fn uploading(kind: RefKind, preview: Option<String>) -> Self {
Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Uploading, token: None, preview, full_url: None, duration_secs: 0.0 }
}
pub fn from_library(kind: RefKind, token: String, preview: Option<String>, full_url: Option<String>, duration_secs: f32) -> Self {
Self { id: NEXT_ID.fetch_add(1, Ordering::Relaxed), kind, status: RefStatus::Ready, token: Some(token), preview, full_url, duration_secs }
}
}
/// Where an image goes: the reference deck, or one of the two keyframe slots.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ImageSlot {
Reference,
FirstFrame,
LastFrame,
}
/// Everything attached to one prompt box.
#[derive(Clone, Debug, Default)]
pub struct References {
pub images: Vec<RefMedia>,
pub videos: Vec<RefMedia>,
pub audios: Vec<RefMedia>,
pub first_frame: Option<RefMedia>,
pub last_frame: Option<RefMedia>,
}
impl References {
pub fn is_empty(&self) -> bool {
self.images.is_empty() && self.videos.is_empty() && self.audios.is_empty() && self.first_frame.is_none() && self.last_frame.is_none()
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn list(&self, kind: RefKind) -> &Vec<RefMedia> {
match kind {
RefKind::Image => &self.images,
RefKind::Video => &self.videos,
RefKind::Audio => &self.audios,
}
}
pub fn list_mut(&mut self, kind: RefKind) -> &mut Vec<RefMedia> {
match kind {
RefKind::Image => &mut self.images,
RefKind::Video => &mut self.videos,
RefKind::Audio => &mut self.audios,
}
}
pub fn total_secs(&self, kind: RefKind) -> f32 {
self.list(kind).iter().map(|r| r.duration_secs).sum()
}
pub fn any_uploading(&self) -> bool {
let slots = self.first_frame.iter().chain(self.last_frame.iter());
self.images.iter().chain(&self.videos).chain(&self.audios).chain(slots).any(|r| r.status == RefStatus::Uploading)
}
/// Finds a reference anywhere by id.
pub fn find_mut(&mut self, id: u64) -> Option<&mut RefMedia> {
let slots = self.first_frame.iter_mut().chain(self.last_frame.iter_mut());
self.images.iter_mut().chain(self.videos.iter_mut()).chain(self.audios.iter_mut()).chain(slots).find(|r| r.id == id)
}
/// Removes a reference anywhere by id.
pub fn remove(&mut self, id: u64) {
for list in [&mut self.images, &mut self.videos, &mut self.audios] {
list.retain(|r| r.id != id);
}
if self.first_frame.as_ref().is_some_and(|r| r.id == id) {
self.first_frame = None;
}
if self.last_frame.as_ref().is_some_and(|r| r.id == id) {
self.last_frame = None;
}
}
pub fn swap_frames(&mut self) {
std::mem::swap(&mut self.first_frame, &mut self.last_frame);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn routes_files_by_extension() {
assert_eq!(RefKind::from_path(Path::new("a/B.PNG")), Some(RefKind::Image));
assert_eq!(RefKind::from_path(Path::new("clip.mov")), Some(RefKind::Video));
assert_eq!(RefKind::from_path(Path::new("beat.m4a")), Some(RefKind::Audio));
assert_eq!(RefKind::from_path(Path::new("notes.txt")), None);
}
#[test]
fn swapping_frames_and_removing_by_id() {
let mut refs = References::default();
let (a, b) = (RefMedia::from_library(RefKind::Image, "a".into(), None, None, 0.0), RefMedia::from_library(RefKind::Image, "b".into(), None, None, 0.0));
let a_id = a.id;
refs.first_frame = Some(a);
refs.last_frame = Some(b);
refs.swap_frames();
assert_eq!(refs.first_frame.as_ref().and_then(|r| r.token.as_deref()), Some("b"));
refs.remove(a_id);
assert!(refs.last_frame.is_none());
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Drives the prompt box headlessly (egui_kittest): generate gating, Enter-to-generate, the
//! pickers, @-mentions and clear-all.
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use crate::backend::media_cache::MediaCache;
use crate::models::ModelInfo;
use crate::pages::toolbar;
use crate::prompt_box::deck::DeckLimits;
use crate::prompt_box::editor::{MentionKind, mention_items};
use crate::prompt_box::refs::{RefKind, References};
use crate::prompt_box::{DeckMode, PromptBoxProps, PromptBoxState, show};
struct State {
// Keeps the media cache's runtime alive.
_rt: tokio::runtime::Runtime,
cache: MediaCache,
prompt: String,
refs: References,
box_state: PromptBoxState,
model: ModelInfo,
aspect: String,
enter_to_generate: bool,
with_mentions: bool,
actions: Vec<String>,
/// The harness draws one frame while building: fonts are installed then and used from the next.
fonts_ready: bool,
}
fn state() -> State {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().expect("runtime");
let cache = MediaCache::new(rt.handle().clone(), reqwest::Client::new());
let model = ModelInfo { id: "nano_banana_pro".into(), name: "Nano Banana Pro".into(), aspect_ratios: vec!["square".into(), "wide_sixteen_by_nine".into()], image_refs_max: 4, batch_default: 1, ..Default::default() };
State { _rt: rt, cache, prompt: String::new(), refs: References::default(), box_state: PromptBoxState::default(), model, aspect: "square".into(), enter_to_generate: false, with_mentions: false, actions: Vec::new(), fonts_ready: false }
}
fn build(state: State) -> Harness<'static, State> {
let mut harness = Harness::builder().with_size(egui::vec2(1100.0, 600.0)).build_ui_state(
|ui, s: &mut State| {
if !s.fonts_ready {
crate::theme::apply(ui.ctx());
s.fonts_ready = true;
return;
}
let mentions = if s.with_mentions { mention_items(MentionKind::Image, [None, None].into_iter()) } else { Vec::new() };
let props = PromptBoxProps { id: egui::Id::new("test-box"), placeholder: "Describe what you want in the image...", enter_to_generate: s.enter_to_generate, mentions: &mentions, max_length: Some(Some(1000)), deck: DeckMode::References(DeckLimits { max_images: 4, max_videos: 0, max_video_secs: None, max_audios: 0, max_audio_secs: None, library: false }), accepts: &[RefKind::Image], credits: Some(15), generate_enabled: !s.prompt.trim().is_empty(), generating: false, generate_tooltip: "Generate", warning: None, banner: None };
let (model, aspect) = (&s.model, &mut s.aspect);
let mut left = |ui: &mut egui::Ui| {
if let Some(v) = toolbar::aspect_ratio(ui, egui::Id::new("test-aspect"), model, aspect) {
*aspect = v;
}
};
let mut right = |_: &mut egui::Ui| {};
let actions = show(ui, &mut s.prompt, &s.refs, &mut s.box_state, &props, &mut s.cache, &mut left, &mut right);
s.actions.extend(actions.iter().map(|a| format!("{a:?}")));
},
state,
);
harness.run();
harness
}
fn editor<'h>(harness: &'h Harness<'_, State>) -> egui_kittest::Node<'h> {
harness.get_by_role(Role::MultilineTextInput)
}
#[test]
fn generate_needs_a_prompt() {
let mut harness = build(state());
harness.get_by_label("Generate").click();
harness.run();
assert!(harness.state().actions.is_empty(), "disabled without a prompt");
harness.state_mut().prompt = "a red knight".into();
harness.run();
harness.get_by_label("Generate").click();
harness.run();
assert_eq!(harness.state().actions, ["Generate"]);
}
#[test]
fn enter_generates_only_when_the_setting_is_on() {
let mut harness = build(state());
editor(&harness).focus();
harness.run();
editor(&harness).type_text("hello");
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "hello\n");
assert!(harness.state().actions.is_empty());
let mut s = state();
s.enter_to_generate = true;
let mut harness = build(s);
editor(&harness).focus();
harness.run();
editor(&harness).type_text("hello");
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "hello");
assert_eq!(harness.state().actions, ["Generate"]);
}
#[test]
fn aspect_ratio_picker_opens_and_picks() {
let mut harness = build(state());
harness.get_by_label("Square").click();
harness.run();
harness.get_by_label("16:9 (Wide)").click();
harness.run();
assert_eq!(harness.state().aspect, "wide_sixteen_by_nine");
assert!(harness.query_by_label("16:9 (Wide)").is_some(), "the pill now shows the pick");
}
#[test]
fn typing_at_offers_mentions_and_enter_inserts_one() {
let mut s = state();
s.with_mentions = true;
let mut harness = build(s);
editor(&harness).focus();
harness.run();
editor(&harness).type_text("put @Im");
harness.run();
assert!(harness.query_by_label("@Image2").is_some(), "dropdown lists the mentions");
harness.key_press(egui::Key::ArrowDown);
harness.key_press(egui::Key::Enter);
harness.run();
assert_eq!(harness.state().prompt, "put @Image2 ");
assert!(harness.state().actions.is_empty(), "Enter picked a mention, it didn't generate");
}
#[test]
fn clear_all_empties_the_prompt() {
let mut s = state();
s.prompt = "something".into();
let mut harness = build(s);
harness.get_by_label("Clear all").click();
harness.run();
assert_eq!(harness.state().prompt, "");
assert_eq!(harness.state().actions, ["ClearAll"]);
}
+395
View File
@@ -0,0 +1,395 @@
//! The app frame from the webapp: a floating sidebar (`app-sidebar`) and the top bar with
//! breadcrumbs, feed toggles, credits and the account menu (`topbar`).
use egui::{Align2, Color32, FontId, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use crate::feed::grid::ViewMode;
use crate::theme;
use crate::ui::icons::{self, Icon};
use crate::ui::widgets;
const SIDEBAR_WIDTH: f32 = 232.0;
const SIDEBAR_INSET: f32 = 8.0;
const TOPBAR_HEIGHT: f32 = 57.0;
const ROW_HEIGHT: f32 = 32.0;
pub const DISCORD_URL: &str = "https://discord.gg/artcraft";
pub const GITHUB_URL: &str = "https://github.com/storytold/artcraft";
pub const TUTORIALS_URL: &str = "https://www.youtube.com/@OfficialArtCraftStudios";
pub const WEB_APP_URL: &str = "https://app.getartcraft.com";
/// Every page in the navigation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Page {
Home,
#[default]
CreateImage,
CreateVideo,
CreateAudio,
Object3d,
World3d,
EditImage,
Stage3d,
VideoEditor,
RemoveBackground,
FrameExtractor,
Library,
}
impl Page {
pub fn label(self) -> &'static str {
match self {
Page::Home => "Home",
Page::CreateImage => "Image",
Page::CreateVideo => "Video",
Page::CreateAudio => "Audio",
Page::Object3d => "3D Object",
Page::World3d => "3D World",
Page::EditImage => "Edit Image",
Page::Stage3d => "3D Stage",
Page::VideoEditor => "Edit Video",
Page::RemoveBackground => "BG Remove",
Page::FrameExtractor => "Frame Extract",
Page::Library => "Library",
}
}
fn icon(self) -> Icon {
match self {
Page::Home => Icon::Home,
Page::CreateImage => Icon::Image,
Page::CreateVideo => Icon::Video,
Page::CreateAudio => Icon::Music,
Page::Object3d | Page::Stage3d => Icon::Box,
Page::World3d => Icon::Globe,
Page::EditImage => Icon::Pencil,
Page::VideoEditor | Page::FrameExtractor => Icon::Film,
Page::RemoveBackground => Icon::Wand,
Page::Library => Icon::Library,
}
}
/// The breadcrumb trail (`Create › Image`).
pub fn crumbs(self) -> (&'static str, &'static str) {
match self {
Page::Home => ("ArtCraft", "Home"),
Page::CreateImage | Page::CreateVideo | Page::CreateAudio | Page::Object3d | Page::World3d => ("Create", self.label()),
Page::Library => ("Assets", "Library"),
_ => ("Studio", self.label()),
}
}
/// A page by its web-app style slug (`create-image`), for `ARTCRAFT_PAGE`.
pub fn from_slug(slug: &str) -> Option<Self> {
Some(match slug {
"home" => Page::Home,
"create-image" | "image" => Page::CreateImage,
"create-video" | "video" => Page::CreateVideo,
"library" => Page::Library,
_ => return None,
})
}
/// Pages the native app has; the rest open a "coming soon" page.
pub fn is_ready(self) -> bool {
matches!(self, Page::Home | Page::CreateImage | Page::CreateVideo | Page::Library)
}
/// The same tool in the web app.
pub fn web_path(self) -> &'static str {
match self {
Page::CreateAudio => "/create-audio",
Page::Object3d => "/create-object",
Page::World3d => "/create-world",
Page::EditImage => "/pagedraw",
Page::Stage3d => "/pagescene",
Page::VideoEditor => "/video-editor",
Page::RemoveBackground => "/create-vfx",
Page::FrameExtractor => "/frame-extractor",
Page::CreateVideo => "/create-video",
Page::Library => "/library",
_ => "/create-image",
}
}
}
const SECTIONS: [(&str, &[Page]); 3] = [("Create", &[Page::CreateImage, Page::CreateVideo, Page::CreateAudio, Page::Object3d, Page::World3d]), ("Studio", &[Page::EditImage, Page::Stage3d, Page::VideoEditor, Page::RemoveBackground, Page::FrameExtractor]), ("Assets", &[Page::Library])];
/// Who's signed in, for the sidebar and top bar.
pub struct Account<'a> {
pub username: Option<&'a str>,
pub display_name: Option<&'a str>,
pub credits: Option<u64>,
}
/// What the user did in the frame.
#[derive(Debug, PartialEq)]
pub enum ShellAction {
Navigate(Page),
ToggleSidebar,
SignIn,
SignOut,
OpenSettings,
SetViewMode(ViewMode),
ToggleSelect,
}
/// The sidebar. Returns the user's action, if any.
pub fn sidebar(ui: &mut Ui, page: Page, account: &Account<'_>, logo: &egui::TextureHandle) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect().shrink(SIDEBAR_INSET);
ui.painter().rect(rect, theme::RADIUS, theme::CONTROLS, theme::hairline(), egui::StrokeKind::Inside);
let mut inner = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(14.0, 10.0))));
let ui = &mut inner;
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
let h = 20.0;
let w = h * logo.size_vec2().x / logo.size_vec2().y.max(1.0);
ui.add(egui::Image::new(logo).fit_to_exact_size(vec2(w, h)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {
action = Some(ShellAction::ToggleSidebar);
}
});
});
ui.add_space(6.0);
if nav_row(ui, Page::Home, page == Page::Home) {
action = Some(ShellAction::Navigate(Page::Home));
}
for (title, pages) in SECTIONS {
ui.add_space(10.0);
widgets::section_label(ui, title, theme::SIDEBAR_INK);
for &p in pages {
if nav_row(ui, p, page == p) {
action = Some(ShellAction::Navigate(p));
}
}
}
ui.add_space(10.0);
widgets::section_label(ui, "Resources", theme::SIDEBAR_INK);
for (label, icon, url) in [("Tutorials", Icon::Play, TUTORIALS_URL), ("Discord", Icon::Globe, DISCORD_URL), ("GitHub", Icon::ExternalLink, GITHUB_URL)] {
if link_row(ui, label, icon) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
// The account sits at the bottom, like the webapp's download button.
let bottom = Rect::from_min_max(pos2(rect.left() + 10.0, rect.bottom() - 58.0), pos2(rect.right() - 10.0, rect.bottom() - 10.0));
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(bottom));
match account.username {
Some(username) => {
let resp = account_row(&mut foot, account.display_name.unwrap_or(username), account.credits);
if resp.clicked() {
action = Some(ShellAction::OpenSettings);
}
},
None => {
if widgets::button(&mut foot, Some(Icon::LogIn), "Sign in", widgets::Kind::Primary, bottom.width(), 40.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
action
}
/// The sidebar's full width (including its inset), for the panel.
pub fn sidebar_width() -> f32 {
SIDEBAR_WIDTH + SIDEBAR_INSET * 2.0
}
fn nav_row(ui: &mut Ui, page: Page, selected: bool) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
if selected {
p.rect_filled(rect, theme::RADIUS, theme::WASH_SELECTED);
p.rect_filled(Rect::from_min_size(pos2(rect.left() - 8.0, rect.top() + 7.0), vec2(2.0, 18.0)), 0.0, Color32::WHITE);
} else if hovered {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = if selected || hovered { Color32::WHITE } else { theme::SIDEBAR_INK };
let ready = page.is_ready();
let ink = if ready { ink } else { theme::fade(ink, 0.7) };
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), page.icon(), ink);
let font = FontId::new(14.0, if selected { theme::medium() } else { egui::FontFamily::Proportional });
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, page.label(), font, ink);
if !ready {
widgets::badge(p, ui, pos2(rect.right() - 6.0, rect.center().y), "Soon", theme::FAINT);
}
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
fn link_row(ui: &mut Ui, label: &str, icon: Icon) -> bool {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
if hovered {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = if hovered { Color32::WHITE } else { theme::SIDEBAR_INK };
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(15.0, 15.0)), icon, ink);
p.text(pos2(rect.left() + 32.0, rect.center().y), Align2::LEFT_CENTER, label, FontId::new(14.0, egui::FontFamily::Proportional), ink);
icons::paint(p, Rect::from_center_size(pos2(rect.right() - 12.0, rect.center().y), vec2(11.0, 11.0)), Icon::ExternalLink, theme::FAINT);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
}
fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
let hovered = resp.hovered();
let p = ui.painter();
p.rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside);
let avatar = Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(28.0, 28.0));
p.circle_filled(avatar.center(), 14.0, theme::ACCENT);
let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE);
p.text(pos2(avatar.right() + 10.0, rect.center().y - 8.0), Align2::LEFT_CENTER, name, FontId::new(13.5, theme::medium()), theme::INK);
let sub = credits.map_or_else(|| "Account".to_owned(), |c| format!("{} credits", group_thousands(c)));
p.text(pos2(avatar.right() + 10.0, rect.center().y + 9.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
icons::paint(p, Rect::from_center_size(pos2(rect.right() - 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Settings, theme::DIM);
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text("Settings")
}
/// The top bar. `feed_toggles` shows the select and grid/list toggles (create pages).
pub fn topbar(ui: &mut Ui, page: Page, account: &Account<'_>, feed_toggles: Option<(ViewMode, bool)>, sidebar_open: bool) -> Option<ShellAction> {
let mut action = None;
let rect = ui.max_rect();
ui.painter().rect_filled(rect, 0.0, theme::PANEL);
ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, theme::hairline());
let mut bar = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(20.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let ui = &mut bar;
ui.spacing_mut().item_spacing.x = 8.0;
if !sidebar_open && widgets::ghost(ui, Icon::PanelLeft, None, "Toggle Sidebar", false).clicked() {
action = Some(ShellAction::ToggleSidebar);
}
let (section, leaf) = page.crumbs();
widgets::hud(ui, section, theme::MUTED);
let (chev, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
icons::paint(ui.painter(), chev, Icon::ChevronRight, theme::FAINT);
let galley = widgets::caps(ui, leaf, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::PANEL);
let (crumb, _) = ui.allocate_exact_size(galley.size() + vec2(12.0, 8.0), Sense::hover());
ui.painter().rect_filled(crumb, 0.0, theme::INK);
ui.painter().galley(crumb.center() - galley.size() / 2.0, galley, theme::PANEL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
match account.username {
Some(_) => {
if let Some(a) = account_menu(ui, account) {
action = Some(a);
}
if let Some(credits) = account.credits {
let resp = credits_pill(ui, credits);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(format!("{WEB_APP_URL}/pricing")));
}
}
},
None => {
if widgets::button(ui, None, "Sign in", widgets::Kind::Primary, 0.0, 32.0).clicked() {
action = Some(ShellAction::SignIn);
}
},
}
if let Some((mode, selecting)) = feed_toggles {
if let Some(m) = view_toggle(ui, mode) {
action = Some(ShellAction::SetViewMode(m));
}
if segmented(ui, &[(Icon::SquareCheck, if selecting { "Exit selection" } else { "Select items" }, selecting)]).is_some() {
action = Some(ShellAction::ToggleSelect);
}
}
});
action
}
pub fn topbar_height() -> f32 {
TOPBAR_HEIGHT
}
fn credits_pill(ui: &mut Ui, credits: u64) -> egui::Response {
let text = group_thousands(credits);
let galley = ui.painter().layout_no_wrap(text, FontId::new(13.0, theme::semibold()), theme::INK);
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 40.0, 30.0), Sense::click());
let hovered = resp.hovered();
ui.painter().rect(rect, theme::RADIUS, if hovered { theme::WASH_HOVER } else { theme::WASH }, theme::hairline(), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 16.0, rect.center().y), vec2(14.0, 14.0)), Icon::Coins, theme::INK);
ui.painter().galley(pos2(rect.left() + 28.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK);
resp.on_hover_text("Your credit balance \u{b7} Buy credits").on_hover_cursor(egui::CursorIcon::PointingHand)
}
fn account_menu(ui: &mut Ui, account: &Account<'_>) -> Option<ShellAction> {
let name = account.display_name.or(account.username).unwrap_or("Account");
let (rect, resp) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::click());
ui.painter().circle(rect.center(), 15.0, theme::ACCENT, Stroke::new(if resp.hovered() { 2.0 } else { 0.0 }, Color32::WHITE));
let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
ui.painter().text(rect.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
let mut action = None;
egui::Popup::menu(&resp).frame(widgets::popover_frame()).align(egui::RectAlign::BOTTOM_END).show(|ui| {
ui.set_min_width(200.0);
ui.label(egui::RichText::new(name).size(13.0).family(theme::semibold()));
if let Some(u) = account.username {
ui.label(egui::RichText::new(format!("@{u}")).size(11.5).color(theme::MUTED));
}
ui.add_space(4.0);
if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::Settings), "Settings", None, false, false, false).clicked() {
action = Some(ShellAction::OpenSettings);
}
if widgets::menu_row(ui, widgets::RowLead::Icon(Icon::LogOut), "Log out", None, false, false, false).clicked() {
action = Some(ShellAction::SignOut);
}
});
action
}
/// The grid / list toggle (`GalleryViewToggle`).
fn view_toggle(ui: &mut Ui, mode: ViewMode) -> Option<ViewMode> {
// Right-to-left: list first so grid ends up on the left.
let picked = segmented(ui, &[(Icon::List, "List view", mode == ViewMode::List), (Icon::LayoutGrid, "Grid view", mode == ViewMode::Grid)])?;
Some(if picked == 0 { ViewMode::List } else { ViewMode::Grid })
}
/// A segmented group of 24 px icon buttons; returns the clicked index.
fn segmented(ui: &mut Ui, buttons: &[(Icon, &str, bool)]) -> Option<usize> {
let w = 4.0 + 26.0 * buttons.len() as f32;
let (rect, _) = ui.allocate_exact_size(vec2(w, 30.0), Sense::hover());
ui.painter().rect(rect, theme::RADIUS, Color32::from_white_alpha(10), Stroke::new(1.0, Color32::from_white_alpha(20)), egui::StrokeKind::Inside);
let mut clicked = None;
for (i, (icon, tip, active)) in buttons.iter().enumerate() {
let b = Rect::from_min_size(pos2(rect.left() + 3.0 + 26.0 * i as f32, rect.top() + 3.0), vec2(24.0, 24.0));
let resp = ui.interact(b, Id::new(("segmented", *tip)), Sense::click()).on_hover_text(*tip).on_hover_cursor(egui::CursorIcon::PointingHand);
if *active || resp.hovered() {
ui.painter().rect_filled(b, theme::RADIUS, theme::WASH_HOVER);
}
icons::paint(ui.painter(), b.shrink(5.0), *icon, if *active { Color32::WHITE } else { theme::DIM });
if resp.clicked() {
clicked = Some(i);
}
}
clicked
}
/// `1234567` → `1,234,567` (`toLocaleString`).
pub fn group_thousands(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn groups_thousands_like_to_locale_string() {
assert_eq!(group_thousands(5663), "5,663");
assert_eq!(group_thousands(1_234_567), "1,234,567");
assert_eq!(group_thousands(15), "15");
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Colours, type and shapes of the ArtCraft webapp's brutalist system
//! (`artcraft-services/frontend/apps/artcraft-webapp`): near-black surfaces, white/15 hairlines,
//! Archivo display type, Inter body text, Geist Mono labels and 3 px corners on anything
//! pressable. Large surfaces (panels, cards, media tiles) stay square.
use std::sync::Arc;
use egui::epaint::text::VariationCoords;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
const fn hex(rgb: u32) -> Color32 {
let [_, r, g, b] = rgb.to_be_bytes();
Color32::from_rgb(r, g, b)
}
const fn alpha(rgb: u32, a: u8) -> Color32 {
let [_, r, g, b] = rgb.to_be_bytes();
Color32::from_rgba_unmultiplied_const(r, g, b, a)
}
/// `ui-background`: the page behind the feed.
pub const BG: Color32 = hex(0x121316);
/// `ui-panel`: the top bar, modals and the lightbox.
pub const PANEL: Color32 = hex(0x101014);
/// `ui-controls`: the sidebar, the prompt box and toolbar buttons.
pub const CONTROLS: Color32 = hex(0x1e1f22);
/// `ui-sunken`: wells and inputs.
pub const SUNKEN: Color32 = hex(0x060607);
/// `base-fg`: body text.
pub const INK: Color32 = hex(0xf2f1ee);
pub const INK_STRONG: Color32 = Color32::WHITE;
/// `base-fg/60`: secondary text and placeholders.
pub const MUTED: Color32 = alpha(0xf2f1ee, 153);
/// `base-fg/50`: idle toolbar icons.
pub const DIM: Color32 = alpha(0xf2f1ee, 128);
/// `base-fg/40`: counters and captions.
pub const FAINT: Color32 = alpha(0xf2f1ee, 102);
/// The sidebar's link colour (`white/72`).
pub const SIDEBAR_INK: Color32 = alpha(0xffffff, 184);
/// `white/15`: every hairline.
pub const LINE: Color32 = alpha(0xffffff, 38);
/// `white/25`: dashed drop targets.
pub const LINE_DASHED: Color32 = alpha(0xffffff, 64);
/// `base-fg/40`: outlined buttons and corner marks.
pub const LINE_STRONG: Color32 = alpha(0xf2f1ee, 102);
/// `white/5`: quiet fills.
pub const WASH: Color32 = alpha(0xffffff, 13);
/// `white/8`: the selected sidebar row.
pub const WASH_SELECTED: Color32 = alpha(0xffffff, 20);
/// `white/10`: hovered menu rows.
pub const WASH_HOVER: Color32 = alpha(0xffffff, 26);
/// The brand blue (`primary`).
pub const ACCENT: Color32 = hex(0x2d81ff);
/// `primary-400`: progress bars and selection rings.
pub const ACCENT_400: Color32 = hex(0x59a7ff);
/// `ui-accent-ink`: blue text and focus rings.
pub const ACCENT_INK: Color32 = hex(0x74aaff);
/// The inverted primary button (white block, near-black text).
pub const INVERT_BG: Color32 = Color32::WHITE;
pub const INVERT_FG: Color32 = hex(0x0b0b0c);
/// `red-400`: failure text.
pub const BAD: Color32 = hex(0xf87171);
/// `red`: destructive fills.
pub const DANGER: Color32 = hex(0xd33242);
/// The "BETA" / "NEW" sidebar badges.
pub const BADGE_BETA: Color32 = hex(0xffc14d);
/// 3 px: buttons, inputs, menus and the prompt box.
pub const RADIUS: CornerRadius = CornerRadius::same(3);
/// Height of toolbar buttons (`py-1.5` + 14 px text + hairline).
pub const CONTROL_H: f32 = 34.0;
/// The `.hud-label`: 11 px Geist Mono, upper case, 0.15 em apart.
pub const HUD_SIZE: f32 = 11.0;
pub const HUD_SPACING: f32 = HUD_SIZE * 0.15;
/// Section labels and mono buttons are tracked a little tighter (0.12 em).
pub const LABEL_SPACING: f32 = HUD_SIZE * 0.12;
pub fn hairline() -> Stroke {
Stroke::new(1.0, LINE)
}
/// `c` at `a` (0–1) of its opacity.
pub fn fade(c: Color32, a: f32) -> Color32 {
c.gamma_multiply(a.clamp(0.0, 1.0))
}
/// The shadow under menus, modals and toasts.
pub fn shadow() -> egui::epaint::Shadow {
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(140) }
}
/// Inter 500.
pub fn medium() -> FontFamily {
FontFamily::Name("medium".into())
}
/// Inter 600.
pub fn semibold() -> FontFamily {
FontFamily::Name("semibold".into())
}
/// Archivo 620 at 118 % width: `.font-display` headings.
pub fn display() -> FontFamily {
FontFamily::Name("display".into())
}
/// Geist Mono 500: HUD labels.
pub fn mono() -> FontFamily {
FontFamily::Name("mono".into())
}
/// Geist Mono 600–700: section labels and mono buttons.
pub fn mono_bold() -> FontFamily {
FontFamily::Name("mono-bold".into())
}
fn visuals() -> Visuals {
let mut v = Visuals::dark();
v.panel_fill = BG;
v.window_fill = PANEL;
v.extreme_bg_color = SUNKEN;
v.text_edit_bg_color = Some(Color32::TRANSPARENT);
v.faint_bg_color = CONTROLS;
v.code_bg_color = SUNKEN;
v.window_stroke = hairline();
v.window_corner_radius = RADIUS;
v.menu_corner_radius = RADIUS;
v.window_shadow = shadow();
v.popup_shadow = shadow();
v.override_text_color = Some(INK);
v.hyperlink_color = ACCENT_INK;
v.selection.bg_fill = ACCENT;
v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
v.text_cursor.stroke = Stroke::new(1.5, Color32::WHITE);
v.warn_fg_color = BADGE_BETA;
v.error_fg_color = BAD;
v.striped = false;
for (w, fill, stroke, fg) in [(&mut v.widgets.noninteractive, CONTROLS, LINE, MUTED), (&mut v.widgets.inactive, CONTROLS, LINE, INK), (&mut v.widgets.hovered, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.active, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.open, WASH_HOVER, LINE_STRONG, INK_STRONG)] {
w.bg_fill = fill;
w.weak_bg_fill = fill;
w.bg_stroke = Stroke::new(1.0, stroke);
w.corner_radius = RADIUS;
w.fg_stroke = Stroke::new(1.0, fg);
w.expansion = 0.0;
}
v
}
/// Installs the fonts, colours and spacing. The app is dark only, like the webapp.
pub fn apply(ctx: &egui::Context) {
install_fonts(ctx);
ctx.set_visuals_of(egui::Theme::Dark, visuals());
ctx.set_theme(egui::ThemePreference::Dark);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(12.0, 6.0);
style.spacing.interact_size.y = CONTROL_H;
style.spacing.menu_margin = egui::Margin::same(4);
style.spacing.window_margin = egui::Margin::same(16);
style.spacing.scroll = egui::style::ScrollStyle { bar_width: 8.0, floating: true, ..egui::style::ScrollStyle::thin() };
style.text_styles = [(TextStyle::Heading, FontId::new(24.0, display())), (TextStyle::Body, FontId::new(14.0, FontFamily::Proportional)), (TextStyle::Button, FontId::new(14.0, medium())), (TextStyle::Small, FontId::new(12.0, FontFamily::Proportional)), (TextStyle::Monospace, FontId::new(13.0, FontFamily::Monospace))].into();
});
}
/// A font registered under `name`: its file, its variation coordinates and its family.
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
/// Registers the three variable fonts, each at the weights (and width) the webapp uses.
fn install_fonts(ctx: &egui::Context) {
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");
let mut fonts = FontDefinitions::default();
// egui's own fonts stay behind ours, for symbols and emoji ours don't have.
let fallback = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let faces: [Face<'_>; 7] = [("Inter", INTER, &[(*b"wght", 400.0), (*b"opsz", 14.0)], FontFamily::Proportional), ("Inter-Medium", INTER, &[(*b"wght", 500.0), (*b"opsz", 14.0)], medium()), ("Inter-SemiBold", INTER, &[(*b"wght", 600.0), (*b"opsz", 14.0)], semibold()), ("Archivo-Display", ARCHIVO, &[(*b"wght", 620.0), (*b"wdth", 118.0)], display()), ("GeistMono", GEIST_MONO, &[(*b"wght", 400.0)], FontFamily::Monospace), ("GeistMono-Medium", GEIST_MONO, &[(*b"wght", 500.0)], mono()), ("GeistMono-SemiBold", GEIST_MONO, &[(*b"wght", 650.0)], mono_bold())];
for (name, bytes, coords, family) in faces {
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);
}
ctx.set_fonts(fonts);
}
+73
View File
@@ -0,0 +1,73 @@
//! Model makers' logos (the Tauri app's `/resources/images/services` SVGs), rasterized once into
//! white-on-transparent textures so they can be tinted like the webapp's `icon-auto-contrast`.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{ColorImage, TextureHandle, TextureOptions};
/// Rasterized side, in pixels (crisp up to ~32 pt at 2x).
const RASTER_SIDE: u32 = 64;
macro_rules! service_svg {
($name:literal) => {
include_bytes!(concat!("../../../frontend/apps/artcraft/app/public/resources/images/services/", $name, ".svg"))
};
}
/// The logo for a `ModelCreator` (snake_case, as the model listing sends it).
fn svg_for_creator(creator: &str) -> &'static [u8] {
match creator {
"alibaba" => service_svg!("alibaba"),
"artcraft" => service_svg!("artcraft"),
"black_forest_labs" | "blackforestlabs" | "bfl" => service_svg!("blackforestlabs"),
"bytedance" => service_svg!("bytedance"),
"fal" => service_svg!("fal"),
"google" => service_svg!("google"),
"grok" | "xai" => service_svg!("grok"),
"higgsfield" => service_svg!("higgsfield"),
"kling" | "kuaishou" => service_svg!("kling"),
"krea" => service_svg!("krea"),
"midjourney" => service_svg!("midjourney"),
"minimax" | "hailuo" => service_svg!("minimax"),
"open_ai" | "openai" => service_svg!("openai"),
"openart" | "open_art" => service_svg!("openart"),
"recraft" => service_svg!("recraft"),
"replicate" => service_svg!("replicate"),
"suno" => service_svg!("suno"),
"tencent" => service_svg!("tencent"),
"tensor_art" | "tensorart" => service_svg!("tensorart"),
"vidu" => service_svg!("vidu"),
"world_labs" | "worldlabs" => service_svg!("worldlabs"),
_ => service_svg!("generic"),
}
}
#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<String, TextureHandle>>>);
/// A white logo for `creator`, to be tinted when drawn.
pub fn texture(ctx: &egui::Context, creator: &str) -> TextureHandle {
let cache: Cache = ctx.data_mut(|d| d.get_temp_mut_or_default::<Cache>(egui::Id::new("creator-icons")).clone());
let mut map = cache.0.lock().unwrap_or_else(|e| e.into_inner());
if let Some(texture) = map.get(creator) {
return texture.clone();
}
let image = rasterize_mask(svg_for_creator(creator)).unwrap_or_else(|| ColorImage::filled([1, 1], egui::Color32::TRANSPARENT));
let texture = ctx.load_texture(format!("creator-{creator}"), image, TextureOptions::LINEAR);
map.insert(creator.to_owned(), texture.clone());
texture
}
/// Renders an SVG and keeps only its coverage: every pixel becomes white at the original alpha.
fn rasterize_mask(svg: &[u8]) -> Option<ColorImage> {
let tree = resvg::usvg::Tree::from_data(svg, &resvg::usvg::Options::default()).ok()?;
let size = tree.size();
let scale = RASTER_SIDE as f32 / size.width().max(size.height());
let mut pixmap = resvg::tiny_skia::Pixmap::new(RASTER_SIDE, RASTER_SIDE)?;
let dx = (RASTER_SIDE as f32 - size.width() * scale) / 2.0;
let dy = (RASTER_SIDE as f32 - size.height() * scale) / 2.0;
resvg::render(&tree, resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy), &mut pixmap.as_mut());
let pixels = pixmap.pixels().iter().map(|p| egui::Color32::from_white_alpha(p.alpha())).collect();
Some(ColorImage::new([RASTER_SIDE as usize, RASTER_SIDE as usize], pixels))
}
+370
View File
@@ -0,0 +1,370 @@
//! Line icons drawn on Lucide's 24-unit grid (the webapp's icon set), so they stay crisp at any
//! size without an icon font.
use egui::{Color32, Painter, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, vec2};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Icon {
AlertCircle,
ArrowLeftRight,
ArrowUp,
Box,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
Coins,
Copy,
Download,
Eraser,
Eye,
ExternalLink,
Film,
Folder,
Globe,
Gem,
Home,
Image,
Info,
LayoutGrid,
Link,
Library,
List,
LogIn,
LogOut,
Maximize,
Music,
PanelLeft,
Pencil,
Play,
Plus,
RotateCw,
Settings,
Sparkles,
SquareCheck,
Trash,
Upload,
Video,
Volume,
VolumeOff,
Wand,
X,
}
/// Maps Lucide's 24-unit grid onto a rect.
struct Grid {
origin: Pos2,
scale: f32,
stroke: Stroke,
}
impl Grid {
fn new(rect: Rect, color: Color32) -> Self {
let side = rect.width().min(rect.height());
let origin = rect.center() - vec2(side, side) / 2.0;
let scale = side / 24.0;
// Lucide's 2-unit stroke, a touch lighter so small icons don't clog.
Self { origin, scale, stroke: Stroke::new((1.75 * scale).max(1.1), color) }
}
fn p(&self, x: f32, y: f32) -> Pos2 {
self.origin + vec2(x, y) * self.scale
}
fn line(&self, painter: &Painter, pts: &[(f32, f32)]) {
let pts: Vec<Pos2> = pts.iter().map(|&(x, y)| self.p(x, y)).collect();
painter.add(Shape::line(pts, self.stroke));
}
fn closed(&self, painter: &Painter, pts: &[(f32, f32)]) {
let pts: Vec<Pos2> = pts.iter().map(|&(x, y)| self.p(x, y)).collect();
painter.add(Shape::closed_line(pts, self.stroke));
}
fn rect(&self, painter: &Painter, x: f32, y: f32, w: f32, h: f32, r: f32) {
let rect = Rect::from_min_size(self.p(x, y), vec2(w, h) * self.scale);
painter.rect_stroke(rect, r * self.scale, self.stroke, egui::StrokeKind::Middle);
}
fn circle(&self, painter: &Painter, x: f32, y: f32, r: f32) {
painter.circle_stroke(self.p(x, y), r * self.scale, self.stroke);
}
fn dot(&self, painter: &Painter, x: f32, y: f32, r: f32) {
painter.circle_filled(self.p(x, y), r * self.scale, self.stroke.color);
}
/// An arc round (cx, cy), angles in degrees clockwise from 3 o'clock.
fn arc(&self, painter: &Painter, cx: f32, cy: f32, r: f32, from: f32, to: f32) {
let steps = 24;
let pts: Vec<Pos2> = (0..=steps)
.map(|i| {
let a = (from + (to - from) * i as f32 / steps as f32).to_radians();
self.p(cx + r * a.cos(), cy + r * a.sin())
})
.collect();
painter.add(Shape::line(pts, self.stroke));
}
}
/// Paints `icon` centred in `rect`.
pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
let g = Grid::new(rect, color);
let p = painter;
match icon {
Icon::AlertCircle => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 8.0), (12.0, 12.0)]);
g.dot(p, 12.0, 16.0, 1.1);
},
Icon::ArrowLeftRight => {
g.line(p, &[(8.0, 3.0), (4.0, 7.0), (8.0, 11.0)]);
g.line(p, &[(4.0, 7.0), (20.0, 7.0)]);
g.line(p, &[(16.0, 21.0), (20.0, 17.0), (16.0, 13.0)]);
g.line(p, &[(20.0, 17.0), (4.0, 17.0)]);
},
Icon::ArrowUp => {
g.line(p, &[(5.0, 12.0), (12.0, 5.0), (19.0, 12.0)]);
g.line(p, &[(12.0, 19.0), (12.0, 5.0)]);
},
Icon::Box => {
g.closed(p, &[(12.0, 2.0), (21.0, 7.0), (21.0, 17.0), (12.0, 22.0), (3.0, 17.0), (3.0, 7.0)]);
g.line(p, &[(3.0, 7.0), (12.0, 12.0), (21.0, 7.0)]);
g.line(p, &[(12.0, 12.0), (12.0, 22.0)]);
},
Icon::Check => g.line(p, &[(20.0, 6.0), (9.0, 17.0), (4.0, 12.0)]),
Icon::ChevronDown => g.line(p, &[(6.0, 9.0), (12.0, 15.0), (18.0, 9.0)]),
Icon::ChevronLeft => g.line(p, &[(15.0, 18.0), (9.0, 12.0), (15.0, 6.0)]),
Icon::ChevronRight => g.line(p, &[(9.0, 18.0), (15.0, 12.0), (9.0, 6.0)]),
Icon::ChevronUp => g.line(p, &[(18.0, 15.0), (12.0, 9.0), (6.0, 15.0)]),
Icon::Clock => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 6.0), (12.0, 12.0), (16.0, 14.0)]);
},
Icon::Coins => {
g.circle(p, 8.0, 8.0, 6.0);
g.arc(p, 15.5, 15.5, 6.0, -100.0, 190.0);
g.line(p, &[(7.0, 6.0), (8.0, 5.0), (8.0, 11.0)]);
},
Icon::Copy => {
g.rect(p, 8.0, 8.0, 14.0, 14.0, 2.0);
g.line(p, &[(4.0, 16.0), (4.0, 4.0), (16.0, 4.0)]);
},
Icon::Download => {
g.line(p, &[(12.0, 3.0), (12.0, 15.0)]);
g.line(p, &[(7.0, 10.0), (12.0, 15.0), (17.0, 10.0)]);
g.line(p, &[(4.0, 21.0), (20.0, 21.0)]);
},
Icon::Eraser => {
g.closed(p, &[(7.0, 21.0), (3.5, 17.5), (14.0, 7.0), (21.0, 14.0), (14.0, 21.0)]);
g.line(p, &[(22.0, 21.0), (7.0, 21.0)]);
g.line(p, &[(9.0, 12.0), (16.0, 19.0)]);
},
Icon::ExternalLink => {
g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]);
g.line(p, &[(10.0, 14.0), (21.0, 3.0)]);
g.line(p, &[(18.0, 13.0), (18.0, 19.0), (16.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 8.0), (5.0, 6.0), (11.0, 6.0)]);
},
Icon::Eye => {
g.closed(p, &[(2.0, 12.0), (5.0, 7.5), (9.0, 5.2), (12.0, 4.8), (15.0, 5.2), (19.0, 7.5), (22.0, 12.0), (19.0, 16.5), (15.0, 18.8), (12.0, 19.2), (9.0, 18.8), (5.0, 16.5)]);
g.circle(p, 12.0, 12.0, 3.0);
},
Icon::Gem => {
g.closed(p, &[(6.0, 3.0), (18.0, 3.0), (22.0, 9.0), (12.0, 22.0), (2.0, 9.0)]);
g.line(p, &[(2.0, 9.0), (22.0, 9.0)]);
g.line(p, &[(11.0, 3.0), (8.0, 9.0), (12.0, 22.0), (16.0, 9.0), (13.0, 3.0)]);
},
Icon::Info => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(12.0, 16.0), (12.0, 12.0)]);
g.dot(p, 12.0, 8.0, 1.1);
},
Icon::Link => {
g.line(p, &[(10.0, 13.0), (11.0, 14.0), (14.0, 14.4), (16.5, 13.0), (20.0, 9.5), (20.6, 6.0), (18.0, 3.4), (14.5, 4.0), (12.8, 5.7)]);
g.line(p, &[(14.0, 11.0), (13.0, 10.0), (10.0, 9.6), (7.5, 11.0), (4.0, 14.5), (3.4, 18.0), (6.0, 20.6), (9.5, 20.0), (11.2, 18.3)]);
},
Icon::LogIn => {
g.line(p, &[(15.0, 3.0), (19.0, 3.0), (21.0, 5.0), (21.0, 19.0), (19.0, 21.0), (15.0, 21.0)]);
g.line(p, &[(10.0, 17.0), (15.0, 12.0), (10.0, 7.0)]);
g.line(p, &[(15.0, 12.0), (3.0, 12.0)]);
},
Icon::SquareCheck => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(8.0, 12.0), (11.0, 15.0), (16.0, 9.0)]);
},
Icon::Film => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(7.0, 3.0), (7.0, 21.0)]);
g.line(p, &[(17.0, 3.0), (17.0, 21.0)]);
g.line(p, &[(3.0, 12.0), (21.0, 12.0)]);
g.line(p, &[(3.0, 7.5), (7.0, 7.5)]);
g.line(p, &[(17.0, 7.5), (21.0, 7.5)]);
g.line(p, &[(3.0, 16.5), (7.0, 16.5)]);
g.line(p, &[(17.0, 16.5), (21.0, 16.5)]);
},
Icon::Folder => g.closed(p, &[(2.0, 5.0), (3.0, 4.0), (9.0, 4.0), (11.0, 7.0), (21.0, 7.0), (22.0, 8.0), (22.0, 19.0), (21.0, 20.0), (3.0, 20.0), (2.0, 19.0)]),
Icon::Globe => {
g.circle(p, 12.0, 12.0, 10.0);
g.line(p, &[(2.0, 12.0), (22.0, 12.0)]);
g.arc(p, 2.0, 12.0, 14.14, -45.0, 45.0);
g.arc(p, 22.0, 12.0, 14.14, 135.0, 225.0);
},
Icon::Home => {
g.closed(p, &[(3.0, 10.0), (12.0, 3.0), (21.0, 10.0), (21.0, 21.0), (3.0, 21.0)]);
g.line(p, &[(9.0, 21.0), (9.0, 14.0), (15.0, 14.0), (15.0, 21.0)]);
},
Icon::Image => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.circle(p, 9.0, 9.0, 2.0);
g.line(p, &[(21.0, 15.0), (16.0, 10.0), (5.0, 21.0)]);
},
Icon::LayoutGrid => {
for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] {
g.rect(p, x, y, 7.0, 7.0, 1.0);
}
},
Icon::Library => {
g.line(p, &[(16.0, 6.0), (20.0, 20.0)]);
g.line(p, &[(12.0, 6.0), (12.0, 20.0)]);
g.line(p, &[(8.0, 8.0), (8.0, 20.0)]);
g.line(p, &[(4.0, 4.0), (4.0, 20.0)]);
},
Icon::List => {
for y in [6.0, 12.0, 18.0] {
g.line(p, &[(8.0, y), (21.0, y)]);
g.dot(p, 3.5, y, 1.1);
}
},
Icon::LogOut => {
g.line(p, &[(9.0, 21.0), (5.0, 21.0), (3.0, 19.0), (3.0, 5.0), (5.0, 3.0), (9.0, 3.0)]);
g.line(p, &[(16.0, 17.0), (21.0, 12.0), (16.0, 7.0)]);
g.line(p, &[(21.0, 12.0), (9.0, 12.0)]);
},
Icon::Maximize => {
g.line(p, &[(15.0, 3.0), (21.0, 3.0), (21.0, 9.0)]);
g.line(p, &[(9.0, 21.0), (3.0, 21.0), (3.0, 15.0)]);
g.line(p, &[(21.0, 3.0), (14.0, 10.0)]);
g.line(p, &[(3.0, 21.0), (10.0, 14.0)]);
},
Icon::Music => {
g.line(p, &[(9.0, 18.0), (9.0, 5.0), (21.0, 3.0), (21.0, 16.0)]);
g.circle(p, 6.0, 18.0, 3.0);
g.circle(p, 18.0, 16.0, 3.0);
},
Icon::PanelLeft => {
g.rect(p, 3.0, 3.0, 18.0, 18.0, 2.0);
g.line(p, &[(9.0, 3.0), (9.0, 21.0)]);
},
Icon::Pencil => {
g.closed(p, &[(17.0, 3.0), (21.0, 7.0), (7.5, 20.5), (2.0, 22.0), (3.5, 16.5)]);
g.line(p, &[(15.0, 5.0), (19.0, 9.0)]);
},
Icon::Play => {
let pts = [g.p(7.0, 4.0), g.p(20.0, 12.0), g.p(7.0, 20.0)];
p.add(Shape::convex_polygon(pts.to_vec(), color, Stroke::NONE));
},
Icon::Plus => {
g.line(p, &[(5.0, 12.0), (19.0, 12.0)]);
g.line(p, &[(12.0, 5.0), (12.0, 19.0)]);
},
Icon::RotateCw => {
g.arc(p, 12.0, 12.0, 9.0, -60.0, 250.0);
g.line(p, &[(21.0, 3.0), (21.0, 8.5), (15.5, 8.5)]);
},
Icon::Settings => {
g.circle(p, 12.0, 12.0, 3.0);
g.circle(p, 12.0, 12.0, 7.5);
for i in 0..8 {
let a = (i as f32 * 45.0).to_radians();
g.line(p, &[(12.0 + 7.5 * a.cos(), 12.0 + 7.5 * a.sin()), (12.0 + 10.0 * a.cos(), 12.0 + 10.0 * a.sin())]);
}
},
Icon::Sparkles => {
let star = |cx: f32, cy: f32, r: f32| {
let k = r * 0.28;
g.closed(p, &[(cx, cy - r), (cx + k, cy - k), (cx + r, cy), (cx + k, cy + k), (cx, cy + r), (cx - k, cy + k), (cx - r, cy), (cx - k, cy - k)]);
};
star(10.0, 12.0, 8.0);
star(19.0, 5.0, 3.0);
},
Icon::Trash => {
g.line(p, &[(3.0, 6.0), (21.0, 6.0)]);
g.line(p, &[(19.0, 6.0), (19.0, 20.0), (17.0, 22.0), (7.0, 22.0), (5.0, 20.0), (5.0, 6.0)]);
g.line(p, &[(8.0, 6.0), (8.0, 4.0), (10.0, 2.0), (14.0, 2.0), (16.0, 4.0), (16.0, 6.0)]);
},
Icon::Upload => {
g.line(p, &[(12.0, 15.0), (12.0, 3.0)]);
g.line(p, &[(7.0, 8.0), (12.0, 3.0), (17.0, 8.0)]);
g.line(p, &[(4.0, 21.0), (20.0, 21.0)]);
},
Icon::Video => {
g.rect(p, 2.0, 6.0, 14.0, 12.0, 2.0);
g.closed(p, &[(16.0, 10.5), (22.0, 7.0), (22.0, 17.0), (16.0, 13.5)]);
},
Icon::Volume | Icon::VolumeOff => {
g.closed(p, &[(11.0, 5.0), (6.0, 9.0), (2.0, 9.0), (2.0, 15.0), (6.0, 15.0), (11.0, 19.0)]);
if icon == Icon::Volume {
g.arc(p, 12.0, 12.0, 4.0, -50.0, 50.0);
g.arc(p, 12.0, 12.0, 8.0, -50.0, 50.0);
} else {
g.line(p, &[(16.0, 9.0), (22.0, 15.0)]);
g.line(p, &[(22.0, 9.0), (16.0, 15.0)]);
}
},
Icon::Wand => {
g.line(p, &[(3.0, 21.0), (15.0, 9.0)]);
g.line(p, &[(15.0, 4.0), (15.0, 6.0)]);
g.line(p, &[(18.0, 9.0), (20.0, 9.0)]);
g.line(p, &[(18.5, 5.5), (17.0, 7.0)]);
g.line(p, &[(11.0, 5.0), (12.0, 6.0)]);
g.line(p, &[(19.0, 13.0), (18.0, 12.0)]);
},
Icon::X => {
g.line(p, &[(18.0, 6.0), (6.0, 18.0)]);
g.line(p, &[(6.0, 6.0), (18.0, 18.0)]);
},
}
}
/// Allocates a `size` square and paints `icon` in it.
pub fn icon(ui: &mut Ui, icon: Icon, size: f32, color: Color32) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
paint(ui.painter(), rect, icon, color);
resp
}
/// The outline of an aspect ratio `w:h`, fitted inside `rect` (the webapp's `AspectRatioIcon`).
/// `None` draws the dashed "auto" frame.
pub fn paint_aspect(painter: &Painter, rect: Rect, ratio: Option<(f32, f32)>, color: Color32) {
let side = rect.width().min(rect.height()) * 0.8;
let stroke = Stroke::new(1.4, color);
match ratio {
Some((w, h)) if w > 0.0 && h > 0.0 => {
let size = if w >= h { vec2(side, side * h / w) } else { vec2(side * w / h, side) };
let r = Rect::from_center_size(rect.center(), size);
painter.rect_stroke(r, 1.5, stroke, egui::StrokeKind::Middle);
},
_ => {
let r = Rect::from_center_size(rect.center(), vec2(side, side));
let shapes = Shape::dashed_line(&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], stroke, 2.5, 2.0);
painter.extend(shapes);
},
}
}
/// A spinner arc, like Lucide's `LoaderCircle` with `animate-spin`.
pub fn paint_spinner(ui: &Ui, rect: Rect, color: Color32) {
let t = ui.input(|i| i.time) as f32;
let g = Grid::new(rect, color);
let start = t * 360.0;
g.arc(ui.painter(), 12.0, 12.0, 9.0, start, start + 270.0);
ui.ctx().request_repaint();
}
/// A dashed outline round `rect` (drop targets and empty reference slots).
pub fn dashed_rect(painter: &Painter, rect: Rect, stroke: Stroke, dash: f32, gap: f32) {
let pts = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom(), rect.left_top()];
painter.extend(Shape::dashed_line(&pts, stroke, dash, gap));
}
+4
View File
@@ -0,0 +1,4 @@
pub mod creator_icons;
pub mod icons;
pub mod toast;
pub mod widgets;
+97
View File
@@ -0,0 +1,97 @@
//! Toasts: brief success and error notes stacked at the top right (the webapp's `toast`).
use std::time::Duration;
use egui::{Align2, Color32, FontId, Id, Order, Rect, Sense, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
const DEFAULT_DURATION: Duration = Duration::from_secs(4);
const MAX_WIDTH: f32 = 448.0;
/// Below the top bar, like the webapp's `top-16 right-5`.
const TOP_OFFSET: f32 = 64.0;
const RIGHT_OFFSET: f32 = 20.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToastKind {
Success,
Error,
}
struct Toast {
id: u64,
kind: ToastKind,
message: String,
expires_at: f64,
}
#[derive(Default)]
pub struct Toasts {
next_id: u64,
items: Vec<Toast>,
pending: Vec<(ToastKind, String)>,
}
impl Toasts {
pub fn success(&mut self, message: impl Into<String>) {
self.pending.push((ToastKind::Success, message.into()));
}
pub fn error(&mut self, message: impl Into<String>) {
self.pending.push((ToastKind::Error, message.into()));
}
/// Draws the stack over everything else and drops expired toasts.
pub fn show(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
for (kind, message) in self.pending.drain(..) {
// The same message twice in a row (e.g. a held Enter key) shows once, with a fresh timer.
self.items.retain(|t| !(t.kind == kind && t.message == message));
self.next_id += 1;
self.items.push(Toast { id: self.next_id, kind, message, expires_at: now + DEFAULT_DURATION.as_secs_f64() });
}
self.items.retain(|t| t.expires_at > now);
if self.items.is_empty() {
return;
}
let screen = ctx.content_rect();
let mut y = screen.top() + TOP_OFFSET;
let mut dismissed = None;
for toast in &self.items {
let area = egui::Area::new(Id::new(("toast", toast.id))).order(Order::Tooltip).pivot(Align2::RIGHT_TOP).fixed_pos(pos2(screen.right() - RIGHT_OFFSET, y)).interactable(true);
let inner = area.show(ctx, |ui| {
let galley = ui.painter().layout(toast.message.clone(), FontId::new(14.0, egui::FontFamily::Proportional), theme::fade(Color32::WHITE, 0.9), MAX_WIDTH - 80.0);
let h = galley.size().y.max(24.0) + 20.0;
let w = 16.0 + 24.0 + 12.0 + galley.size().x + 12.0 + 12.0 + 16.0;
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
let p = ui.painter();
p.rect(rect, theme::RADIUS, theme::fade(theme::CONTROLS, 0.97), theme::hairline(), egui::StrokeKind::Inside);
let badge = Rect::from_min_size(pos2(rect.left() + 16.0, rect.center().y - 12.0), vec2(24.0, 24.0));
let (fill, icon) = match toast.kind {
ToastKind::Success => (Color32::from_rgb(0x22, 0xc5, 0x5e), Icon::Check),
ToastKind::Error => (Color32::from_rgb(0xef, 0x44, 0x44), Icon::AlertCircle),
};
p.rect_filled(badge, 0.0, fill);
icons::paint(p, badge.shrink(6.0), icon, Color32::WHITE);
p.galley(pos2(badge.right() + 12.0, rect.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
let close = Rect::from_center_size(pos2(rect.right() - 22.0, rect.center().y), vec2(16.0, 16.0));
let close_resp = ui.interact(close, Id::new(("toast-close", toast.id)), Sense::click());
let ink = if close_resp.hovered() { theme::fade(Color32::WHITE, 0.8) } else { theme::fade(Color32::WHITE, 0.4) };
icons::paint(p, close.shrink(2.0), Icon::X, ink);
close_resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked()
});
if inner.inner {
dismissed = Some(toast.id);
}
y += inner.response.rect.height() + 8.0;
}
if let Some(id) = dismissed {
self.items.retain(|t| t.id != id);
}
// Wake up to expire the oldest toast on time.
if let Some(next) = self.items.iter().map(|t| t.expires_at).reduce(f64::min) {
ctx.request_repaint_after(Duration::from_secs_f64((next - now).max(0.0)));
}
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Building blocks in the webapp's brutalist style: mono HUD labels, 3 px toolbar pills, the round
//! generate button, popover menus and corner registration marks.
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, FontId, Frame, Galley, Margin, Painter, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use crate::theme;
use crate::ui::icons::{self, Icon};
/// `text` laid out upper case in `family`, `spacing` points between letters.
pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc<Galley> {
let format = egui::TextFormat { font_id: FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))
}
/// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.
pub fn hud(ui: &mut Ui, text: &str, color: Color32) -> Response {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, color);
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::hover());
ui.painter().galley(rect.min, galley, color);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
resp
}
/// A sidebar section or menu heading: 11 px Geist Mono 600, upper case.
pub fn section_label(ui: &mut Ui, text: &str, color: Color32) -> Response {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, color);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
let pos = pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0);
ui.painter().galley(pos, galley, color);
resp
}
/// A small badge after a sidebar link ("BETA", "NEW"): 9 px mono in `color` on a tinted chip.
pub fn badge(painter: &Painter, ui: &Ui, right_center: egui::Pos2, text: &str, color: Color32) {
let galley = caps(ui, text, 9.0, theme::mono_bold(), 0.9, color);
let size = galley.size() + vec2(10.0, 4.0);
let rect = Rect::from_min_size(pos2(right_center.x - size.x, right_center.y - size.y / 2.0), size);
painter.rect(rect, CornerRadius::same(2), theme::fade(color, 0.12), Stroke::new(1.0, theme::fade(color, 0.4)), egui::StrokeKind::Inside);
painter.galley(rect.center() - galley.size() / 2.0, galley, color);
}
/// What a [`pill`] shows before its label.
pub enum Leading<'a> {
None,
Icon(Icon),
/// An aspect ratio outline (`None` = the dashed "auto" frame).
Aspect(Option<(f32, f32)>),
/// A small caption in front of the label ("SD", "HD").
Caption(&'a str),
/// A model maker's logo.
Logo(&'a egui::TextureHandle),
}
/// A toolbar pill (`bg-ui-controls border-white/15 rounded-[3px] px-3 py-1.5 text-sm font-medium`),
/// as used for the model, aspect ratio, resolution and duration pickers.
pub fn pill(ui: &mut Ui, leading: Leading<'_>, label: &str, open: bool) -> Response {
let enabled = ui.is_enabled();
let font = FontId::new(14.0, theme::medium());
let galley = ui.painter().layout_no_wrap(label.to_owned(), font, theme::INK);
let caption = match leading {
Leading::Caption(c) => Some(caps(ui, c, 9.0, theme::mono_bold(), 0.6, theme::DIM)),
_ => None,
};
let lead_w = match (&leading, &caption) {
(Leading::None, _) => 0.0,
(_, Some(c)) => c.size().x + 6.0,
_ => 14.0 + 7.0,
};
let size = vec2(12.0 + lead_w + galley.size().x + 12.0, theme::CONTROL_H);
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let hovered = resp.hovered() && enabled;
let p = ui.painter();
let fill = if open || hovered { blend(theme::CONTROLS, theme::WASH_HOVER) } else { theme::CONTROLS };
let stroke = if open { theme::LINE_STRONG } else { theme::LINE };
p.rect(rect, theme::RADIUS, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
let ink = if enabled { theme::INK } else { theme::FAINT };
let mut x = rect.left() + 12.0;
let lead_rect = Rect::from_center_size(pos2(x + 7.0, rect.center().y), vec2(14.0, 14.0));
match leading {
Leading::None => {},
Leading::Icon(icon) => icons::paint(p, lead_rect, icon, ink),
Leading::Aspect(ratio) => icons::paint_aspect(p, lead_rect, ratio, ink),
Leading::Logo(texture) => {
egui::Image::new(texture).tint(ink).paint_at(ui, lead_rect);
},
Leading::Caption(_) => {
if let Some(c) = caption {
p.galley(pos2(x, rect.center().y - c.size().y / 2.0), c, theme::DIM);
}
},
}
x += lead_w;
p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, ink);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// A transparent toolbar button with an icon and an optional trailing label
/// (`text-base-fg/50`, `hover:bg-white/5`): the count picker and clear-all.
pub fn ghost(ui: &mut Ui, icon: Icon, label: Option<&str>, tip: &str, active: bool) -> Response {
let enabled = ui.is_enabled();
let galley = label.map(|l| ui.painter().layout_no_wrap(l.to_owned(), FontId::new(14.0, theme::medium()), theme::INK));
let w = match &galley {
Some(g) => 10.0 + 15.0 + 6.0 + g.size().x + 10.0,
None => 36.0,
};
let (rect, resp) = ui.allocate_exact_size(vec2(w, 36.0), Sense::click());
let hovered = resp.hovered() && enabled;
let p = ui.painter();
if hovered || active {
p.rect_filled(rect, theme::RADIUS, theme::WASH);
}
let ink = match (enabled, hovered || active) {
(false, _) => theme::fade(theme::DIM, 0.5),
(true, true) => theme::INK,
(true, false) => theme::DIM,
};
match galley {
Some(g) => {
let icon_rect = Rect::from_center_size(pos2(rect.left() + 10.0 + 7.5, rect.center().y), vec2(15.0, 15.0));
icons::paint(p, icon_rect, icon, ink);
p.galley_with_override_text_color(pos2(icon_rect.right() + 6.0, rect.center().y - g.size().y / 2.0), g, ink);
},
None => icons::paint(p, Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, ink),
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
let resp = resp.on_hover_text(tip);
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// A small square icon button for overlays (`h-7 w-7`, `hover:bg-white/15`).
pub fn overlay_icon(ui: &mut Ui, icon: Icon, tip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(rect, theme::RADIUS, Color32::from_white_alpha(38));
}
let ink = if hovered { Color32::WHITE } else { Color32::from_white_alpha(217) };
icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(14.0, 14.0)), icon, ink);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_text(tip).on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The round blue generate button (`GenerateIconButton`): an up arrow, a spinner while `loading`.
pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) -> Response {
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), if enabled { Sense::click() } else { Sense::hover() });
let hovered = resp.hovered() && enabled;
let fill = match (enabled, hovered) {
(false, _) => theme::fade(theme::ACCENT, 0.4),
(true, true) => theme::ACCENT_400,
(true, false) => theme::ACCENT,
};
ui.painter().circle_filled(rect.center(), 18.0, fill);
let ink = if enabled { Color32::WHITE } else { theme::fade(Color32::WHITE, 0.5) };
let inner = Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
if loading {
icons::paint_spinner(ui, inner, ink);
} else {
icons::paint(ui.painter(), inner, Icon::ArrowUp, ink);
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, "Generate"));
let resp = resp.on_hover_text(tip);
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Which of the site's block button variants.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// A white block with near-black text; fades a little on hover.
Primary,
/// Outlined in `base-fg/40`; inverts on hover.
Secondary,
/// The brand blue.
Accent,
/// Solid red.
Danger,
}
/// A block button with a mono upper-case label (`SIGN UP TO CREATE`, `DOWNLOAD`), at least `width`
/// wide and `height` tall.
pub fn button(ui: &mut Ui, icon: Option<Icon>, text: &str, kind: Kind, width: f32, height: f32) -> Response {
let enabled = ui.is_enabled();
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::INK);
let icon_w = if icon.is_some() { 13.0 + 8.0 } else { 0.0 };
let size = vec2((galley.size().x + icon_w + 40.0).max(width), height);
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let hovered = resp.hovered() && enabled;
let (fill, stroke, ink) = match kind {
Kind::Primary => (theme::fade(theme::INVERT_BG, if hovered { 0.88 } else { 1.0 }), Stroke::NONE, theme::INVERT_FG),
Kind::Accent => (if hovered { theme::ACCENT_400 } else { theme::ACCENT }, Stroke::NONE, Color32::WHITE),
Kind::Danger => (theme::fade(theme::DANGER, if hovered { 0.85 } else { 1.0 }), Stroke::NONE, Color32::WHITE),
Kind::Secondary if hovered => (theme::INVERT_BG, Stroke::NONE, theme::INVERT_FG),
Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, theme::LINE_STRONG), theme::INK),
};
let dim = |c: Color32| if enabled { c } else { theme::fade(c, 0.5) };
let p = ui.painter();
p.rect(rect, theme::RADIUS, dim(fill), Stroke::new(stroke.width, dim(stroke.color)), egui::StrokeKind::Inside);
let content_w = icon_w + galley.size().x;
let mut x = rect.center().x - content_w / 2.0;
if let Some(icon) = icon {
icons::paint(p, Rect::from_center_size(pos2(x + 6.5, rect.center().y), vec2(13.0, 13.0)), icon, dim(ink));
x += icon_w;
}
p.galley_with_override_text_color(pos2(x, rect.center().y - galley.size().y / 2.0), galley, dim(ink));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, text));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Fine registration marks at a panel's corners (the landing page's section corners).
pub fn corner_marks(painter: &Painter, rect: Rect) {
let stroke = Stroke::new(1.0, theme::LINE_STRONG);
for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] {
painter.hline(c.x - 5.5..=c.x + 5.5, c.y, stroke);
painter.vline(c.x, c.y - 5.5..=c.y + 5.5, stroke);
}
}
/// The frame of popovers and menus: the controls surface, a hairline and a soft shadow.
pub fn popover_frame() -> Frame {
Frame::new().fill(theme::CONTROLS).stroke(theme::hairline()).corner_radius(theme::RADIUS).shadow(theme::shadow()).inner_margin(Margin::same(4))
}
/// A popover's heading ("SELECT MODEL", "ASPECT RATIO") with a hairline under it.
pub fn menu_header(ui: &mut Ui, text: &str) {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::LABEL_SPACING, theme::fade(theme::INK, 0.5));
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
ui.painter().galley(pos2(rect.left() + 8.0, rect.center().y - galley.size().y / 2.0), galley, theme::INK);
}
/// What a [`menu_row`] shows at its left.
pub enum RowLead<'a> {
None,
Icon(Icon),
Aspect(Option<(f32, f32)>),
/// A 36 px tile with a model maker's logo (white texture, tinted).
Logo(&'a egui::TextureHandle),
}
/// One option in a popover: a lead, a title, an optional muted subtitle, and a check when
/// `selected` (or a chevron when `submenu`). Highlights on hover or when `highlighted`.
pub fn menu_row(ui: &mut Ui, lead: RowLead<'_>, title: &str, subtitle: Option<&str>, selected: bool, submenu: bool, highlighted: bool) -> Response {
let enabled = ui.is_enabled();
let h = if subtitle.is_some() { 48.0 } else { 34.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
let on = (resp.hovered() && enabled) || highlighted;
let p = ui.painter();
if on {
p.rect_filled(rect, theme::RADIUS, theme::WASH_HOVER);
}
let ink = if enabled { theme::INK } else { theme::FAINT };
let mut x = rect.left() + 8.0;
match lead {
RowLead::None => {},
RowLead::Icon(icon) => {
icons::paint(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(15.0, 15.0)), icon, ink);
x += 26.0;
},
RowLead::Aspect(ratio) => {
icons::paint_aspect(p, Rect::from_center_size(pos2(x + 8.0, rect.center().y), vec2(16.0, 16.0)), ratio, ink);
x += 26.0;
},
RowLead::Logo(texture) => {
let tile = Rect::from_center_size(pos2(x + 18.0, rect.center().y), vec2(36.0, 36.0));
let (fill, border) = if selected { (theme::WASH_HOVER, Color32::WHITE) } else { (theme::WASH, theme::LINE) };
p.rect(tile, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside);
egui::Image::new(texture).tint(ink).paint_at(ui, Rect::from_center_size(tile.center(), vec2(18.0, 18.0)));
x += 46.0;
},
}
let title_font = FontId::new(13.5, theme::medium());
match subtitle {
Some(sub) => {
p.text(pos2(x, rect.center().y - 8.0), Align2::LEFT_CENTER, title, title_font, ink);
p.text(pos2(x, rect.center().y + 8.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
},
None => {
p.text(pos2(x, rect.center().y), Align2::LEFT_CENTER, title, title_font, ink);
},
}
let trail = Rect::from_center_size(pos2(rect.right() - 14.0, rect.center().y), vec2(13.0, 13.0));
if selected {
icons::paint(p, trail, Icon::Check, theme::INK);
} else if submenu {
icons::paint(p, trail, Icon::ChevronRight, theme::DIM);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, title));
if enabled {
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
} else {
resp
}
}
/// Paints `fg` over `bg` (for hover fills on opaque surfaces).
pub fn blend(bg: Color32, fg: Color32) -> Color32 {
let a = fg.a() as f32 / 255.0;
let mix = |b: u8, f: u8| (b as f32 * (1.0 - a) + f as f32 * a).round() as u8;
let fg = fg.to_srgba_unmultiplied();
Color32::from_rgb(mix(bg.r(), fg[0]), mix(bg.g(), fg[1]), mix(bg.b(), fg[2]))
}
/// A thin square progress bar (`h-1 bg-white/10`, filled in `primary-400`).
pub fn progress_bar(painter: &Painter, rect: Rect, fraction: f32) {
painter.rect_filled(rect, CornerRadius::same(2), theme::WASH_HOVER);
let fill = Rect::from_min_size(rect.min, vec2(rect.width() * fraction.clamp(0.0, 1.0), rect.height()));
painter.rect_filled(fill, CornerRadius::same(2), theme::ACCENT_400);
}
/// A heading in the display face (Archivo 620, tracked in like the site's `tracking-[-0.03em]`).
pub fn heading(text: &str, size: f32) -> egui::RichText {
egui::RichText::new(text).size(size).family(theme::display()).color(theme::INK).extra_letter_spacing(-0.03 * size)
}
/// A square modal on the panel surface with a dimmed backdrop. Returns whether it asked to close
/// (backdrop click or Escape).
pub fn modal(ctx: &egui::Context, id: egui::Id, width: f32, content: impl FnOnce(&mut Ui)) -> bool {
let frame = Frame::new().fill(theme::PANEL).stroke(theme::hairline()).inner_margin(Margin::same(16)).shadow(theme::shadow());
let resp = egui::Modal::new(id).frame(frame).backdrop_color(Color32::from_black_alpha(153)).show(ctx, |ui| {
ui.set_width(width.min(ctx.content_rect().width() - 48.0));
content(ui);
});
resp.should_close()
}
/// A mono link with an ↗: muted, brighter on hover. Opens `url` in the browser.
pub fn hud_link(ui: &mut Ui, text: &str, url: &str) -> Response {
let galley = caps(ui, &format!("{text} \u{2197}"), theme::HUD_SIZE, theme::mono(), theme::HUD_SPACING, theme::MUTED);
let (rect, resp) = ui.allocate_exact_size(galley.size(), Sense::click());
let color = if resp.hovered() { theme::INK_STRONG } else { theme::MUTED };
ui.painter().galley_with_override_text_color(rect.min, galley, color);
let resp = resp.on_hover_text(url).on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
resp
}