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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 12:13:17 -04:00
1 parent 173e89ed03
commit 66345b7e24
22 files changed
+1169 -10

No files matched your search

+6 -1
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-ban"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M4.929 4.929 19.07 19.071" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-dices"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="12" height="12" x="2" y="10" rx="2" ry="2" />
<path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6" />
<path d="M6 18h.01" />
<path d="M10 14h.01" />
<path d="M15 6h.01" />
<path d="M18 9h.01" />
</svg>

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+26
View File
@@ -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()
}
+183
View File
@@ -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<dyn Fn(&mut Window, &mut App)>;
/// 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<StylePreset> {
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<SharedString>,
left_out: Vec<SharedString>,
style: SharedString,
ratio: (u32, u32),
ran: Option<SharedString>,
}
fn edit(settings: &Entity<Settings>, 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)),
),
),
)
}
+2
View File
@@ -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,
];
+11
View File
@@ -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};
+238
View File
@@ -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<dyn Fn(&str, &mut Window, &mut App)>;
/// 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<Range<usize>> {
let diff = TextDiff::from_unicode_words(old, new);
let mut ranges: Vec<Range<usize>> = 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<TextInput>,
enhancing: bool,
suggestion: Option<SharedString>,
on_enhance: Option<OnText>,
on_resolve: Option<OnFlag>,
}
impl PromptEnhancer {
/// `field` is the owner's prompt.
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> 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<SharedString>) -> 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]);
}
}
+164
View File
@@ -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<dyn Fn((u32, u32), &mut Window, &mut App)>;
/// 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<OnRatio>,
}
impl AspectRatioPicker {
/// `selected` is one of `ratios`, each `(width, height)`.
pub fn new(
id: impl Into<ElementId>,
ratios: impl IntoIterator<Item = (u32, u32)>,
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);
}
}
+92
View File
@@ -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<usize>, 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::<u32>().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<TextInput>,
}
impl SeedInput {
pub fn new(id: impl Into<ElementId>, field: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
field: field.clone(),
}
}
/// The seed in `field`; none while it is empty.
pub fn read(field: &Entity<TextInput>, cx: &App) -> Option<u32> {
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));
}
}
+148
View File
@@ -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<SharedString>,
}
/// 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<StylePreset>,
selected: SharedString,
on_select: Option<OnValue>,
}
impl StylePresetPicker {
/// `selected` names one of `presets` by key.
pub fn new(
id: impl Into<ElementId>,
presets: impl IntoIterator<Item = StylePreset>,
selected: impl Into<SharedString>,
) -> Self {
let (presets, selected): (Vec<StylePreset>, 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)),
)
}))
}
}
+216
View File
@@ -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<TextInput>,
suggestion: Option<SharedString>,
heard: Vec<String>,
}
impl Render for Enhancing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (asked, resolved) = (cx.entity(), cx.entity());
let enhancer = PromptEnhancer::new("enhancer", &self.field)
.on_enhance(move |prompt, _, cx| {
asked.update(cx, |view, _| view.heard.push(format!("asked {prompt}")))
})
.on_resolve(move |taken, _, cx| {
resolved.update(cx, |view, _| {
view.heard.push(format!("taken {taken}"));
view.suggestion = None;
})
});
let enhancer = match &self.suggestion {
Some(text) => enhancer.suggestion(text.clone()),
None => enhancer,
};
div().w(px(420.0)).child(enhancer)
}
}
fn enhancing(cx: &mut TestAppContext) -> (Entity<Enhancing>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|window, cx| Enhancing {
field: cx.new(|cx| TextInput::new(window, cx)),
suggestion: None,
heard: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn enhance_asks_with_the_trimmed_prompt_and_use_this_takes_the_offer(cx: &mut TestAppContext) {
let (view, cx) = enhancing(cx);
tab(1, cx);
cx.simulate_input(" a cat ");
tab(1, cx);
press("enter", cx);
view.update(cx, |view, cx| {
view.suggestion = Some("a fluffy cat asleep".into());
cx.notify();
});
settle(cx);
tab(1, cx);
press("enter", cx);
view.read_with(cx, |view, cx| {
assert_eq!(view.heard, ["asked a cat", "taken true"]);
assert_eq!(view.field.read(cx).text(), "a fluffy cat asleep");
});
}
#[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<TextInput>,
style: SharedString,
heard: Rc<RefCell<Vec<String>>>,
}
impl Render for Picks {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let (styled, shaped) = (self.heard.clone(), self.heard.clone());
let preset = |key: &str| StylePreset {
key: key.to_string().into(),
name: key.to_string().into(),
picture: None,
};
div()
.w(px(420.0))
.child(
StylePresetPicker::new(
"styles",
[preset("a"), preset("b"), preset("c")],
self.style.clone(),
)
.on_select(move |key, _, _| styled.borrow_mut().push(format!("style {key}"))),
)
.child(
AspectRatioPicker::new("ratios", [(1, 1), (4, 3)], (1, 1)).on_select(
move |ratio, _, _| {
shaped
.borrow_mut()
.push(format!("ratio {}:{}", ratio.0, ratio.1))
},
),
)
.child(SeedInput::new("seed", &self.seed))
}
}
fn picks(
cx: &mut TestAppContext,
) -> (
Entity<Picks>,
Rc<RefCell<Vec<String>>>,
&mut VisualTestContext,
) {
setup(cx);
let heard = Rc::new(RefCell::new(Vec::new()));
let store = heard.clone();
let (view, cx) = cx.add_window_view(|window, cx| Picks {
seed: cx.new(|cx| TextInput::new(window, cx)),
style: "a".into(),
heard: store,
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, heard, cx)
}
#[gpui::test]
fn tabs_reach_each_tile_and_chip_and_enter_picks_it(cx: &mut TestAppContext) {
let (_, heard, cx) = picks(cx);
tab(1, cx);
press("enter", cx);
tab(1, cx);
press("enter", cx);
tab(3, cx);
press("enter", cx);
assert_eq!(
*heard.borrow(),
["style b", "ratio 4:3"],
"the chosen tile stays quiet"
);
}
#[gpui::test]
fn a_seed_keeps_digits_and_the_die_rolls_one(cx: &mut TestAppContext) {
let (view, _, cx) = picks(cx);
tab(6, cx);
cx.simulate_input("12a3");
view.read_with(cx, |view, cx| {
assert_eq!(SeedInput::read(&view.seed, cx), Some(123))
});
tab(1, cx);
press("enter", cx);
view.read_with(cx, |view, cx| {
let text = view.seed.read(cx).text().to_string();
assert_ne!(text, "123", "the die rolls a new seed");
assert_eq!(
SeedInput::read(&view.seed, cx).map(|seed| seed.to_string()),
Some(text)
);
});
}
+1
View File
@@ -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;
+2
View File
@@ -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",
+18
View File
@@ -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),
}
}
}
+2
View File
@@ -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,
+24 -9
View File
@@ -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`