mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
## 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>
205 lines
9.0 KiB
Rust
205 lines
9.0 KiB
Rust
//! 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);
|
|
}
|