From 81d8d52a0955dc03d713e1ed6a2cdadcb77a91eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 12:35:23 -0400 Subject: [PATCH] T25b Generative: queue and results GenerationQueue shows each job's picture or where it stands, its prompt and its progress, with Cancel, Retry and Remove as its state allows; new rows fold in. GenerationGrid lays a run's results out in their shape: breathing while they come, the picture once done, why one failed with Retry. VariationPicker shows the chosen variation large over thumbnails and ways on from it. ABCompareView sets two to four models' answers side by side, blind until a verdict. ImageCompare points to data_display::BeforeAfter. primitives::framed and checked_ratio now serve previews and generative alike. AGENTS.md records that gpui reads a &str picture as a web address, and that grid rows overgrow tiles that keep a ratio. --- AGENTS.md | 2 + assets/icons/shuffle.svg | 19 ++ examples/gallery/pages/generative/mod.rs | 16 +- examples/gallery/pages/generative/results.rs | 199 ++++++++++++++ src/agent/previews.rs | 22 +- src/generative/compare.rs | 200 ++++++++++++++ src/generative/grid.rs | 212 +++++++++++++++ src/generative/mod.rs | 8 + src/generative/queue.rs | 268 +++++++++++++++++++ src/generative/tests/mod.rs | 194 +------------- src/generative/tests/results.rs | 176 ++++++++++++ src/generative/tests/settings.rs | 190 +++++++++++++ src/generative/variation.rs | 192 +++++++++++++ src/primitives/icon.rs | 1 + src/primitives/image.rs | 20 ++ src/primitives/mod.rs | 1 + tasks/ch21-30.md | 10 +- 17 files changed, 1515 insertions(+), 215 deletions(-) create mode 100644 assets/icons/shuffle.svg create mode 100644 examples/gallery/pages/generative/results.rs create mode 100644 src/generative/compare.rs create mode 100644 src/generative/grid.rs create mode 100644 src/generative/queue.rs create mode 100644 src/generative/tests/results.rs create mode 100644 src/generative/tests/settings.rs create mode 100644 src/generative/variation.rs diff --git a/AGENTS.md b/AGENTS.md index deb3cd2..155d913 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box. - Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures. - `img()` keeps loading state only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping. +- gpui reads a `&str` picture source as a web address. A file goes through `Path` (`documents::source` in the crate, `Path::new` in the gallery). +- Taffy 0.9's grid rows overgrow tiles that keep a ratio. Tiles in a picture's shape sit in flex rows of `flex_1` cells (`generative::GenerationGrid`). - Rise reads green and fall red unless a component's `red_up` swaps them, as markets in East Asia read. `finance::quotes::moves` picks the pair. - Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`. - A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size. diff --git a/assets/icons/shuffle.svg b/assets/icons/shuffle.svg new file mode 100644 index 0000000..93d272a --- /dev/null +++ b/assets/icons/shuffle.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs index eefedcc..45bde9b 100644 --- a/examples/gallery/pages/generative/mod.rs +++ b/examples/gallery/pages/generative/mod.rs @@ -1,4 +1,5 @@ mod panel; +mod results; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -19,8 +20,21 @@ const SCRIPT: &[Step] = &[ Step::UpAt("gen-enhancer", 356.0, 124.0), Step::Wait(1400), Step::Shot("suggested"), + Step::DownAt("gen-queue", 426.0, 280.0), + Step::UpAt("gen-queue", 426.0, 280.0), + Step::Wait(400), + Step::Shot("retried"), + Step::DownAt("gen-ab", 181.0, 147.0), + Step::UpAt("gen-ab", 181.0, 147.0), + Step::Wait(300), + Step::Shot("verdict"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { - div().child(panel::panel(window, cx)).into_any_element() + div() + .child(panel::panel(window, cx)) + .child(results::queue(window, cx)) + .child(results::results(window, cx)) + .child(results::variations(window, cx)) + .into_any_element() } diff --git a/examples/gallery/pages/generative/results.rs b/examples/gallery/pages/generative/results.rs new file mode 100644 index 0000000..aa6239e --- /dev/null +++ b/examples/gallery/pages/generative/results.rs @@ -0,0 +1,199 @@ +use std::{path::Path, time::Duration}; + +use ely_gpui_component::{ + data_display::BeforeAfter, + generative::{ + ABCompareView, GenerationGrid, GenerationQueue, Job, JobState, Outcome, VariationPicker, + Verdict, + }, + primitives::Image, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +fn jobs() -> Vec { + let job = |key: &str, prompt: &str, state, picture: Option<&str>| Job { + key: key.to_string().into(), + prompt: prompt.to_string().into(), + state, + picture: picture.map(|path| path.to_string().into()), + }; + vec![ + job( + "stair", + "a spiral staircase in a white atrium", + JobState::Done(Duration::from_secs(34)), + Some(asset!("atrium-stair.jpg")), + ), + job( + "dunes", + "dunes at dusk, grass in the wind, a low sun", + JobState::Running(0.62, Some(Duration::from_secs(12))), + None, + ), + job( + "olive", + "an olive tree in a limestone courtyard", + JobState::Queued(0), + None, + ), + job( + "hall", + "a long hall of arches, noon light", + JobState::Queued(1), + None, + ), + job( + "tide", + "a tide pool at low water, pale rocks", + JobState::Failed("out of memory on the GPU".into()), + None, + ), + ] +} + +pub fn queue(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let list = keep("gen-jobs", jobs, window, cx); + let now = list.read(cx).clone(); + let (canceled, retried, removed) = (list.clone(), list.clone(), list); + let drop = |jobs: &mut Vec, key: &SharedString| jobs.retain(|job| &job.key != key); + section( + "GenerationQueue", + "Generations in line: each shows its picture or where it stands, its prompt, and how far it has come. Cancel while it waits or runs, Retry once it fails, Remove once it ends; a retried job folds back in at the end.", + cx, + ) + .child(probe( + "gen-queue", + div().w(px(480.)).child( + GenerationQueue::new("gen-queue", now) + .on_cancel(move |key, _, cx| { + let mut next = canceled.read(cx).clone(); + drop(&mut next, key); + set(&canceled, next, cx) + }) + .on_retry(move |key, _, cx| { + let mut next = retried.read(cx).clone(); + let ahead = next.iter().filter(|job| matches!(job.state, JobState::Queued(_) | JobState::Running(..))).count(); + let job = next.iter().find(|job| &job.key == key).cloned().expect("a listed job"); + drop(&mut next, key); + next.push(Job { key: format!("{key}-again").into(), state: JobState::Queued(ahead), ..job }); + set(&retried, next, cx) + }) + .on_remove(move |key, _, cx| { + let mut next = removed.read(cx).clone(); + drop(&mut next, key); + set(&removed, next, cx) + }), + ), + )) +} + +pub fn results(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let tiles = keep( + "gen-results", + || { + vec![ + Outcome::Done(asset!("atrium.jpg").into()), + Outcome::Done(asset!("dunes.jpg").into()), + Outcome::Pending(Some(0.4)), + Outcome::Failed("Stopped by the content filter".into()), + ] + }, + window, + cx, + ); + let opened = keep("gen-opened", || None::, window, cx); + let (now, seen) = (tiles.read(cx).clone(), *opened.read(cx)); + section( + "GenerationGrid / ImageCompare", + "A run's results in its shape: a breathing placeholder with its percent while one comes, the picture once done, or why it failed with Retry. Beside it, an upscale against its source; drag the divider or use Left and Right.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(420.)).flex().flex_col().gap_2().child(probe( + "gen-grid", + div().w(px(420.)).child( + GenerationGrid::new("gen-grid", now, 1.5) + .on_open(move |ix, _, cx| set(&opened, Some(ix), cx)) + .on_retry(move |ix, _, cx| { + let mut next = tiles.read(cx).clone(); + next[ix] = Outcome::Pending(Some(0.05)); + set(&tiles, next, cx) + }), + ), + )) + .children(seen.map(|ix| format!("Opened result {}", ix + 1))), + ) + .child( + BeforeAfter::new( + "gen-compare", + Image::new("gen-compare-before", Path::new(asset!("atrium-before.jpg"))).size_full(), + Image::new("gen-compare-after", Path::new(asset!("atrium.jpg"))).size_full(), + ) + .w(px(420.)) + .h(px(280.)), + ), + ) +} + +pub fn variations(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep("gen-variation", || 0_usize, window, cx); + let varied = keep("gen-varied", || None::, window, cx); + let (now, said) = (*chosen.read(cx), varied.read(cx).clone()); + let (strong, up) = (varied.clone(), varied); + section( + "VariationPicker / ABCompareView", + "Variations of one picture, the chosen one large; vary it a little or a lot, or upscale it. Then two models' answers side by side, blind until you pick the better or call a tie.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(420.)).flex().flex_col().gap_2().child( + VariationPicker::new( + "gen-variations", + [asset!("atrium.jpg"), asset!("atrium-olive.jpg"), asset!("atrium-stair.jpg"), asset!("atrium-before.jpg")], + 1.5, + now, + ) + .on_choose(move |ix, _, cx| set(&chosen, ix, cx)) + .on_vary(move |strong_one, _, cx| { + let word = if strong_one { "Varying strongly" } else { "Varying subtly" }; + set(&strong, Some(word.into()), cx) + }) + .on_upscale(move |_, cx| set(&up, Some("Upscaling".into()), cx)), + ) + .children(said), + ) + .child(comparison(window, cx)), + ) +} + +fn comparison(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let verdict = keep("gen-verdict", || None::, window, cx); + let now = *verdict.read(cx); + let compare = ABCompareView::new("gen-ab") + .side("Orchid 3", "Morning light pools on a limestone stair as it turns up into a white atrium.") + .side("Juniper 2", "A white spiral stair rises through a quiet atrium, each step catching the first light.") + .blind() + .on_verdict(move |next, _, cx| set(&verdict, Some(next), cx)); + let compare = match now { + Some(now) => compare.verdict(now), + None => compare, + }; + probe("gen-ab", div().w(px(460.)).child(compare)) +} diff --git a/src/agent/previews.rs b/src/agent/previews.rs index e81f82a..0e2c713 100644 --- a/src/agent/previews.rs +++ b/src/agent/previews.rs @@ -12,7 +12,7 @@ use crate::{ forms::{Pick, Run, reveal, revealer}, layout::on_axis, motion::{self, Spinner}, - primitives::{FocusRing, Icon, IconName, Image, tab_stop}, + primitives::{FocusRing, Icon, IconName, Image, checked_ratio, framed, tab_stop}, theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize}, typography::Ellipsis, }; @@ -20,22 +20,6 @@ use crate::{ /// Width over height of a frame from the host, unless it says. const FRAME: f32 = 16.0 / 10.0; -fn checked(ratio: f32) -> f32 { - assert!(ratio.is_finite() && ratio > 0.0, "a frame ratio of {ratio}"); - ratio -} - -/// A box that keeps a frame's shape at the width it is given. -fn framed(ratio: f32, cx: &App) -> Div { - let mut frame = div() - .relative() - .w_full() - .overflow_hidden() - .bg(cx.theme().colors.sunken); - frame.style().aspect_ratio = Some(ratio); - frame -} - /// The bar over a page: a lock, the address, a spinner while it loads, and the owner's actions. fn address_bar( id: &ElementId, @@ -116,7 +100,7 @@ impl BrowserPreview { /// The frames' width over their height, 16:10 unless set, so the boxes take the pictures' shape. pub fn ratio(mut self, width_over_height: f32) -> Self { - self.ratio = checked(width_over_height); + self.ratio = checked_ratio(width_over_height); self } @@ -260,7 +244,7 @@ impl ComputerUseViewer { /// The frame's width over its height, 16:10 unless set, so the box takes the picture's shape. pub fn ratio(mut self, width_over_height: f32) -> Self { - self.ratio = checked(width_over_height); + self.ratio = checked_ratio(width_over_height); self } diff --git a/src/generative/compare.rs b/src/generative/compare.rs new file mode 100644 index 0000000..a42b96c --- /dev/null +++ b/src/generative/compare.rs @@ -0,0 +1,200 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::{Badge, Tone}, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::Ellipsis, +}; + +/// Which answer won a comparison, or neither. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verdict { + Side(usize), + Tie, +} + +type OnVerdict = Rc; + +/// A side's heading: its model's name, or a letter while the comparison is blind and undecided. +pub(crate) fn heading(ix: usize, name: &SharedString, blind: bool, decided: bool) -> SharedString { + match blind && !decided { + true => format!("Model {}", char::from(b'A' + ix as u8)).into(), + false => name.clone(), + } +} + +/// Answers from two to four models side by side, each under its model's name, with a way to pick the better or call a tie. Blind, the names stay letters until the verdict; after it, the winner takes accent. Narrow, the sides stack. +#[derive(IntoElement)] +pub struct ABCompareView { + id: ElementId, + sides: Vec<(SharedString, AnyElement)>, + blind: bool, + verdict: Option, + on_verdict: Option, +} + +impl ABCompareView { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + sides: Vec::new(), + blind: false, + verdict: None, + on_verdict: None, + } + } + + /// One model's answer under its name. + pub fn side(mut self, name: impl Into, answer: impl IntoElement) -> Self { + self.sides.push((name.into(), answer.into_any_element())); + self + } + + /// Hides the names until the verdict. + pub fn blind(mut self) -> Self { + self.blind = true; + self + } + + pub fn verdict(mut self, verdict: Verdict) -> Self { + self.verdict = Some(verdict); + self + } + + pub fn on_verdict( + mut self, + handler: impl Fn(Verdict, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_verdict = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ABCompareView { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let count = self.sides.len(); + assert!((2..=4).contains(&count), "a comparison of {count} sides"); + if let Some(Verdict::Side(ix)) = self.verdict { + assert!(ix < count, "verdict for side {ix} of {count}"); + } + let theme = cx.theme(); + let colors = theme.colors.clone(); + let decided = self.verdict.is_some(); + let judge = |verdict: Verdict, label: SharedString, key: String| { + self.on_verdict.clone().map(|judge| { + Button::new((self.id.clone(), key), label) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("comparison: {verdict:?}"); + judge(verdict, window, cx) + }) + }) + }; + let mut titles = Vec::new(); + let sides: Vec<_> = self + .sides + .into_iter() + .enumerate() + .map(|(ix, (name, answer))| { + let won = self.verdict == Some(Verdict::Side(ix)); + let title = heading(ix, &name, self.blind, decided); + titles.push(title.clone()); + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(if won { colors.accent } else { colors.border }) + .bg(colors.surface) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg) + .child(Ellipsis::new(title)), + ) + .when(won, |row| { + row.child( + div() + .flex_none() + .child(Badge::new("Preferred").tone(Tone::Accent)), + ) + }), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(answer), + ) + }) + .collect(); + let verdicts = match self.verdict { + Some(Verdict::Tie) => vec![div().child("Called a tie").into_any_element()], + Some(Verdict::Side(_)) => Vec::new(), + None => titles + .iter() + .enumerate() + .filter_map(|(ix, title)| { + judge( + Verdict::Side(ix), + format!("{title} is better").into(), + format!("better-{ix}"), + ) + .map(IntoElement::into_any_element) + }) + .chain( + judge(Verdict::Tie, "Tie".into(), "tie".into()) + .map(IntoElement::into_any_element), + ) + .collect(), + }; + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .child(div().w_full().flex().flex_wrap().gap_3().children(sides)) + .child( + div() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .children(verdicts), + ) + } +} + +#[cfg(test)] +mod tests { + use super::heading; + + #[test] + fn a_blind_side_shows_a_letter_until_the_verdict() { + let name = "Orchid".into(); + assert_eq!(heading(1, &name, true, false), "Model B"); + assert_eq!(heading(1, &name, true, true), "Orchid"); + assert_eq!(heading(0, &name, false, false), "Orchid"); + } +} diff --git a/src/generative/grid.rs b/src/generative/grid.rs new file mode 100644 index 0000000..7752998 --- /dev/null +++ b/src/generative/grid.rs @@ -0,0 +1,212 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + transparent_black, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + documents::source, + forms::Pick, + motion::Skeleton, + primitives::{Icon, IconName, Image, checked_ratio, framed, tab_stop}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{format::percent, tabular}, +}; + +/// One result of a run: on its way, with its share done when known; a picture; or why it failed. +#[derive(Clone, Debug, PartialEq)] +pub enum Outcome { + Pending(Option), + Done(SharedString), + Failed(SharedString), +} + +/// A run's results in columns, each in the run's shape: a breathing placeholder and its percent while it comes, the picture once done, or why it failed with Retry. A done picture is a Tab stop; a press or Enter opens it. +#[derive(IntoElement)] +pub struct GenerationGrid { + id: ElementId, + results: Vec, + ratio: f32, + columns: u16, + on_open: Option, + on_retry: Option, +} + +impl GenerationGrid { + /// `ratio` is the run's width over height. + pub fn new( + id: impl Into, + results: impl IntoIterator, + ratio: f32, + ) -> Self { + let results: Vec = results.into_iter().collect(); + for result in &results { + if let Outcome::Pending(Some(share)) = result { + assert!((0.0..=1.0).contains(share), "a result at {share} of 1"); + } + } + Self { + id: id.into(), + results, + ratio: checked_ratio(ratio), + columns: 2, + on_open: None, + on_retry: None, + } + } + + pub fn columns(mut self, columns: u16) -> Self { + assert!(columns > 0, "a grid needs a column"); + self.columns = columns; + self + } + + pub fn on_open(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + pub fn on_retry(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_retry = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for GenerationGrid { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let focuses: Vec<_> = (0..self.results.len()) + .map(|ix| { + let id = (self.id.clone(), format!("focus-{ix}")).into(); + let pressable = + matches!(self.results[ix], Outcome::Done(_)) && self.on_open.is_some(); + let focus = tab_stop(id, pressable, window, cx); + let focused = focus.is_focused(window); + (focus, focused) + }) + .collect(); + let radius = cx.theme().radius(Radius::Md); + let tiles: Vec<_> = self + .results + .into_iter() + .zip(focuses) + .enumerate() + .map(|(ix, (result, (focus, focused)))| { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let frame = framed(self.ratio, cx) + .id((self.id.clone(), format!("result-{ix}"))) + .rounded(radius) + .border_1() + .border_color(if focused { + colors.focus + } else { + transparent_black() + }); + match result { + Outcome::Pending(share) => frame + .child( + Skeleton::new((self.id.clone(), format!("pending-{ix}"))) + .size_full() + .rounded(radius), + ) + .children(share.map(|share| { + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(tabular(div()).child(percent(f64::from(share), 0, false))) + })) + .into_any_element(), + Outcome::Done(picture) => { + let open = self.on_open.clone(); + frame + .track_focus(&focus) + .when_some(open, |tile, open| { + tile.cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + log::info!("generation grid: open {ix}"); + open(ix, window, cx) + }) + }) + .child( + Image::new( + (self.id.clone(), format!("picture-{ix}")), + source(&picture), + ) + .size_full() + .rounded(radius), + ) + .into_any_element() + } + Outcome::Failed(reason) => { + let retry = self.on_retry.clone(); + frame + .child( + div() + .absolute() + .inset_0() + .p_3() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::CircleAlert) + .size(IconSize::Md) + .color(colors.danger), + ) + .child(div().text_center().child(reason)) + .children(retry.map(|retry| { + Button::new( + (self.id.clone(), format!("retry-{ix}")), + "Retry", + ) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .on_click( + move |_, window, cx| { + log::info!("generation grid: retry {ix}"); + retry(ix, window, cx) + }, + ) + })), + ) + .into_any_element() + } + } + }) + .collect(); + let columns = usize::from(self.columns); + let mut tiles = tiles.into_iter().peekable(); + let mut rows = Vec::new(); + while tiles.peek().is_some() { + let row: Vec<_> = tiles.by_ref().take(columns).collect(); + let short = columns - row.len(); + rows.push( + div() + .flex() + .gap_2() + .children( + row.into_iter() + .map(|tile| div().flex_1().min_w_0().child(tile)), + ) + .children((0..short).map(|_| div().flex_1())), + ); + } + div().w_full().flex().flex_col().gap_2().children(rows) + } +} diff --git a/src/generative/mod.rs b/src/generative/mod.rs index 38e6b36..5ec37a6 100644 --- a/src/generative/mod.rs +++ b/src/generative/mod.rs @@ -1,11 +1,19 @@ +mod compare; +mod grid; mod prompt; +mod queue; mod ratio; mod seed; mod style; #[cfg(all(test, feature = "test-support"))] mod tests; +mod variation; +pub use compare::{ABCompareView, Verdict}; +pub use grid::{GenerationGrid, Outcome}; pub use prompt::PromptEnhancer; +pub use queue::{GenerationQueue, Job, JobState}; pub use ratio::AspectRatioPicker; pub use seed::SeedInput; pub use style::{StylePreset, StylePresetPicker}; +pub use variation::VariationPicker; diff --git a/src/generative/queue.rs b/src/generative/queue.rs new file mode 100644 index 0000000..50dabc3 --- /dev/null +++ b/src/generative/queue.rs @@ -0,0 +1,268 @@ +use std::{rc::Rc, time::Duration}; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + documents::source, + forms::OnValue, + motion::{AnimatePresence, ProgressRing}, + primitives::{Icon, IconName, Image}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, + typography::{ + Ellipsis, + format::{DurationStyle, duration, percent}, + }, +}; + +/// Where a generation stands. +#[derive(Clone, Debug, PartialEq)] +pub enum JobState { + /// Waiting, with this many ahead of it. + Queued(usize), + /// Under way: the share done, and the time left when known. + Running(f32, Option), + /// Finished, and how long it took. + Done(Duration), + Failed(SharedString), +} + +/// One generation in line: its key, its prompt, where it stands, and its picture once done. +#[derive(Clone, Debug, PartialEq)] +pub struct Job { + pub key: SharedString, + pub prompt: SharedString, + pub state: JobState, + pub picture: Option, +} + +/// What a job's state says under its prompt. +pub(crate) fn status(state: &JobState) -> String { + match state { + JobState::Queued(0) => "Queued · next".to_string(), + JobState::Queued(ahead) => format!("Queued · {ahead} ahead"), + JobState::Running(share, None) => percent(f64::from(*share), 0, false), + JobState::Running(share, Some(left)) => format!( + "{} · about {} left", + percent(f64::from(*share), 0, false), + duration(left.as_secs().max(1), DurationStyle::Compact) + ), + JobState::Done(took) => format!( + "Done in {}", + duration(took.as_secs().max(1), DurationStyle::Compact) + ), + JobState::Failed(reason) => format!("Failed · {reason}"), + } +} + +/// Generations in line, newest last: each row shows its picture or where it stands, its prompt, and how far it has come; Cancel while it waits or runs, Retry once it fails, and Remove once it ends. New rows fold in. +#[derive(IntoElement)] +pub struct GenerationQueue { + id: ElementId, + jobs: Vec, + on_cancel: Option, + on_retry: Option, + on_remove: Option, +} + +impl GenerationQueue { + pub fn new(id: impl Into, jobs: impl IntoIterator) -> Self { + let jobs: Vec = jobs.into_iter().collect(); + for job in &jobs { + if let JobState::Running(share, _) = job.state { + assert!( + (0.0..=1.0).contains(&share), + "job {} at {share} of 1", + job.key + ); + } + } + Self { + id: id.into(), + jobs, + on_cancel: None, + on_retry: None, + on_remove: None, + } + } + + pub fn on_cancel( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } + + pub fn on_retry( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_retry = Some(Rc::new(handler)); + self + } + + pub fn on_remove( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for GenerationQueue { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (side, radius) = (theme.progress_ring(), theme.radius(Radius::Md)); + let action = |label: &'static str, key: &SharedString, handler: &Option| { + handler.clone().map(|handler| { + let key = key.clone(); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + log::info!("generation queue: {label} {key}"); + handler(&key, window, cx) + } + }) + }; + let rows = self.jobs.iter().map(|job| { + let id = (self.id.clone(), format!("job-{}", job.key)); + let lead = match (&job.state, &job.picture) { + (JobState::Running(share, _), _) => { + ProgressRing::new((self.id.clone(), format!("ring-{}", job.key)), *share) + .into_any_element() + } + (JobState::Done(_), Some(picture)) => div() + .size(side) + .child( + Image::new( + (self.id.clone(), format!("picture-{}", job.key)), + source(picture), + ) + .size_full() + .rounded(radius), + ) + .into_any_element(), + (state, _) => { + let (icon, ink) = match state { + JobState::Queued(_) => (IconName::Clock, colors.fg_subtle), + JobState::Failed(_) => (IconName::CircleAlert, colors.danger), + _ => (IconName::Check, colors.success), + }; + div() + .size(side) + .flex() + .items_center() + .justify_center() + .rounded(radius) + .bg(colors.sunken) + .child(Icon::new(icon).size(IconSize::Sm).color(ink)) + .into_any_element() + } + }; + let failed = matches!(job.state, JobState::Failed(_)); + let ended = matches!(job.state, JobState::Done(_) | JobState::Failed(_)); + let actions = div() + .flex_none() + .flex() + .items_center() + .gap_1() + .when(failed, |row| { + row.children(action("retry", &job.key, &self.on_retry).map(|retry| { + Button::new((self.id.clone(), format!("retry-{}", job.key)), "Retry") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(retry) + })) + }) + .children(match ended { + true => action("remove", &job.key, &self.on_remove).map(|remove| { + IconButton::new( + (self.id.clone(), format!("remove-{}", job.key)), + IconName::X, + ) + .size(ControlSize::Sm) + .tooltip("Remove") + .on_click(remove) + }), + false => action("cancel", &job.key, &self.on_cancel).map(|cancel| { + IconButton::new( + (self.id.clone(), format!("cancel-{}", job.key)), + IconName::X, + ) + .size(ControlSize::Sm) + .tooltip("Cancel") + .on_click(cancel) + }), + }); + let row = div() + .id(id) + .w_full() + .flex() + .items_center() + .gap_3() + .py_2() + .child(div().flex_none().child(lead)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(Ellipsis::new(job.prompt.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if failed { + colors.danger + } else { + colors.fg_muted + }) + .child(Ellipsis::new(status(&job.state))), + ), + ) + .child(actions); + (job.key.clone(), row) + }); + rows.fold( + AnimatePresence::new((self.id.clone(), "rows")), + |list, (key, row)| list.row(key, true, row), + ) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{JobState, status}; + + #[test] + fn a_status_says_where_a_job_stands() { + assert_eq!(status(&JobState::Queued(0)), "Queued · next"); + assert_eq!(status(&JobState::Queued(2)), "Queued · 2 ahead"); + let left = Some(Duration::from_millis(12_400)); + assert_eq!( + status(&JobState::Running(0.62, left)), + "62% · about 12s left" + ); + assert_eq!(status(&JobState::Running(0.05, None)), "5%"); + assert_eq!( + status(&JobState::Done(Duration::from_secs(95))), + "Done in 1m 35s" + ); + assert_eq!( + status(&JobState::Failed("out of memory".into())), + "Failed · out of memory" + ); + } +} diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index 3e494f6..23c35cb 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -1,17 +1,9 @@ -use std::{cell::RefCell, rc::Rc}; +use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext}; -use gpui::{ - AppContext as _, Context, Entity, Focusable, IntoElement, KeyBinding, KeyUpEvent, Keystroke, - ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, - px, -}; +use crate::{forms, primitives::FocusNext, theme::Theme}; -use crate::{ - forms::{self, TextInput}, - generative::{AspectRatioPicker, PromptEnhancer, SeedInput, StylePreset, StylePresetPicker}, - primitives::FocusNext, - theme::Theme, -}; +mod results; +mod settings; fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); @@ -42,181 +34,3 @@ fn setup(cx: &mut TestAppContext) { cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); }); } - -/// A prompt with its enhancer, a suggestion the test sets, and what it heard. -struct Enhancing { - field: Entity, - suggestion: Option, - heard: Vec, -} - -impl Render for Enhancing { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let (asked, resolved) = (cx.entity(), cx.entity()); - let enhancer = PromptEnhancer::new("enhancer", &self.field) - .on_enhance(move |prompt, _, cx| { - asked.update(cx, |view, _| view.heard.push(format!("asked {prompt}"))) - }) - .on_resolve(move |taken, _, cx| { - resolved.update(cx, |view, _| { - view.heard.push(format!("taken {taken}")); - view.suggestion = None; - }) - }); - let enhancer = match &self.suggestion { - Some(text) => enhancer.suggestion(text.clone()), - None => enhancer, - }; - div().w(px(420.0)).child(enhancer) - } -} - -fn enhancing(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { - setup(cx); - let (view, cx) = cx.add_window_view(|window, cx| Enhancing { - field: cx.new(|cx| TextInput::new(window, cx)), - suggestion: None, - heard: Vec::new(), - }); - cx.update(|window, _| window.activate_window()); - settle(cx); - (view, cx) -} - -#[gpui::test] -fn enhance_asks_with_the_trimmed_prompt_and_use_this_takes_the_offer(cx: &mut TestAppContext) { - let (view, cx) = enhancing(cx); - tab(1, cx); - cx.simulate_input(" a cat "); - tab(1, cx); - press("enter", cx); - view.update(cx, |view, cx| { - view.suggestion = Some("a fluffy cat asleep".into()); - cx.notify(); - }); - settle(cx); - tab(1, cx); - press("enter", cx); - view.read_with(cx, |view, cx| { - assert_eq!(view.heard, ["asked a cat", "taken true"]); - assert_eq!(view.field.read(cx).text(), "a fluffy cat asleep"); - }); - assert!(field_focused(&view, cx), "the field takes focus back"); -} - -fn field_focused(view: &Entity, cx: &mut VisualTestContext) -> bool { - cx.update(|window, cx| view.read(cx).field.focus_handle(cx).is_focused(window)) -} - -#[gpui::test] -fn keep_mine_leaves_the_prompt_as_written(cx: &mut TestAppContext) { - let (view, cx) = enhancing(cx); - tab(1, cx); - cx.simulate_input("a cat"); - view.update(cx, |view, cx| { - view.suggestion = Some("a fluffy cat".into()); - cx.notify(); - }); - settle(cx); - tab(3, cx); - press("enter", cx); - view.read_with(cx, |view, cx| { - assert_eq!(view.heard, ["taken false"]); - assert_eq!(view.field.read(cx).text(), "a cat"); - }); - assert!(field_focused(&view, cx), "the field takes focus back"); -} - -/// Three presets, two ratios, a seed, and every pick heard. -struct Picks { - seed: Entity, - style: SharedString, - heard: Rc>>, -} - -impl Render for Picks { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let (styled, shaped) = (self.heard.clone(), self.heard.clone()); - let preset = |key: &str| StylePreset { - key: key.to_string().into(), - name: key.to_string().into(), - picture: None, - }; - div() - .w(px(420.0)) - .child( - StylePresetPicker::new( - "styles", - [preset("a"), preset("b"), preset("c")], - self.style.clone(), - ) - .on_select(move |key, _, _| styled.borrow_mut().push(format!("style {key}"))), - ) - .child( - AspectRatioPicker::new("ratios", [(1, 1), (4, 3)], (1, 1)).on_select( - move |ratio, _, _| { - shaped - .borrow_mut() - .push(format!("ratio {}:{}", ratio.0, ratio.1)) - }, - ), - ) - .child(SeedInput::new("seed", &self.seed)) - } -} - -fn picks( - cx: &mut TestAppContext, -) -> ( - Entity, - Rc>>, - &mut VisualTestContext, -) { - setup(cx); - let heard = Rc::new(RefCell::new(Vec::new())); - let store = heard.clone(); - let (view, cx) = cx.add_window_view(|window, cx| Picks { - seed: cx.new(|cx| TextInput::new(window, cx)), - style: "a".into(), - heard: store, - }); - cx.update(|window, _| window.activate_window()); - settle(cx); - (view, heard, cx) -} - -#[gpui::test] -fn tabs_reach_each_tile_and_chip_and_enter_picks_it(cx: &mut TestAppContext) { - let (_, heard, cx) = picks(cx); - tab(1, cx); - press("enter", cx); - tab(1, cx); - press("enter", cx); - tab(3, cx); - press("enter", cx); - assert_eq!( - *heard.borrow(), - ["style b", "ratio 4:3"], - "the chosen tile stays quiet" - ); -} - -#[gpui::test] -fn a_seed_keeps_digits_and_the_die_rolls_one(cx: &mut TestAppContext) { - let (view, _, cx) = picks(cx); - tab(6, cx); - cx.simulate_input("12a3"); - view.read_with(cx, |view, cx| { - assert_eq!(SeedInput::read(&view.seed, cx), Some(123)) - }); - tab(1, cx); - press("enter", cx); - view.read_with(cx, |view, cx| { - let text = view.seed.read(cx).text().to_string(); - assert_ne!(text, "123", "the die rolls a new seed"); - assert_eq!( - SeedInput::read(&view.seed, cx).map(|seed| seed.to_string()), - Some(text) - ); - }); -} diff --git a/src/generative/tests/results.rs b/src/generative/tests/results.rs new file mode 100644 index 0000000..889ee25 --- /dev/null +++ b/src/generative/tests/results.rs @@ -0,0 +1,176 @@ +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use gpui::{Context, IntoElement, ParentElement, Render, Styled, TestAppContext, Window, div, px}; + +use super::{press, settle, setup, tab}; +use crate::generative::{ + ABCompareView, GenerationGrid, GenerationQueue, Job, JobState, Outcome, VariationPicker, + Verdict, +}; + +type Heard = Rc>>; + +/// A queue of a running, a failed and a finished job, and the actions heard. +struct Queue(Heard); + +impl Render for Queue { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let job = |key: &str, state| Job { + key: key.to_string().into(), + prompt: "a white atrium".into(), + state, + picture: None, + }; + let (canceled, retried, removed) = (self.0.clone(), self.0.clone(), self.0.clone()); + div().w(px(420.0)).child( + GenerationQueue::new( + "queue", + [ + job("run", JobState::Running(0.4, Some(Duration::from_secs(12)))), + job("fail", JobState::Failed("out of memory".into())), + job("done", JobState::Done(Duration::from_secs(30))), + ], + ) + .on_cancel(move |key, _, _| canceled.borrow_mut().push(format!("cancel {key}"))) + .on_retry(move |key, _, _| retried.borrow_mut().push(format!("retry {key}"))) + .on_remove(move |key, _, _| removed.borrow_mut().push(format!("remove {key}"))), + ) + } +} + +#[gpui::test] +fn each_job_offers_what_its_state_allows(cx: &mut TestAppContext) { + setup(cx); + let heard = Heard::default(); + let store = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Queue(store)); + cx.update(|window, _| window.activate_window()); + settle(cx); + for _ in 0..4 { + tab(1, cx); + press("enter", cx); + } + assert_eq!( + *heard.borrow(), + ["cancel run", "retry fail", "remove fail", "remove done"] + ); +} + +/// A grid of a picture, a pending result and a failure, and what it heard. +struct Grid(Heard); + +impl Render for Grid { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (opened, retried) = (self.0.clone(), self.0.clone()); + let results = [ + Outcome::Pending(Some(0.3)), + Outcome::Done("missing.jpg".into()), + Outcome::Failed("blocked".into()), + ]; + div().w(px(420.0)).child( + GenerationGrid::new("grid", results, 1.5) + .on_open(move |ix, _, _| opened.borrow_mut().push(format!("open {ix}"))) + .on_retry(move |ix, _, _| retried.borrow_mut().push(format!("retry {ix}"))), + ) + } +} + +#[gpui::test] +fn only_done_results_open_and_a_failure_retries(cx: &mut TestAppContext) { + setup(cx); + let heard = Heard::default(); + let store = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Grid(store)); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab(1, cx); + press("enter", cx); + tab(1, cx); + press("enter", cx); + assert_eq!( + *heard.borrow(), + ["open 1", "retry 2"], + "the pending result takes no Tab" + ); +} + +/// Three variations with the first chosen, and what it heard. +struct Variations(Heard); + +impl Render for Variations { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (chosen, varied) = (self.0.clone(), self.0.clone()); + div().w(px(420.0)).child( + VariationPicker::new("variations", ["a.jpg", "b.jpg", "c.jpg"], 1.5, 0) + .on_choose(move |ix, _, _| chosen.borrow_mut().push(format!("choose {ix}"))) + .on_vary(move |strong, _, _| varied.borrow_mut().push(format!("vary {strong}"))), + ) + } +} + +#[gpui::test] +fn a_thumbnail_chooses_and_the_chosen_one_is_quiet(cx: &mut TestAppContext) { + setup(cx); + let heard = Heard::default(); + let store = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Variations(store)); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab(1, cx); + press("enter", cx); + tab(2, cx); + press("enter", cx); + tab(2, cx); + press("enter", cx); + assert_eq!(*heard.borrow(), ["choose 2", "vary true"]); +} + +/// A blind comparison of two answers, deciding what it hears. +struct Compared { + verdict: Option, + heard: Heard, +} + +impl Render for Compared { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let compare = ABCompareView::new("compare") + .side("Orchid", "one answer") + .side("Juniper", "another") + .blind() + .on_verdict(move |verdict, _, cx| { + view.update(cx, |compared, cx| { + compared.heard.borrow_mut().push(format!("{verdict:?}")); + compared.verdict = Some(verdict); + cx.notify(); + }) + }); + let compare = match self.verdict { + Some(verdict) => compare.verdict(verdict), + None => compare, + }; + div().w(px(420.0)).child(compare) + } +} + +#[gpui::test] +fn a_verdict_is_given_once(cx: &mut TestAppContext) { + setup(cx); + let heard = Heard::default(); + let store = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Compared { + verdict: None, + heard: store, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab(2, cx); + press("enter", cx); + tab(1, cx); + press("enter", cx); + assert_eq!( + *heard.borrow(), + ["Side(1)"], + "the buttons leave with the verdict" + ); +} diff --git a/src/generative/tests/settings.rs b/src/generative/tests/settings.rs new file mode 100644 index 0000000..5c3abfe --- /dev/null +++ b/src/generative/tests/settings.rs @@ -0,0 +1,190 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, SharedString, + Styled, TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{press, settle, setup, tab}; +use crate::{ + forms::TextInput, + generative::{AspectRatioPicker, PromptEnhancer, SeedInput, StylePreset, StylePresetPicker}, +}; + +/// A prompt with its enhancer, a suggestion the test sets, and what it heard. +struct Enhancing { + field: Entity, + suggestion: Option, + heard: Vec, +} + +impl Render for Enhancing { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (asked, resolved) = (cx.entity(), cx.entity()); + let enhancer = PromptEnhancer::new("enhancer", &self.field) + .on_enhance(move |prompt, _, cx| { + asked.update(cx, |view, _| view.heard.push(format!("asked {prompt}"))) + }) + .on_resolve(move |taken, _, cx| { + resolved.update(cx, |view, _| { + view.heard.push(format!("taken {taken}")); + view.suggestion = None; + }) + }); + let enhancer = match &self.suggestion { + Some(text) => enhancer.suggestion(text.clone()), + None => enhancer, + }; + div().w(px(420.0)).child(enhancer) + } +} + +fn enhancing(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|window, cx| Enhancing { + field: cx.new(|cx| TextInput::new(window, cx)), + suggestion: None, + heard: Vec::new(), + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, cx) +} + +#[gpui::test] +fn enhance_asks_with_the_trimmed_prompt_and_use_this_takes_the_offer(cx: &mut TestAppContext) { + let (view, cx) = enhancing(cx); + tab(1, cx); + cx.simulate_input(" a cat "); + tab(1, cx); + press("enter", cx); + view.update(cx, |view, cx| { + view.suggestion = Some("a fluffy cat asleep".into()); + cx.notify(); + }); + settle(cx); + tab(1, cx); + press("enter", cx); + view.read_with(cx, |view, cx| { + assert_eq!(view.heard, ["asked a cat", "taken true"]); + assert_eq!(view.field.read(cx).text(), "a fluffy cat asleep"); + }); + assert!(field_focused(&view, cx), "the field takes focus back"); +} + +fn field_focused(view: &Entity, cx: &mut VisualTestContext) -> bool { + cx.update(|window, cx| view.read(cx).field.focus_handle(cx).is_focused(window)) +} + +#[gpui::test] +fn keep_mine_leaves_the_prompt_as_written(cx: &mut TestAppContext) { + let (view, cx) = enhancing(cx); + tab(1, cx); + cx.simulate_input("a cat"); + view.update(cx, |view, cx| { + view.suggestion = Some("a fluffy cat".into()); + cx.notify(); + }); + settle(cx); + tab(3, cx); + press("enter", cx); + view.read_with(cx, |view, cx| { + assert_eq!(view.heard, ["taken false"]); + assert_eq!(view.field.read(cx).text(), "a cat"); + }); + assert!(field_focused(&view, cx), "the field takes focus back"); +} + +/// Three presets, two ratios, a seed, and every pick heard. +struct Picks { + seed: Entity, + style: SharedString, + heard: Rc>>, +} + +impl Render for Picks { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (styled, shaped) = (self.heard.clone(), self.heard.clone()); + let preset = |key: &str| StylePreset { + key: key.to_string().into(), + name: key.to_string().into(), + picture: None, + }; + div() + .w(px(420.0)) + .child( + StylePresetPicker::new( + "styles", + [preset("a"), preset("b"), preset("c")], + self.style.clone(), + ) + .on_select(move |key, _, _| styled.borrow_mut().push(format!("style {key}"))), + ) + .child( + AspectRatioPicker::new("ratios", [(1, 1), (4, 3)], (1, 1)).on_select( + move |ratio, _, _| { + shaped + .borrow_mut() + .push(format!("ratio {}:{}", ratio.0, ratio.1)) + }, + ), + ) + .child(SeedInput::new("seed", &self.seed)) + } +} + +fn picks( + cx: &mut TestAppContext, +) -> ( + Entity, + Rc>>, + &mut VisualTestContext, +) { + setup(cx); + let heard = Rc::new(RefCell::new(Vec::new())); + let store = heard.clone(); + let (view, cx) = cx.add_window_view(|window, cx| Picks { + seed: cx.new(|cx| TextInput::new(window, cx)), + style: "a".into(), + heard: store, + }); + cx.update(|window, _| window.activate_window()); + settle(cx); + (view, heard, cx) +} + +#[gpui::test] +fn tabs_reach_each_tile_and_chip_and_enter_picks_it(cx: &mut TestAppContext) { + let (_, heard, cx) = picks(cx); + tab(1, cx); + press("enter", cx); + tab(1, cx); + press("enter", cx); + tab(3, cx); + press("enter", cx); + assert_eq!( + *heard.borrow(), + ["style b", "ratio 4:3"], + "the chosen tile stays quiet" + ); +} + +#[gpui::test] +fn a_seed_keeps_digits_and_the_die_rolls_one(cx: &mut TestAppContext) { + let (view, _, cx) = picks(cx); + tab(6, cx); + cx.simulate_input("12a3"); + view.read_with(cx, |view, cx| { + assert_eq!(SeedInput::read(&view.seed, cx), Some(123)) + }); + tab(1, cx); + press("enter", cx); + view.read_with(cx, |view, cx| { + let text = view.seed.read(cx).text().to_string(); + assert_ne!(text, "123", "the die rolls a new seed"); + assert_eq!( + SeedInput::read(&view.seed, cx).map(|seed| seed.to_string()), + Some(text) + ); + }); +} diff --git a/src/generative/variation.rs b/src/generative/variation.rs new file mode 100644 index 0000000..406468a --- /dev/null +++ b/src/generative/variation.rs @@ -0,0 +1,192 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, MouseButton, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + transparent_black, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + documents::source, + forms::{OnFlag, Pick, Run}, + motion, + primitives::{IconName, Image, checked_ratio, framed, tab_stop}, + theme::{ActiveTheme, AvatarSize, ControlSize, Radius}, +}; + +/// Variations of one picture: the chosen one large, every one as a thumbnail to choose, and ways on from the chosen: vary it a little or a lot, or upscale it. The large picture fades in on each choice. +#[derive(IntoElement)] +pub struct VariationPicker { + id: ElementId, + pictures: Vec, + ratio: f32, + chosen: usize, + on_choose: Option, + on_vary: Option, + on_upscale: Option, +} + +impl VariationPicker { + /// `ratio` is the pictures' width over height; `chosen` indexes `pictures`. + pub fn new( + id: impl Into, + pictures: impl IntoIterator>, + ratio: f32, + chosen: usize, + ) -> Self { + let pictures: Vec = pictures.into_iter().map(Into::into).collect(); + assert!( + chosen < pictures.len(), + "variation {chosen} of {}", + pictures.len() + ); + Self { + id: id.into(), + pictures, + ratio: checked_ratio(ratio), + chosen, + on_choose: None, + on_vary: None, + on_upscale: None, + } + } + + pub fn on_choose(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_choose = Some(Rc::new(handler)); + self + } + + /// Gets whether to vary a lot. + pub fn on_vary(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_vary = Some(Rc::new(handler)); + self + } + + pub fn on_upscale(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_upscale = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for VariationPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let turn = motion::changes((self.id.clone(), "turns"), self.chosen, window, cx); + let thumbs: Vec<_> = (0..self.pictures.len()) + .map(|ix| { + let focus = tab_stop( + (self.id.clone(), format!("focus-{ix}")).into(), + true, + window, + cx, + ); + let focused = focus.is_focused(window); + (focus, focused) + }) + .collect(); + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (large, small) = (theme.radius(Radius::Lg), theme.radius(Radius::Md)); + let thumb = theme.avatar_size(AvatarSize::Xl); + let length = motion::duration(motion::BASE, cx); + let picture = Image::new( + (self.id.clone(), format!("large-{}", self.chosen)), + source(&self.pictures[self.chosen]), + ) + .size_full() + .rounded(large); + let shown = match turn { + 0 => picture.into_any_element(), + _ => div() + .size_full() + .child(picture) + .with_animation( + (self.id.clone(), format!("fade-{turn}")), + Animation::new(length).with_easing(motion::ease_out_cubic), + |shown, t| shown.opacity(0.4 + 0.6 * t), + ) + .into_any_element(), + }; + let vary = |strong: bool, label: &'static str| { + self.on_vary.clone().map(|vary| { + Button::new((self.id.clone(), label), label) + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::Shuffle) + .on_click(move |_, window, cx| { + log::info!("variation picker: vary, strong {strong}"); + vary(strong, window, cx) + }) + }) + }; + div() + .w_full() + .flex() + .flex_col() + .gap_3() + .child(framed(self.ratio, cx).rounded(large).child(shown)) + .child(div().flex().flex_wrap().gap_2().children( + self.pictures.iter().zip(thumbs).enumerate().map( + |(ix, (picture, (focus, focused)))| { + let chosen = ix == self.chosen; + let choose = self.on_choose.clone(); + div() + .id((self.id.clone(), format!("thumb-{ix}"))) + .track_focus(&focus) + .w(thumb) + .p_0p5() + .rounded(large) + .border_1() + .border_color(if focused { + colors.focus + } else if chosen { + colors.accent + } else { + transparent_black() + }) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default() + }) + .on_click(move |_, window, cx| { + if chosen { + return; + } + log::info!("variation picker: chose {ix}"); + if let Some(choose) = &choose { + choose(ix, window, cx); + } + }) + .child( + framed(self.ratio, cx).rounded(small).child( + Image::new( + (self.id.clone(), format!("thumb-picture-{ix}")), + source(picture), + ) + .size_full() + .rounded(small), + ), + ) + }, + ), + )) + .child( + div() + .flex() + .flex_wrap() + .gap_2() + .children(vary(false, "Vary subtly")) + .children(vary(true, "Vary strongly")) + .children(self.on_upscale.clone().map(|upscale| { + Button::new((self.id.clone(), "upscale"), "Upscale") + .variant(ButtonVariant::Secondary) + .size(ControlSize::Sm) + .icon(IconName::Maximize2) + .on_click(move |_, window, cx| { + log::info!("variation picker: upscale"); + upscale(window, cx) + }) + })), + ) + } +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 349a62b..c239b6f 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -241,6 +241,7 @@ icons! { Shapes => "shapes", Share2 => "share-2", Shield => "shield", + Shuffle => "shuffle", SkipBack => "skip-back", SkipForward => "skip-forward", SlidersHorizontal => "sliders-horizontal", diff --git a/src/primitives/image.rs b/src/primitives/image.rs index db9d481..0ddc38a 100644 --- a/src/primitives/image.rs +++ b/src/primitives/image.rs @@ -13,6 +13,26 @@ use crate::{ const PULSE: Duration = Duration::from_millis(1600); +/// A host's width over height, fail-fast when it is not a positive number. +pub(crate) fn checked_ratio(ratio: f32) -> f32 { + assert!( + ratio.is_finite() && ratio > 0.0, + "a picture ratio of {ratio}" + ); + ratio +} + +/// A box in a picture's shape at the width it is given, sunken until the picture draws. +pub(crate) fn framed(ratio: f32, cx: &App) -> Div { + let mut frame = div() + .relative() + .w_full() + .overflow_hidden() + .bg(cx.theme().colors.sunken); + frame.style().aspect_ratio = Some(ratio); + frame +} + /// Themed `img()` that fills the box it is given; a `Cover` crop keeps square corners. #[derive(IntoElement)] pub struct Image { diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 1e09c95..a483038 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -18,6 +18,7 @@ pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, tak pub use ghost::DragGhost; pub use icon::{Icon, IconName}; pub use image::Image; +pub(crate) use image::{checked_ratio, framed}; pub use measure::{IntersectionObserver, Measure}; pub use pressable::Pressable; pub use severity::Severity; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index 129fad3..04b89cd 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -219,11 +219,11 @@ ## T25b Generative · Queue and results -- [ ] GenerationQueue -- [ ] GenerationGrid -- [ ] VariationPicker -- [ ] ImageCompare → `data_display::BeforeAfter` -- [ ] ABCompareView +- [x] GenerationQueue +- [x] GenerationGrid +- [x] VariationPicker +- [x] ImageCompare → `data_display::BeforeAfter` +- [x] ABCompareView ## T25c Generative · Canvas