Files
artcraft/artcraft-native/src/feed/grid.rs
T
BFlatandClaude Opus 5.5 b5614ad333 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>
2026-10-11 00:44:59 +07:00

532 lines
26 KiB
Rust

//! 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))
}
}