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.
No files matched your search
@@ -83,7 +83,12 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T24d Agent · Previews
|
- [x] T24d Agent · Previews
|
||||||
- [x] T24e Agent · Environment
|
- [x] T24e Agent · Environment
|
||||||
- [x] T24f Agent · Control and cost
|
- [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
|
- [ ] T26 Media
|
||||||
- [ ] T27 Files
|
- [ ] T27 Files
|
||||||
- [ ] T28 Messaging
|
- [ ] T28 Messaging
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ mod editor;
|
|||||||
mod feedback;
|
mod feedback;
|
||||||
mod finance;
|
mod finance;
|
||||||
mod forms;
|
mod forms;
|
||||||
|
mod generative;
|
||||||
mod git;
|
mod git;
|
||||||
mod layout;
|
mod layout;
|
||||||
mod lists;
|
mod lists;
|
||||||
@@ -63,6 +64,7 @@ pub const ALL: &[Page] = &[
|
|||||||
collab::PAGE,
|
collab::PAGE,
|
||||||
chat::PAGE,
|
chat::PAGE,
|
||||||
agent::PAGE,
|
agent::PAGE,
|
||||||
|
generative::PAGE,
|
||||||
theme::PAGE,
|
theme::PAGE,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod editor;
|
|||||||
pub mod feedback;
|
pub mod feedback;
|
||||||
pub mod finance;
|
pub mod finance;
|
||||||
pub mod forms;
|
pub mod forms;
|
||||||
|
pub mod generative;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod lists;
|
pub mod lists;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ icons! {
|
|||||||
ArrowUpRight => "arrow-up-right",
|
ArrowUpRight => "arrow-up-right",
|
||||||
ArrowUp => "arrow-up",
|
ArrowUp => "arrow-up",
|
||||||
AtSign => "at-sign",
|
AtSign => "at-sign",
|
||||||
|
Ban => "ban",
|
||||||
Battery => "battery",
|
Battery => "battery",
|
||||||
Bell => "bell",
|
Bell => "bell",
|
||||||
Bold => "bold",
|
Bold => "bold",
|
||||||
@@ -115,6 +116,7 @@ icons! {
|
|||||||
Crop => "crop",
|
Crop => "crop",
|
||||||
Crosshair => "crosshair",
|
Crosshair => "crosshair",
|
||||||
Database => "database",
|
Database => "database",
|
||||||
|
Dices => "dices",
|
||||||
DollarSign => "dollar-sign",
|
DollarSign => "dollar-sign",
|
||||||
Dot => "dot",
|
Dot => "dot",
|
||||||
Download => "download",
|
Download => "download",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod chart;
|
mod chart;
|
||||||
|
mod generative;
|
||||||
mod palette;
|
mod palette;
|
||||||
mod tokens;
|
mod tokens;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
|
|||||||
|
|
||||||
use crate::motion;
|
use crate::motion;
|
||||||
pub use chart::ChartSizes;
|
pub use chart::ChartSizes;
|
||||||
|
pub use generative::GenerativeSizes;
|
||||||
pub use palette::{Mix, Palette, Syntax};
|
pub use palette::{Mix, Palette, Syntax};
|
||||||
pub use tokens::{
|
pub use tokens::{
|
||||||
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
|
AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize,
|
||||||
|
|||||||
@@ -208,33 +208,48 @@
|
|||||||
- [x] TraceViewer → `debug::TimelineProfiler` (a track per depth)
|
- [x] TraceViewer → `debug::TimelineProfiler` (a track per depth)
|
||||||
- [x] EvalResultTable → `tables::DataTable` (Tag and Progress cells)
|
- [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
|
- [ ] GenerationQueue
|
||||||
- [ ] GenerationGrid
|
- [ ] GenerationGrid
|
||||||
- [ ] VariationPicker
|
- [ ] VariationPicker
|
||||||
- [ ] PromptEnhancer
|
- [ ] ImageCompare → `data_display::BeforeAfter`
|
||||||
- [ ] NegativePromptInput
|
- [ ] ABCompareView
|
||||||
- [ ] StylePresetPicker
|
|
||||||
- [ ] SeedInput
|
## T25c Generative · Canvas
|
||||||
- [ ] AspectRatioPicker
|
|
||||||
- [ ] InpaintCanvas
|
- [ ] InpaintCanvas
|
||||||
- [ ] MaskBrush
|
- [ ] MaskBrush
|
||||||
- [ ] ImageCompare → `data_display::BeforeAfter`
|
|
||||||
|
## T25d Generative · Sound and motion
|
||||||
|
|
||||||
- [ ] AudioGenerationPlayer
|
- [ ] AudioGenerationPlayer
|
||||||
- [ ] VideoGenerationTimeline
|
- [ ] VideoGenerationTimeline
|
||||||
- [ ] TTSVoicePicker
|
- [ ] TTSVoicePicker
|
||||||
|
|
||||||
|
## T25e Generative · Models
|
||||||
|
|
||||||
- [ ] ModelCard
|
- [ ] ModelCard
|
||||||
- [ ] ModelDownloadManager
|
- [ ] ModelDownloadManager
|
||||||
- [ ] ModelStatus
|
- [ ] ModelStatus
|
||||||
- [ ] HardwareMonitor
|
- [ ] HardwareMonitor
|
||||||
- [ ] FineTuneJobCard
|
- [ ] FineTuneJobCard
|
||||||
|
|
||||||
|
## T25f Generative · Data and prompts
|
||||||
|
|
||||||
- [ ] DatasetViewer
|
- [ ] DatasetViewer
|
||||||
- [ ] EmbeddingVisualizer — 2D, 3D projection
|
- [ ] EmbeddingVisualizer — 2D, 3D projection
|
||||||
- [ ] PromptPlayground
|
- [ ] PromptPlayground
|
||||||
- [ ] PromptDiff / PromptVersionHistory
|
- [ ] PromptDiff / PromptVersionHistory
|
||||||
- [ ] ABCompareView
|
|
||||||
|
|
||||||
## T26 Media — `src/media`
|
## T26 Media — `src/media`
|
||||||
|
|
||||||
|
|||||||