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