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:
1 parent
173e89ed03
commit
66345b7e24
22 files changed
+1169
-10
No files matched your search
@@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user