From 66345b7e24141c7e1ec42f6353dcfcd70050863f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 12:13:17 -0400 Subject: [PATCH] T25a Generative: prompt and settings PromptEnhancer offers a fuller prompt with the words it adds washed, split at punctuation; Use this takes it into the field, Keep mine lets it go. StylePresetPicker and AspectRatioPicker are rows of Tab stops, the chosen one in accent; a ratio chip draws its shape. SeedInput keeps digits up to u32::MAX and rolls one with a die. NegativePromptInput and ImageGenerationPanel point to forms::TagInput and forms::Form. theme.generative() holds the chapter's sizes. The gallery's preset pictures are the atrium photo in five styles. --- TASKS.md | 7 +- assets/icons/ban.svg | 16 ++ assets/icons/dices.svg | 20 ++ examples/gallery/assets/styles/cinematic.jpg | Bin 0 -> 6381 bytes examples/gallery/assets/styles/line-art.jpg | Bin 0 -> 12806 bytes examples/gallery/assets/styles/monochrome.jpg | Bin 0 -> 6558 bytes .../gallery/assets/styles/photographic.jpg | Bin 0 -> 6323 bytes examples/gallery/assets/styles/watercolor.jpg | Bin 0 -> 5419 bytes examples/gallery/pages/generative/mod.rs | 26 ++ examples/gallery/pages/generative/panel.rs | 183 ++++++++++++++ examples/gallery/pages/mod.rs | 2 + src/generative/mod.rs | 11 + src/generative/prompt.rs | 238 ++++++++++++++++++ src/generative/ratio.rs | 164 ++++++++++++ src/generative/seed.rs | 92 +++++++ src/generative/style.rs | 148 +++++++++++ src/generative/tests/mod.rs | 216 ++++++++++++++++ src/lib.rs | 1 + src/primitives/icon.rs | 2 + src/theme/generative.rs | 18 ++ src/theme/mod.rs | 2 + tasks/ch21-30.md | 33 ++- 22 files changed, 1169 insertions(+), 10 deletions(-) create mode 100644 assets/icons/ban.svg create mode 100644 assets/icons/dices.svg create mode 100644 examples/gallery/assets/styles/cinematic.jpg create mode 100644 examples/gallery/assets/styles/line-art.jpg create mode 100644 examples/gallery/assets/styles/monochrome.jpg create mode 100644 examples/gallery/assets/styles/photographic.jpg create mode 100644 examples/gallery/assets/styles/watercolor.jpg create mode 100644 examples/gallery/pages/generative/mod.rs create mode 100644 examples/gallery/pages/generative/panel.rs create mode 100644 src/generative/mod.rs create mode 100644 src/generative/prompt.rs create mode 100644 src/generative/ratio.rs create mode 100644 src/generative/seed.rs create mode 100644 src/generative/style.rs create mode 100644 src/generative/tests/mod.rs create mode 100644 src/theme/generative.rs 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 0000000000000000000000000000000000000000..4d2cdbaaea1f546f164b5a7e775a98e0209c7018 GIT binary patch literal 6381 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 literal 0 HcmV?d00001 diff --git a/examples/gallery/assets/styles/line-art.jpg b/examples/gallery/assets/styles/line-art.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d260e5595ea554f00b45abc92131f0e102072bae GIT binary patch literal 12806 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 G`@aFVj_i;C literal 0 HcmV?d00001 diff --git a/examples/gallery/assets/styles/monochrome.jpg b/examples/gallery/assets/styles/monochrome.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3853116245b9d18b0003bb659707345888a6b0d1 GIT binary patch literal 6558 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 b45_g$YmkjOI7%S6_nE7f`Cb3vTuuBh8Yzpt literal 0 HcmV?d00001 diff --git a/examples/gallery/assets/styles/photographic.jpg b/examples/gallery/assets/styles/photographic.jpg new file mode 100644 index 0000000000000000000000000000000000000000..473b7f8a8653ddac5b24a428461432a91dce2a85 GIT binary patch literal 6323 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%0AO5Zsf?5c?s%z=xW&!kbg)!3THx% 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 literal 0 HcmV?d00001 diff --git a/examples/gallery/assets/styles/watercolor.jpg b/examples/gallery/assets/styles/watercolor.jpg new file mode 100644 index 0000000000000000000000000000000000000000..50d38a31e6125b8cdd447e645cac41352da85425 GIT binary patch literal 5419 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 }TTaApKt&!1Nk2Eb zhApGZu)wS8HItS_uMLm>)Ol_wDd{RPHq-10x%1c&&R=T#k$q6dk~J8F4R)r1>7;_isVmL%LevCf9K0{-I0!xwp(1D)Vr#}^kMf3riv&B^bt z=&{wMUDrFUKMJ~E?sBUnYKgivo+gEELq~|v1jO&I2;g3 zi!dt>DPPx93CB9t#$JtWkN=e|;x0@}vJ0mSI-kW-8F_yjnRHkw-P9F?CmVXbYD(0xUx@sOoQOM3vAba(0|z8n|-`QbkbYl%xPT1 zjCC~MTA??Eo*g8cH={mGr!w*fo*YSO$;bk692R*uV0qi4)wp2;IQfR`}7;h`14z#R6IAR)9 z=KnT8Q7gX;hq_AUl?$oK!aDT!z&{2a;k8JC&qmR(@4VGIOer$JBiu(@cY%20PSg4p zx;2#`nl04dOvc;XVwup#Qa_N+LaeP9#o)FF* zMPjPus3{^v(*-2wYl&>(L4@JpK_7-CPeO(1is6TBQX=wh9D3e>!V7QVdgwwf8w7Z@ zgaVqg*>aN1bKz`|B-NJcvMh-grXGBNU*tQ|omYjJ=ytOyMFU?|2jB~tf>n+Pj9bTx z9r4I^?Ri9(Pp(Ua>zuy6Ap3WX*^^s&$4A($QCFFticfX+jVXIoQdyraX?KE#Ri1fI zUC?^&y1aGp1JI=lyH7QHK9oAocIIuIQWfG%mjCRl3|+8VhLSo3e|EQxbS_2aqKqa0 z%apiTb=J<9r#nk_)(!Z|Cr)M=Tv~kQaEo8bqw{~x=Dh#TM#jq~$ z0*#a0l0q3|*-4h-9iN7Trw98y%GR(%vqTu5b`j=4$(~((jHusB*5ybZNG@%$O`-8< zAji^IC$U$cIPHjAxP_J#9$dY-vNEoGpU&k~8fWVXePa9}u|4L7^`^@!avih1^zlNn zte688G*8A!EaqpDJ-JF^q9lyjv1S+|Qn_wPF*(zX9Q(_1G>LwWi==>!$ww^A|Q3$4Ee% zD0MVp258UiIOX};Ja{rsv5YYNj0Qpoqk? zV|^lMoT(gsy*?z7bK7aeJ3<1Ag-nJ5P5+wF6oy2=jPFn2-4{Q`9*M zX+Kqbf9eTdEp17mUH2~Z9HFk?s$cX**HQ?6t0t@@xO!MwMy32)bQEFG1Mrwk>$?O|bnE;T1rXvj341@z#Nb9m$p@a3=9HtjA73%<;2fDfM4QspTP zxgD8;Ft;xbsZiiyChu1^+^FMa5ZuCBAMoNTA+{?gx7=`*u8cZOV zqJBoH6Bg$?nG_n_*2aosz5eY;nOK^dAP zl%&undMK75nCrXl{o)i&gw{JrtW2L;@l(*WU?uRA(qD%bAlBsk?vOzH4ZKX~S)9gH zlVp3EG9OIl`B)C^p}&)udxt_dNp5+Y#u8oP?g(!I^JCU%8OrGj8Fy3gX5MA-1Cmg_ zP*<~O78NtjlhVc&3Jwe4AC(M+4!2D!HST8SIjcXzIW8%c1t3zxuzjU3O2QC4Tg9AS zoV6)gA$r>r0VrcZD_XZk`>vTC&{Y4(itI4kxI+vd?D{+DBq_1}AYk+P*R|7;!^&`6 zd5EmKIomPzX}4+u96p`o-Y2ZCZ}mnfp@m;BqF(pNxWRvI@ZK9K5_jb4tj!CMQa3{p z(R*tVdc_~%N3oRcy($G$yz-$7+4CvVZ}I!sf}eyZAA?BFSgVX{P?hbeMQ|ESpJRBB zN*ScqTCon^E}sZ}X&xT24Q6rQw7Y(iR7Ks#)ypSz*F0^0>)5XwX&n9*49w@qtUPFp>nfnd?04>bRx$@QHsF z^FTB@an+ru&Zs#)M!fkOFg6>nhO~T8KPEDLZe<`^=_VQS1s}x@dt{_s 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`