T25b Generative: queue and results

GenerationQueue shows each job's picture or where it stands, its
prompt and its progress, with Cancel, Retry and Remove as its state
allows; new rows fold in. GenerationGrid lays a run's results out in
their shape: breathing while they come, the picture once done, why one
failed with Retry. VariationPicker shows the chosen variation large
over thumbnails and ways on from it. ABCompareView sets two to four
models' answers side by side, blind until a verdict. ImageCompare
points to data_display::BeforeAfter.

primitives::framed and checked_ratio now serve previews and generative
alike. AGENTS.md records that gpui reads a &str picture as a web
address, and that grid rows overgrow tiles that keep a ratio.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 12:35:23 -04:00
1 parent f0431411d5
commit 81d8d52a09
17 files changed
+1515 -215

No files matched your search

+200
View File
@@ -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<dyn Fn(Verdict, &mut Window, &mut App)>;
/// 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<Verdict>,
on_verdict: Option<OnVerdict>,
}
impl ABCompareView {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>, 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");
}
}
+212
View File
@@ -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<f32>),
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<Outcome>,
ratio: f32,
columns: u16,
on_open: Option<Pick>,
on_retry: Option<Pick>,
}
impl GenerationGrid {
/// `ratio` is the run's width over height.
pub fn new(
id: impl Into<ElementId>,
results: impl IntoIterator<Item = Outcome>,
ratio: f32,
) -> Self {
let results: Vec<Outcome> = 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)
}
}
+8
View File
@@ -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;
+268
View File
@@ -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<Duration>),
/// 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<SharedString>,
}
/// 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<Job>,
on_cancel: Option<OnValue>,
on_retry: Option<OnValue>,
on_remove: Option<OnValue>,
}
impl GenerationQueue {
pub fn new(id: impl Into<ElementId>, jobs: impl IntoIterator<Item = Job>) -> Self {
let jobs: Vec<Job> = 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<OnValue>| {
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"
);
}
}
+4 -190
View File
@@ -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<TextInput>,
suggestion: Option<SharedString>,
heard: Vec<String>,
}
impl Render for Enhancing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Enhancing>, &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<Enhancing>, 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<TextInput>,
style: SharedString,
heard: Rc<RefCell<Vec<String>>>,
}
impl Render for Picks {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Picks>,
Rc<RefCell<Vec<String>>>,
&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)
);
});
}
+176
View File
@@ -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<RefCell<Vec<String>>>;
/// 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Verdict>,
heard: Heard,
}
impl Render for Compared {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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"
);
}
+190
View File
@@ -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<TextInput>,
suggestion: Option<SharedString>,
heard: Vec<String>,
}
impl Render for Enhancing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Enhancing>, &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<Enhancing>, 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<TextInput>,
style: SharedString,
heard: Rc<RefCell<Vec<String>>>,
}
impl Render for Picks {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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<Picks>,
Rc<RefCell<Vec<String>>>,
&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)
);
});
}
+192
View File
@@ -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<SharedString>,
ratio: f32,
chosen: usize,
on_choose: Option<Pick>,
on_vary: Option<OnFlag>,
on_upscale: Option<Run>,
}
impl VariationPicker {
/// `ratio` is the pictures' width over height; `chosen` indexes `pictures`.
pub fn new(
id: impl Into<ElementId>,
pictures: impl IntoIterator<Item = impl Into<SharedString>>,
ratio: f32,
chosen: usize,
) -> Self {
let pictures: Vec<SharedString> = 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)
})
})),
)
}
}