diff --git a/TASKS.md b/TASKS.md
index 03e47ac..1534fc2 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -83,7 +83,12 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T24d Agent · Previews
- [x] T24e Agent · Environment
- [x] T24f Agent · Control and cost
-- [ ] T25 Generative
+- [ ] T25a Generative · Prompt and settings
+- [ ] T25b Generative · Queue and results
+- [ ] T25c Generative · Canvas
+- [ ] T25d Generative · Sound and motion
+- [ ] T25e Generative · Models
+- [ ] T25f Generative · Data and prompts
- [ ] T26 Media
- [ ] T27 Files
- [ ] T28 Messaging
diff --git a/assets/icons/ban.svg b/assets/icons/ban.svg
new file mode 100644
index 0000000..08cbfa9
--- /dev/null
+++ b/assets/icons/ban.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/assets/icons/dices.svg b/assets/icons/dices.svg
new file mode 100644
index 0000000..1da7d19
--- /dev/null
+++ b/assets/icons/dices.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/examples/gallery/assets/styles/cinematic.jpg b/examples/gallery/assets/styles/cinematic.jpg
new file mode 100644
index 0000000..4d2cdba
Binary files /dev/null and b/examples/gallery/assets/styles/cinematic.jpg differ
diff --git a/examples/gallery/assets/styles/line-art.jpg b/examples/gallery/assets/styles/line-art.jpg
new file mode 100644
index 0000000..d260e55
Binary files /dev/null and b/examples/gallery/assets/styles/line-art.jpg differ
diff --git a/examples/gallery/assets/styles/monochrome.jpg b/examples/gallery/assets/styles/monochrome.jpg
new file mode 100644
index 0000000..3853116
Binary files /dev/null and b/examples/gallery/assets/styles/monochrome.jpg differ
diff --git a/examples/gallery/assets/styles/photographic.jpg b/examples/gallery/assets/styles/photographic.jpg
new file mode 100644
index 0000000..473b7f8
Binary files /dev/null and b/examples/gallery/assets/styles/photographic.jpg differ
diff --git a/examples/gallery/assets/styles/watercolor.jpg b/examples/gallery/assets/styles/watercolor.jpg
new file mode 100644
index 0000000..50d38a3
Binary files /dev/null and b/examples/gallery/assets/styles/watercolor.jpg differ
diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs
new file mode 100644
index 0000000..eefedcc
--- /dev/null
+++ b/examples/gallery/pages/generative/mod.rs
@@ -0,0 +1,26 @@
+mod panel;
+
+use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
+
+use super::Page;
+use crate::script::Step;
+
+pub const PAGE: Page = Page {
+ number: 25,
+ slug: "generative",
+ title: "Generative",
+ summary: "Making pictures, sound and video with a model: the prompt and its settings, the queue and what comes back, the canvas, and the models behind them.",
+ render,
+ script: SCRIPT,
+};
+
+const SCRIPT: &[Step] = &[
+ Step::DownAt("gen-enhancer", 356.0, 124.0),
+ Step::UpAt("gen-enhancer", 356.0, 124.0),
+ Step::Wait(1400),
+ Step::Shot("suggested"),
+];
+
+fn render(window: &mut Window, cx: &mut App) -> AnyElement {
+ div().child(panel::panel(window, cx)).into_any_element()
+}
diff --git a/examples/gallery/pages/generative/panel.rs b/examples/gallery/pages/generative/panel.rs
new file mode 100644
index 0000000..fe1702e
--- /dev/null
+++ b/examples/gallery/pages/generative/panel.rs
@@ -0,0 +1,183 @@
+use std::{rc::Rc, time::Duration};
+
+use ely_gpui_component::{
+ buttons::{Button, ButtonVariant},
+ forms::{Form, FormField, TagInput, TextInput},
+ generative::{AspectRatioPicker, PromptEnhancer, SeedInput, StylePreset, StylePresetPicker},
+ typography::Caption,
+};
+use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
+
+use crate::{
+ probe::probe,
+ ui::{keep, section, set},
+};
+
+type Run = Rc;
+
+/// What an enhancer might give back; the demo adds light, material and lens.
+fn fuller(prompt: &str) -> String {
+ format!(
+ "{prompt}, pale limestone, soft morning light through tall windows, long shadows, 35mm, quiet and airy"
+ )
+}
+
+fn presets() -> Vec {
+ let preset = |key: &str, name: &str, picture: Option<&str>| StylePreset {
+ key: key.to_string().into(),
+ name: name.to_string().into(),
+ picture: picture.map(|path| path.to_string().into()),
+ };
+ vec![
+ preset("none", "None", None),
+ preset(
+ "photo",
+ "Photographic",
+ Some(asset!("styles/photographic.jpg")),
+ ),
+ preset("mono", "Monochrome", Some(asset!("styles/monochrome.jpg"))),
+ preset("line", "Line art", Some(asset!("styles/line-art.jpg"))),
+ preset("wash", "Watercolor", Some(asset!("styles/watercolor.jpg"))),
+ preset("film", "Cinematic", Some(asset!("styles/cinematic.jpg"))),
+ ]
+}
+
+/// The demo's settings: a suggestion on its way or offered, the left-out words, style, shape, and the last run.
+#[derive(Clone)]
+struct Settings {
+ enhancing: bool,
+ suggestion: Option,
+ left_out: Vec,
+ style: SharedString,
+ ratio: (u32, u32),
+ ran: Option,
+}
+
+fn edit(settings: &Entity, cx: &mut App, change: impl FnOnce(&mut Settings)) {
+ let mut next = settings.read(cx).clone();
+ change(&mut next);
+ set(settings, next, cx)
+}
+
+pub fn panel(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
+ let prompt = window.use_keyed_state("gen-prompt-field", cx, |window, cx| {
+ let mut input = TextInput::new(window, cx)
+ .multi_line(3, 6)
+ .placeholder("Describe a picture");
+ input.set_text("a spiral staircase in a white atrium", cx);
+ input
+ });
+ let seed = window.use_keyed_state("gen-seed-field", cx, |window, cx| {
+ let mut input = TextInput::new(window, cx);
+ input.set_text("2710", cx);
+ input
+ });
+ let settings = keep(
+ "gen-settings",
+ || Settings {
+ enhancing: false,
+ suggestion: None,
+ left_out: vec!["blurry".into(), "watermark".into(), "people".into()],
+ style: "photo".into(),
+ ratio: (3, 2),
+ ran: None,
+ },
+ window,
+ cx,
+ );
+ let now = settings.read(cx).clone();
+ let run: Run = {
+ let (settings, seed, prompt) = (settings.clone(), seed.clone(), prompt.clone());
+ Rc::new(move |_, cx| {
+ let seed = SeedInput::read(&seed, cx)
+ .map_or("a new seed".to_string(), |seed| format!("seed {seed}"));
+ let words = prompt.read(cx).text().split_whitespace().count();
+ let line = format!("Queued: {words} words, {seed}");
+ edit(&settings, cx, |settings| settings.ran = Some(line.into()));
+ })
+ };
+ let (asked, resolved, tagged, styled, shaped) = (
+ settings.clone(),
+ settings.clone(),
+ settings.clone(),
+ settings.clone(),
+ settings,
+ );
+ let enhancer = PromptEnhancer::new("gen-prompt", &prompt)
+ .enhancing(now.enhancing)
+ .on_enhance(move |text, _, cx| {
+ edit(&asked, cx, |settings| settings.enhancing = true);
+ let (asked, text) = (asked.clone(), fuller(text));
+ cx.spawn(async move |cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(900))
+ .await;
+ cx.update(|cx| {
+ edit(&asked, cx, |settings| {
+ settings.enhancing = false;
+ settings.suggestion = Some(text.into());
+ })
+ })
+ .ok();
+ })
+ .detach();
+ })
+ .on_resolve(move |_, _, cx| edit(&resolved, cx, |settings| settings.suggestion = None));
+ let enhancer = match now.suggestion {
+ Some(text) => enhancer.suggestion(text),
+ None => enhancer,
+ };
+ let submit = run.clone();
+ section(
+ "ImageGenerationPanel / PromptEnhancer / NegativePromptInput / StylePresetPicker / AspectRatioPicker / SeedInput",
+ "A picture's settings in one form: the prompt, which Enhance offers to fill out, the words it adds washed; what to leave out; a style; a shape; and a seed, empty for a new one each run. Cmd-Enter generates.",
+ cx,
+ )
+ .child(
+ div().w(px(400.)).child(
+ Form::new("gen-form")
+ .on_submit(move |window, cx| submit(window, cx))
+ .child(probe(
+ "gen-enhancer",
+ div().w(px(400.)).child(FormField::new("gen-prompt-label", "Prompt").child(enhancer)),
+ ))
+ .child(
+ FormField::new("gen-left-out", "Leave out").child(
+ TagInput::new("gen-left-out-tags", now.left_out)
+ .placeholder("A word, then Enter")
+ .on_change(move |tags, _, cx| edit(&tagged, cx, |settings| settings.left_out = tags)),
+ ),
+ )
+ .child(
+ FormField::new("gen-style", "Style").child(
+ StylePresetPicker::new("gen-styles", presets(), now.style)
+ .on_select(move |key, _, cx| {
+ let key = key.clone();
+ edit(&styled, cx, |settings| settings.style = key)
+ }),
+ ),
+ )
+ .child(
+ FormField::new("gen-shape", "Shape").child(
+ AspectRatioPicker::new("gen-ratios", [(1, 1), (4, 5), (3, 2), (16, 9), (9, 16)], now.ratio)
+ .on_select(move |ratio, _, cx| edit(&shaped, cx, |settings| settings.ratio = ratio)),
+ ),
+ )
+ .child(FormField::new("gen-seed", "Seed").child(SeedInput::new("gen-seed-input", &seed)))
+ .child(
+ div()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .child(
+ Button::new("gen-generate", "Generate")
+ .variant(ButtonVariant::Primary)
+ .full_width()
+ .shortcut("secondary-enter")
+ .on_click(move |_, window, cx| run(window, cx)),
+ )
+ .children(now.ran.map(Caption::new)),
+ ),
+ ),
+ )
+}
diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs
index 34ed265..1b9fa24 100644
--- a/examples/gallery/pages/mod.rs
+++ b/examples/gallery/pages/mod.rs
@@ -10,6 +10,7 @@ mod editor;
mod feedback;
mod finance;
mod forms;
+mod generative;
mod git;
mod layout;
mod lists;
@@ -63,6 +64,7 @@ pub const ALL: &[Page] = &[
collab::PAGE,
chat::PAGE,
agent::PAGE,
+ generative::PAGE,
theme::PAGE,
];
diff --git a/src/generative/mod.rs b/src/generative/mod.rs
new file mode 100644
index 0000000..38e6b36
--- /dev/null
+++ b/src/generative/mod.rs
@@ -0,0 +1,11 @@
+mod prompt;
+mod ratio;
+mod seed;
+mod style;
+#[cfg(all(test, feature = "test-support"))]
+mod tests;
+
+pub use prompt::PromptEnhancer;
+pub use ratio::AspectRatioPicker;
+pub use seed::SeedInput;
+pub use style::{StylePreset, StylePresetPicker};
diff --git a/src/generative/prompt.rs b/src/generative/prompt.rs
new file mode 100644
index 0000000..1f0ff73
--- /dev/null
+++ b/src/generative/prompt.rs
@@ -0,0 +1,238 @@
+use std::{ops::Range, rc::Rc};
+
+use gpui::{
+ App, ElementId, Entity, Focusable, FontWeight, HighlightStyle, IntoElement, ParentElement,
+ RenderOnce, SharedString, Styled, StyledText, Window, div,
+};
+use similar::{ChangeTag, TextDiff};
+
+use crate::{
+ buttons::{Button, ButtonVariant},
+ forms::{Input, OnFlag, TextInput},
+ motion::{Entrance, Transition},
+ primitives::{Icon, IconName},
+ theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
+};
+
+type OnText = Rc;
+
+/// Byte ranges of `new` that `old` lacks, word by word: neighbors joined, spaces and punctuation trimmed from each end.
+pub(crate) fn added_words(old: &str, new: &str) -> Vec> {
+ let diff = TextDiff::from_unicode_words(old, new);
+ let mut ranges: Vec> = Vec::new();
+ let mut at = 0;
+ for change in diff.iter_all_changes() {
+ let len = change.value().len();
+ match change.tag() {
+ ChangeTag::Delete => continue,
+ ChangeTag::Equal => {}
+ ChangeTag::Insert => match ranges.last_mut() {
+ Some(last) if last.end == at => last.end = at + len,
+ _ => ranges.push(at..at + len),
+ },
+ }
+ at += len;
+ }
+ ranges
+ .into_iter()
+ .filter_map(|range| {
+ let word = &new[range.clone()];
+ let glue = |ch: char| ch.is_whitespace() || ch.is_ascii_punctuation();
+ let start = range.start + word.len() - word.trim_start_matches(glue).len();
+ let end = range.end - (word.len() - word.trim_end_matches(glue).len());
+ (start < end).then_some(start..end)
+ })
+ .collect()
+}
+
+/// A prompt and a way to rewrite it: Enhance asks the owner for a fuller prompt, offered with the words it adds washed; Use this takes it, Keep mine lets it go.
+#[derive(IntoElement)]
+pub struct PromptEnhancer {
+ id: ElementId,
+ field: Entity,
+ enhancing: bool,
+ suggestion: Option,
+ on_enhance: Option,
+ on_resolve: Option,
+}
+
+impl PromptEnhancer {
+ /// `field` is the owner's prompt.
+ pub fn new(id: impl Into, field: &Entity) -> Self {
+ Self {
+ id: id.into(),
+ field: field.clone(),
+ enhancing: false,
+ suggestion: None,
+ on_enhance: None,
+ on_resolve: None,
+ }
+ }
+
+ /// While the owner writes a suggestion.
+ pub fn enhancing(mut self, enhancing: bool) -> Self {
+ self.enhancing = enhancing;
+ self
+ }
+
+ /// A fuller prompt to offer.
+ pub fn suggestion(mut self, text: impl Into) -> Self {
+ self.suggestion = Some(text.into());
+ self
+ }
+
+ /// Gets the prompt to rewrite, trimmed.
+ pub fn on_enhance(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
+ self.on_enhance = Some(Rc::new(handler));
+ self
+ }
+
+ /// Gets whether the suggestion was taken; a taken one is already in the field.
+ pub fn on_resolve(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
+ self.on_resolve = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for PromptEnhancer {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let last = window.use_keyed_state((self.id.clone(), "offer"), cx, |_, _| {
+ SharedString::default()
+ });
+ if let Some(text) = self
+ .suggestion
+ .as_ref()
+ .filter(|text| *last.read(cx) != **text)
+ {
+ last.update(cx, |last, _| *last = text.clone());
+ }
+ let (offered, shown) = (last.read(cx).clone(), self.suggestion.is_some());
+ let prompt = self.field.read(cx).text().trim().to_string();
+ let words = added_words(&prompt, &offered);
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let enhance = self.on_enhance.map(|enhance| {
+ Button::new((self.id.clone(), "enhance"), "Enhance")
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .icon(IconName::Sparkles)
+ .loading(self.enhancing)
+ .disabled(prompt.is_empty() || self.enhancing)
+ .on_click(move |_, window, cx| {
+ log::info!("prompt enhancer: asked with {} characters", prompt.len());
+ enhance(&prompt, window, cx)
+ })
+ });
+ let answer = |taken: bool| {
+ let (field, text, resolve) =
+ (self.field.clone(), offered.clone(), self.on_resolve.clone());
+ move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| {
+ if taken {
+ field.update(cx, |input, cx| input.set_text(text.to_string(), cx));
+ }
+ log::info!(
+ "prompt enhancer: suggestion {}",
+ if taken { "taken" } else { "kept out" }
+ );
+ window.focus(&field.focus_handle(cx));
+ if let Some(resolve) = &resolve {
+ resolve(taken, window, cx);
+ }
+ }
+ };
+ let wash = HighlightStyle {
+ background_color: Some(colors.success_subtle),
+ ..HighlightStyle::default()
+ };
+ let card = div()
+ .mt_1()
+ .flex()
+ .flex_col()
+ .gap_3()
+ .p_3()
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(colors.border)
+ .bg(colors.surface)
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap_1p5()
+ .text_size(theme.text_size(TextSize::Xs))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(colors.fg_muted)
+ .child(
+ Icon::new(IconName::Sparkles)
+ .size(IconSize::Xs)
+ .color(colors.fg_muted),
+ )
+ .child("Suggested"),
+ )
+ .child(
+ div()
+ .text_size(theme.text_size(TextSize::Sm))
+ .text_color(colors.fg)
+ .child(
+ StyledText::new(offered.clone())
+ .with_highlights(words.into_iter().map(|range| (range, wash))),
+ ),
+ )
+ .child(
+ div()
+ .flex()
+ .flex_wrap()
+ .gap_2()
+ .child(
+ Button::new((self.id.clone(), "take"), "Use this")
+ .variant(ButtonVariant::Primary)
+ .size(ControlSize::Sm)
+ .disabled(!shown)
+ .on_click(answer(true)),
+ )
+ .child(
+ Button::new((self.id.clone(), "keep"), "Keep mine")
+ .variant(ButtonVariant::Ghost)
+ .size(ControlSize::Sm)
+ .disabled(!shown)
+ .on_click(answer(false)),
+ ),
+ );
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .child(Input::new(&self.field))
+ .children(enhance.map(|button| div().flex().justify_end().child(button)))
+ .child(
+ Transition::new((self.id.clone(), "offered"), shown)
+ .entrance(Entrance::Rise)
+ .child(card),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::added_words;
+
+ #[test]
+ fn added_words_are_the_new_runs_without_their_spaces() {
+ let new = "a fluffy cat asleep on a sunlit mat";
+ let ranges = added_words("a cat on a mat", new);
+ let words: Vec<&str> = ranges.iter().map(|range| &new[range.clone()]).collect();
+ assert_eq!(words, ["fluffy", "asleep", "sunlit"]);
+ let new = "a white atrium, pale limestone";
+ let ranges = added_words("a white atrium", new);
+ let words: Vec<&str> = ranges.iter().map(|range| &new[range.clone()]).collect();
+ assert_eq!(
+ words,
+ ["pale limestone"],
+ "a comma added after a kept word stays plain"
+ );
+ assert!(added_words("same words", "same words").is_empty());
+ let whole = 2..7;
+ assert_eq!(added_words("", " a cat "), [whole]);
+ }
+}
diff --git a/src/generative/ratio.rs b/src/generative/ratio.rs
new file mode 100644
index 0000000..73935e0
--- /dev/null
+++ b/src/generative/ratio.rs
@@ -0,0 +1,164 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement,
+ RenderOnce, StatefulInteractiveElement, Styled, Window, div, prelude::*,
+};
+
+use crate::{
+ primitives::tab_stop,
+ theme::{ActiveTheme, IconSize, Radius, TextSize},
+ typography::tabular,
+};
+
+type OnRatio = Rc;
+
+/// The share of a square a `width:height` shape spans across and down; its long side spans it all.
+pub(crate) fn span(width: u32, height: u32) -> (f32, f32) {
+ assert!(width > 0 && height > 0, "a ratio of {width}:{height}");
+ let (width, height) = (width as f32, height as f32);
+ let long = width.max(height);
+ (width / long, height / long)
+}
+
+/// A picture's shape: a chip per ratio, each with an outline drawn to its proportions. The chosen chip takes accent; each is a Tab stop.
+#[derive(IntoElement)]
+pub struct AspectRatioPicker {
+ id: ElementId,
+ ratios: Vec<(u32, u32)>,
+ selected: (u32, u32),
+ on_select: Option,
+}
+
+impl AspectRatioPicker {
+ /// `selected` is one of `ratios`, each `(width, height)`.
+ pub fn new(
+ id: impl Into,
+ ratios: impl IntoIterator- ,
+ selected: (u32, u32),
+ ) -> Self {
+ let ratios: Vec<(u32, u32)> = ratios.into_iter().collect();
+ assert!(
+ ratios.contains(&selected),
+ "ratio {}:{} is not among the ratios",
+ selected.0,
+ selected.1
+ );
+ Self {
+ id: id.into(),
+ ratios,
+ selected,
+ on_select: None,
+ }
+ }
+
+ pub fn on_select(
+ mut self,
+ handler: impl Fn((u32, u32), &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_select = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for AspectRatioPicker {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let chips: Vec<_> = self
+ .ratios
+ .iter()
+ .map(|&(width, height)| {
+ let id: ElementId = (self.id.clone(), format!("ratio-{width}-{height}")).into();
+ let focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
+ (id, focus.is_focused(window), focus, (width, height))
+ })
+ .collect();
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let glyph = theme.icon_size(IconSize::Md);
+ div()
+ .w_full()
+ .flex()
+ .flex_wrap()
+ .gap_2()
+ .children(chips.into_iter().map(|(id, focused, focus, ratio)| {
+ let chosen = ratio == self.selected;
+ let ink = if chosen {
+ colors.accent
+ } else {
+ colors.fg_muted
+ };
+ let border = if focused {
+ colors.focus
+ } else if chosen {
+ colors.accent
+ } else {
+ colors.border
+ };
+ let (across, down) = span(ratio.0, ratio.1);
+ let pick = self.on_select.clone();
+ div()
+ .id(id)
+ .track_focus(&focus)
+ .flex()
+ .items_center()
+ .gap_1p5()
+ .px_2p5()
+ .py_1()
+ .rounded(theme.radius(Radius::Md))
+ .border_1()
+ .border_color(border)
+ .text_size(theme.text_size(TextSize::Sm))
+ .font_weight(FontWeight::MEDIUM)
+ .text_color(ink)
+ .cursor_pointer()
+ .when(!chosen && !focused, |chip| {
+ chip.hover(|style| style.border_color(colors.border_strong))
+ })
+ .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
+ .on_click(move |_, window, cx| {
+ if chosen {
+ return;
+ }
+ log::info!("aspect ratio: {}:{}", ratio.0, ratio.1);
+ if let Some(pick) = &pick {
+ pick(ratio, window, cx);
+ }
+ })
+ .child(
+ div()
+ .flex_none()
+ .size(glyph)
+ .flex()
+ .items_center()
+ .justify_center()
+ .child(
+ div()
+ .w(glyph * across)
+ .h(glyph * down)
+ .rounded(theme.radius(Radius::Sm))
+ .border_1()
+ .border_color(ink),
+ ),
+ )
+ .child(tabular(div()).child(format!("{}:{}", ratio.0, ratio.1)))
+ }))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::span;
+
+ #[test]
+ fn a_ratio_spans_its_long_side_whole() {
+ assert_eq!(span(16, 9), (1.0, 0.5625));
+ assert_eq!(span(9, 16), (0.5625, 1.0));
+ assert_eq!(span(1, 1), (1.0, 1.0));
+ }
+
+ #[test]
+ #[should_panic(expected = "a ratio of 0:1")]
+ fn an_empty_side_fails_loud() {
+ span(0, 1);
+ }
+}
diff --git a/src/generative/seed.rs b/src/generative/seed.rs
new file mode 100644
index 0000000..3386ee0
--- /dev/null
+++ b/src/generative/seed.rs
@@ -0,0 +1,92 @@
+use std::{
+ hash::{BuildHasher, Hasher, RandomState},
+ ops::Range,
+ rc::Rc,
+};
+
+use gpui::{App, ElementId, Entity, IntoElement, RenderOnce, Window};
+
+use crate::{
+ buttons::IconButton,
+ forms::{InputAddon, InputGroup, TextInput},
+ primitives::IconName,
+};
+
+/// Fits an edit into a seed: digits only, and nothing past `u32::MAX`; a refused edit keeps the text.
+pub(crate) fn digits(text: &str, range: Range, typed: &str) -> (String, usize) {
+ let kept: String = typed.chars().filter(char::is_ascii_digit).collect();
+ let next = format!("{}{kept}{}", &text[..range.start], &text[range.end..]);
+ if next.is_empty() || next.parse::().is_ok() {
+ (next, range.start + kept.len())
+ } else {
+ (text.to_string(), range.start)
+ }
+}
+
+/// A seed from the system's source of randomness.
+fn roll() -> u32 {
+ RandomState::new().build_hasher().finish() as u32
+}
+
+/// A generation's seed in the owner's field: digits up to `u32::MAX`, empty for a new one each run, and a die that rolls one.
+#[derive(IntoElement)]
+pub struct SeedInput {
+ id: ElementId,
+ field: Entity,
+}
+
+impl SeedInput {
+ pub fn new(id: impl Into, field: &Entity) -> Self {
+ Self {
+ id: id.into(),
+ field: field.clone(),
+ }
+ }
+
+ /// The seed in `field`; none while it is empty.
+ pub fn read(field: &Entity, cx: &App) -> Option {
+ let text = field.read(cx).text();
+ (!text.is_empty()).then(|| text.parse().expect("a seed field holds digits"))
+ }
+}
+
+impl RenderOnce for SeedInput {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let fitted = window.use_keyed_state((self.id.clone(), "fitted"), cx, |_, _| false);
+ if !*fitted.read(cx) {
+ fitted.update(cx, |fitted, _| *fitted = true);
+ self.field.update(cx, |input, cx| {
+ input.set_fit(Rc::new(digits));
+ let text = input.text().to_string();
+ input.set_text(text, cx);
+ input.set_placeholder("Random", cx);
+ });
+ }
+ let field = self.field.clone();
+ InputGroup::new(&self.field).after(InputAddon::new(
+ IconButton::new((self.id.clone(), "roll"), IconName::Dices)
+ .tooltip("Roll a seed")
+ .on_click(move |_, _, cx| {
+ let seed = roll();
+ log::info!("seed input: rolled {seed}");
+ field.update(cx, |input, cx| input.set_text(seed.to_string(), cx));
+ }),
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::digits;
+
+ #[test]
+ fn a_seed_keeps_digits_up_to_the_largest() {
+ assert_eq!(digits("12", 2..2, "3a4"), ("1234".to_string(), 4));
+ assert_eq!(digits("", 0..0, "12,345"), ("12345".to_string(), 5));
+ assert_eq!(
+ digits("4294967295", 10..10, "1"),
+ ("4294967295".to_string(), 10)
+ );
+ assert_eq!(digits("1234", 1..3, ""), ("14".to_string(), 1));
+ }
+}
diff --git a/src/generative/style.rs b/src/generative/style.rs
new file mode 100644
index 0000000..646e7bc
--- /dev/null
+++ b/src/generative/style.rs
@@ -0,0 +1,148 @@
+use std::rc::Rc;
+
+use gpui::{
+ App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
+ SharedString, StatefulInteractiveElement, Styled, Window, div, transparent_black,
+};
+
+use crate::{
+ documents::source,
+ forms::OnValue,
+ primitives::{Icon, IconName, Image, tab_stop},
+ theme::{ActiveTheme, IconSize, Radius, TextSize},
+ typography::Ellipsis,
+};
+
+/// A style to generate in: its key, its name, and a picture of it; one with no picture draws a plain tile.
+#[derive(Clone, Debug, PartialEq)]
+pub struct StylePreset {
+ pub key: SharedString,
+ pub name: SharedString,
+ pub picture: Option,
+}
+
+/// Styles to generate in, a tile each with its picture and name. The chosen tile takes accent; each is a Tab stop.
+#[derive(IntoElement)]
+pub struct StylePresetPicker {
+ id: ElementId,
+ presets: Vec,
+ selected: SharedString,
+ on_select: Option,
+}
+
+impl StylePresetPicker {
+ /// `selected` names one of `presets` by key.
+ pub fn new(
+ id: impl Into,
+ presets: impl IntoIterator
- ,
+ selected: impl Into,
+ ) -> Self {
+ let (presets, selected): (Vec, SharedString) =
+ (presets.into_iter().collect(), selected.into());
+ assert!(
+ presets.iter().any(|preset| preset.key == selected),
+ "style {selected} is not among the presets"
+ );
+ Self {
+ id: id.into(),
+ presets,
+ selected,
+ on_select: None,
+ }
+ }
+
+ pub fn on_select(
+ mut self,
+ handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
+ ) -> Self {
+ self.on_select = Some(Rc::new(handler));
+ self
+ }
+}
+
+impl RenderOnce for StylePresetPicker {
+ fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let tiles: Vec<_> = self
+ .presets
+ .into_iter()
+ .map(|preset| {
+ let id: ElementId = (self.id.clone(), format!("preset-{}", preset.key)).into();
+ let focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
+ (id, focus.is_focused(window), focus, preset)
+ })
+ .collect();
+ let theme = cx.theme();
+ let colors = theme.colors.clone();
+ let side = theme.generative().preset;
+ div()
+ .w_full()
+ .flex()
+ .flex_wrap()
+ .gap_3()
+ .children(tiles.into_iter().map(|(id, focused, focus, preset)| {
+ let chosen = preset.key == self.selected;
+ let ring = if focused {
+ colors.focus
+ } else if chosen {
+ colors.accent
+ } else {
+ transparent_black()
+ };
+ let (key, pick) = (preset.key.clone(), self.on_select.clone());
+ let inner = theme.radius(Radius::Md);
+ let picture = match &preset.picture {
+ Some(path) => Image::new((id.clone(), "picture"), source(path))
+ .size_full()
+ .rounded(inner)
+ .into_any_element(),
+ None => div()
+ .size_full()
+ .rounded(inner)
+ .flex()
+ .items_center()
+ .justify_center()
+ .bg(colors.sunken)
+ .child(
+ Icon::new(IconName::Ban)
+ .size(IconSize::Md)
+ .color(colors.fg_subtle),
+ )
+ .into_any_element(),
+ };
+ div()
+ .id(id)
+ .track_focus(&focus)
+ .w(side)
+ .flex()
+ .flex_col()
+ .gap_1p5()
+ .cursor_pointer()
+ .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
+ .on_click(move |_, window, cx| {
+ if chosen {
+ return;
+ }
+ log::info!("style preset: {key}");
+ if let Some(pick) = &pick {
+ pick(&key, window, cx);
+ }
+ })
+ .child(
+ div()
+ .size(side)
+ .p_0p5()
+ .rounded(theme.radius(Radius::Lg))
+ .border_1()
+ .border_color(ring)
+ .child(picture),
+ )
+ .child(
+ div()
+ .px_0p5()
+ .text_size(theme.text_size(TextSize::Xs))
+ .text_color(if chosen { colors.fg } else { colors.fg_muted })
+ .child(Ellipsis::new(preset.name)),
+ )
+ }))
+ }
+}
diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs
new file mode 100644
index 0000000..eb5cfea
--- /dev/null
+++ b/src/generative/tests/mod.rs
@@ -0,0 +1,216 @@
+use std::{cell::RefCell, rc::Rc};
+
+use gpui::{
+ AppContext as _, Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
+ ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div,
+ px,
+};
+
+use crate::{
+ forms::{self, TextInput},
+ generative::{AspectRatioPicker, PromptEnhancer, SeedInput, StylePreset, StylePresetPicker},
+ primitives::FocusNext,
+ theme::Theme,
+};
+
+fn settle(cx: &mut VisualTestContext) {
+ cx.run_until_parked();
+ cx.update(|window, _| window.refresh());
+ cx.run_until_parked();
+}
+
+/// Presses and releases `key`, a frame apart.
+fn press(key: &str, cx: &mut VisualTestContext) {
+ cx.simulate_keystrokes(key);
+ settle(cx);
+ cx.simulate_event(KeyUpEvent {
+ keystroke: Keystroke::parse(key).expect("a key"),
+ });
+ settle(cx);
+}
+
+fn tab(times: usize, cx: &mut VisualTestContext) {
+ for _ in 0..times {
+ cx.update(|window, _| window.focus_next());
+ }
+}
+
+fn setup(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ Theme::init(cx);
+ forms::bind_keys(cx);
+ 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");
+ });
+}
+
+#[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");
+ });
+}
+
+/// 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/lib.rs b/src/lib.rs
index f09d118..e199f04 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -12,6 +12,7 @@ pub mod editor;
pub mod feedback;
pub mod finance;
pub mod forms;
+pub mod generative;
pub mod git;
pub mod layout;
pub mod lists;
diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs
index 1deb609..349a62b 100644
--- a/src/primitives/icon.rs
+++ b/src/primitives/icon.rs
@@ -56,6 +56,7 @@ icons! {
ArrowUpRight => "arrow-up-right",
ArrowUp => "arrow-up",
AtSign => "at-sign",
+ Ban => "ban",
Battery => "battery",
Bell => "bell",
Bold => "bold",
@@ -115,6 +116,7 @@ icons! {
Crop => "crop",
Crosshair => "crosshair",
Database => "database",
+ Dices => "dices",
DollarSign => "dollar-sign",
Dot => "dot",
Download => "download",
diff --git a/src/theme/generative.rs b/src/theme/generative.rs
new file mode 100644
index 0000000..648098d
--- /dev/null
+++ b/src/theme/generative.rs
@@ -0,0 +1,18 @@
+use gpui::Rems;
+
+use super::{Theme, tokens::px_to_rems};
+
+/// Generative tools' measures.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct GenerativeSizes {
+ /// A style preset's picture, square.
+ pub preset: Rems,
+}
+
+impl Theme {
+ pub fn generative(&self) -> GenerativeSizes {
+ GenerativeSizes {
+ preset: px_to_rems(88.0),
+ }
+ }
+}
diff --git a/src/theme/mod.rs b/src/theme/mod.rs
index 4262726..c6b9aeb 100644
--- a/src/theme/mod.rs
+++ b/src/theme/mod.rs
@@ -1,4 +1,5 @@
mod chart;
+mod generative;
mod palette;
mod tokens;
@@ -8,6 +9,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
use crate::motion;
pub use chart::ChartSizes;
+pub use generative::GenerativeSizes;
pub use palette::{Mix, Palette, Syntax};
pub use tokens::{
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md
index 6f3aaf7..129fad3 100644
--- a/tasks/ch21-30.md
+++ b/tasks/ch21-30.md
@@ -208,33 +208,48 @@
- [x] TraceViewer → `debug::TimelineProfiler` (a track per depth)
- [x] EvalResultTable → `tables::DataTable` (Tag and Progress cells)
-## T25 Generative — `src/generative`
+## T25a Generative · Prompt and settings — `src/generative`
+
+- [x] ImageGenerationPanel → `forms::Form` of the fields below; Cmd-Enter generates
+- [x] PromptEnhancer
+- [x] NegativePromptInput → `forms::TagInput`
+- [x] StylePresetPicker
+- [x] SeedInput
+- [x] AspectRatioPicker
+
+## T25b Generative · Queue and results
-- [ ] ImageGenerationPanel
- [ ] GenerationQueue
- [ ] GenerationGrid
- [ ] VariationPicker
-- [ ] PromptEnhancer
-- [ ] NegativePromptInput
-- [ ] StylePresetPicker
-- [ ] SeedInput
-- [ ] AspectRatioPicker
+- [ ] ImageCompare → `data_display::BeforeAfter`
+- [ ] ABCompareView
+
+## T25c Generative · Canvas
+
- [ ] InpaintCanvas
- [ ] MaskBrush
-- [ ] ImageCompare → `data_display::BeforeAfter`
+
+## T25d Generative · Sound and motion
+
- [ ] AudioGenerationPlayer
- [ ] VideoGenerationTimeline
- [ ] TTSVoicePicker
+
+## T25e Generative · Models
+
- [ ] ModelCard
- [ ] ModelDownloadManager
- [ ] ModelStatus
- [ ] HardwareMonitor
- [ ] FineTuneJobCard
+
+## T25f Generative · Data and prompts
+
- [ ] DatasetViewer
- [ ] EmbeddingVisualizer — 2D, 3D projection
- [ ] PromptPlayground
- [ ] PromptDiff / PromptVersionHistory
-- [ ] ABCompareView
## T26 Media — `src/media`