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`