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