T25f Generative: data and prompts

DatasetViewer is tables::DataTable with row detail; the gallery pages
through a dataset and opens a sample whole. EmbeddingVisualizer scatters
points by group, flat or turned in three dimensions: lens.rs holds the
projection, where a turned view keeps the unit ball's scale and fits any
point past it at every turn. A drag or the arrow keys spin it, the
pointer names a point, and the legend hides a group. PromptPlayground
gives each {name} of a template a field, marks the blanks, and runs once
every value is in. PromptDiff shows word changes inline, a space parting
a word taken out from the word put in; PromptVersionHistory lists
versions to choose from and restore.

ChartLegend entries that toggle are Tab stops with the focus ring. Each
capture pass starts its page fresh, so dark runs the script from the same
start as light.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 14:57:55 -04:00
1 parent 2f859eeb03
commit e3038647c8
16 files changed
+1800 -17

No files matched your search

+1
View File
@@ -50,6 +50,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags.
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band.
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
- Posted events cannot move a macOS window, so window drags are not scripted. `drag_region` follows Zed's title bar.
- An overlay takes focus with `primitives::take_focus` and returns it with `give_back` on every close path. While open, it takes focus back whenever the focused element leaves the tree (gpui's `on_focus_lost`).
+1
View File
@@ -44,6 +44,7 @@ async fn shoot_all(
for (choice, name) in [(Choice::Light, "light"), (Choice::Dark, "dark")] {
window.update(cx, |gallery, window, cx| {
gallery.select(ix, cx);
gallery.fresh(cx);
gallery.choose(choice, window, cx);
})?;
script::park(window, cx)?;
+312
View File
@@ -0,0 +1,312 @@
use std::time::Duration;
use ely_gpui_component::{
data_display::{DescriptionList, Tone},
forms::TextInput,
generative::{
Embedded, EmbeddingVisualizer, PromptPlayground, PromptVersion, PromptVersionHistory,
},
tables::{Cell, Column, DataTable, Row},
};
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems};
use jiff::{SignedDuration, Timestamp};
use crate::{
probe::probe,
ui::{keep, section, set},
};
const SAMPLES: [(&str, &str, bool, f64); 12] = [
(
"Summarize the meeting notes in two lines.",
"The launch moves to May. Design owns the new onboarding flow.",
true,
38.0,
),
(
"Turn this list into a friendly reminder.",
"A quick note: the lab closes at six on Friday. Please take your samples home.",
true,
41.0,
),
(
"Translate 'good morning' into Spanish.",
"Buenos días.",
true,
12.0,
),
(
"Write a subject line for a refund email.",
"Your refund is on its way",
false,
17.0,
),
(
"Explain a mutex to a new engineer.",
"A mutex lets one thread touch shared data at a time. The others wait their turn.",
true,
36.0,
),
("Suggest a name for a hiking app.", "Trailhead", true, 11.0),
(
"Rewrite without jargon: 'We will leverage synergies.'",
"We will work together.",
false,
24.0,
),
(
"List three checks before a deploy.",
"Tests pass. Migrations run. Someone is on call.",
true,
29.0,
),
("Answer in one word: is 91 prime?", "No.", false, 13.0),
(
"Draft a two-line product update.",
"Search is twice as fast. Filters now remember your last choice.",
true,
33.0,
),
(
"Give a calm reply to an angry customer.",
"I'm sorry for the trouble. I've refunded the order and will check in tomorrow.",
true,
40.0,
),
("Name the capital of Canada.", "Ottawa.", false, 10.0),
];
pub fn dataset(cx: &mut App) -> impl IntoElement + use<> {
let rows: Vec<Row> = SAMPLES
.iter()
.enumerate()
.map(|(ix, (prompt, answer, train, tokens))| {
let split = if *train {
("Train", Tone::Neutral)
} else {
("Held out", Tone::Info)
};
Row::new(
SharedString::from(format!("sample-{ix}")),
[
Cell::Text((*prompt).into()),
Cell::Text((*answer).into()),
Cell::Tag(split.0.into(), split.1),
Cell::Number(*tokens),
],
)
})
.collect();
section(
"DatasetViewer → tables::DataTable",
"Samples of a dataset: a prompt, its answer, its split and its length. Sort by any column, page through, and open a sample to read it whole.",
cx,
)
.child(probe(
"gen-dataset",
div().w(px(680.)).child(
DataTable::new(
"gen-dataset",
[
Column::new("prompt", "Prompt"),
Column::new("answer", "Answer"),
Column::new("split", "Split").width(rems(6.)),
Column::new("tokens", "Tokens").width(rems(5.)).end().decimals(0),
],
)
.rows(rows)
.paged(5)
.detail(|key, _, _| {
let ix: usize = key.trim_start_matches("sample-").parse().expect("a sample's key");
let (prompt, answer, _, _) = SAMPLES[ix];
DescriptionList::new().stacked().item("Prompt", prompt).item("Answer", answer).into_any_element()
}),
),
))
}
/// A number from 0 to 1 that looks random and stays the same for its seed.
fn noise(seed: u32) -> f32 {
let mut x = (seed + 1).wrapping_mul(0x9E37_79B9);
x ^= x >> 16;
x = x.wrapping_mul(0x7FEB_352D);
x ^= x >> 15;
x = x.wrapping_mul(0x846C_A68B);
x ^= x >> 16;
x as f32 / u32::MAX as f32
}
/// Notes in four topics, gathered near each topic's middle.
fn notes() -> Vec<Embedded> {
let middles = [
[-0.5, 0.4, 0.2],
[0.45, 0.35, -0.3],
[0.1, -0.5, 0.4],
[-0.3, -0.2, -0.5],
];
(0..64)
.map(|ix| {
let group = ix % 4;
let seed = (ix * 6) as u32;
let jitter =
|axis: u32| (noise(seed + axis * 2) + noise(seed + axis * 2 + 1) - 1.0) * 0.3;
Embedded {
key: format!("note-{ix}").into(),
label: format!("Note {}", ix + 1).into(),
group,
at: [0, 1, 2]
.map(|axis| (middles[group][axis as usize] + jitter(axis)).clamp(-1.0, 1.0)),
}
})
.collect()
}
pub fn embeddings(cx: &mut App) -> impl IntoElement + use<> {
let topics = ["Recipes", "Travel", "Finance", "Code"];
section(
"EmbeddingVisualizer",
"Notes placed by what they mean, colored by topic, flat or turned in three dimensions. Drag the turned one, or use the arrow keys. The pointer names a note; the legend hides a topic.",
cx,
)
.child(probe(
"gen-embed",
div()
.flex()
.flex_wrap()
.gap_6()
.child(div().w(px(340.)).child(EmbeddingVisualizer::new("gen-embed-flat", notes(), topics)))
.child(div().w(px(340.)).child(EmbeddingVisualizer::new("gen-embed-turned", notes(), topics).three_dimensions(true))),
))
}
/// What the playground shows: running, and the last answer with its tokens and time.
#[derive(Clone, Default)]
struct Play {
running: bool,
answer: Option<(SharedString, usize, Duration)>,
}
pub fn playground(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let template = window.use_keyed_state("gen-play-template", cx, |window, cx| {
let mut input = TextInput::new(window, cx).multi_line(2, 5);
input.set_text("Write three short notes on {topic} in a {tone} voice.", cx);
input
});
let play = keep("gen-play", Play::default, window, cx);
let now = play.read(cx).clone();
let playground = PromptPlayground::new("gen-play", &template)
.running(now.running)
.on_run(move |_, _, cx| {
set(
&play,
Play {
running: true,
answer: None,
},
cx,
);
let play = play.clone();
cx.spawn(async move |cx| {
cx.background_executor()
.timer(Duration::from_millis(900))
.await;
cx.update(|cx| {
let answer =
"Start with what you saw. Keep each line short. End on one plain fact.";
set(
&play,
Play {
running: false,
answer: Some((answer.into(), 18, Duration::from_millis(1240))),
},
cx,
)
})
.ok();
})
.detach();
});
let playground = match now.answer {
Some((text, tokens, took)) => playground.output(text, tokens, took),
None => playground,
};
section(
"PromptPlayground",
"A prompt to try. Each {name} in the template gets a field, and the prompt below marks what is still blank. Run waits for every value, then shows the answer with its tokens and time.",
cx,
)
.child(probe("gen-play", div().w(px(520.)).child(playground)))
}
fn history() -> Vec<PromptVersion> {
let now = Timestamp::now();
let version = |key: &str, text: &str, note: &str, hours: i64| PromptVersion {
key: key.to_string().into(),
text: text.to_string().into(),
author: if hours > 30 { "Mira" } else { "Theo" }.into(),
at: now - SignedDuration::from_hours(hours),
note: note.to_string().into(),
};
vec![
version(
"v4",
"You answer support questions. Write plain, short replies. Say sorry once. Offer the next step.",
"Offer the next step",
3,
),
version(
"v3",
"You answer support questions. Write plain, short replies. Say sorry once.",
"Sorry once, not every line",
26,
),
version(
"v2",
"You answer support questions. Write plain replies. Always say sorry.",
"Plain words",
50,
),
version(
"v1",
"You are a helpful assistant. Always say sorry.",
"First draft",
98,
),
]
}
pub fn versions(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let kept = keep("gen-versions", || (history(), 1usize), window, cx);
let (list, chosen) = kept.read(cx).clone();
let (choose, restore) = (kept.clone(), kept.clone());
section(
"PromptDiff / PromptVersionHistory",
"A prompt's versions, newest first, and what the chosen one changed: words taken out struck through, words put in washed green. Restore brings an older one back as the newest.",
cx,
)
.child(probe(
"gen-versions",
div().w(px(640.)).child(
PromptVersionHistory::new("gen-versions", list, chosen)
.on_choose(move |ix, _, cx| {
let list = choose.read(cx).0.clone();
set(&choose, (list, ix), cx)
})
.on_restore(move |ix, _, cx| {
let mut list = restore.read(cx).0.clone();
let back = list[ix].clone();
let place = list.len() - ix;
list.insert(
0,
PromptVersion {
key: format!("v{}", list.len() + 1).into(),
at: Timestamp::now(),
note: format!("Restored version {place}").into(),
..back
},
);
set(&restore, (list, 0), cx)
}),
),
))
}
+36 -1
View File
@@ -1,4 +1,5 @@
mod canvas;
mod data;
mod models;
mod panel;
mod results;
@@ -13,7 +14,7 @@ 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.",
summary: "Making pictures, sound and video with a model: the prompt and its settings, the queue and what comes back, the canvas, the models behind them, and the data and prompts they work from.",
render,
script: SCRIPT,
};
@@ -36,6 +37,36 @@ const SCRIPT: &[Step] = &[
Step::UpAt("gen-canvas", 390.0, 220.0),
Step::Wait(300),
Step::Shot("masked"),
Step::DownAt("gen-dataset", 14.0, 54.0),
Step::UpAt("gen-dataset", 14.0, 54.0),
Step::Wait(300),
Step::Shot("sample"),
Step::HoverAt("gen-embed", 49.6, 133.1),
Step::Wait(200),
Step::Shot("named"),
Step::DownAt("gen-embed", 534.0, 160.0),
Step::DragTo("gen-embed", 604.0, 140.0),
Step::UpAt("gen-embed", 604.0, 140.0),
Step::Wait(200),
Step::Shot("turned"),
Step::DownAt("gen-play", 313.0, 143.0),
Step::UpAt("gen-play", 313.0, 143.0),
Step::Type("dunes"),
Step::DownAt("gen-play", 313.0, 179.0),
Step::UpAt("gen-play", 313.0, 179.0),
Step::Type("calm"),
Step::DownAt("gen-play", 29.0, 302.0),
Step::UpAt("gen-play", 29.0, 302.0),
Step::Wait(1200),
Step::Shot("played"),
Step::DownAt("gen-versions", 46.0, 136.0),
Step::UpAt("gen-versions", 46.0, 136.0),
Step::Wait(200),
Step::Shot("chosen"),
Step::DownAt("gen-versions", 591.0, 28.0),
Step::UpAt("gen-versions", 591.0, 28.0),
Step::Wait(200),
Step::Shot("restored"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
@@ -52,5 +83,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
.child(models::downloads_section(window, cx))
.child(models::machine(window, cx))
.child(models::tuning(window, cx))
.child(data::dataset(cx))
.child(data::embeddings(cx))
.child(data::playground(window, cx))
.child(data::versions(window, cx))
.into_any_element()
}
+11 -2
View File
@@ -21,6 +21,8 @@ pub enum Choice {
pub struct Gallery {
page: usize,
/// Counts fresh starts; the page's state lives under it.
pass: usize,
choice: Choice,
focus: FocusHandle,
scroll: ScrollHandle,
@@ -38,6 +40,7 @@ impl Gallery {
window.focus(&focus);
Self {
page,
pass: 0,
choice: Choice::Light,
focus,
scroll: ScrollHandle::new(),
@@ -52,6 +55,12 @@ impl Gallery {
cx.notify();
}
/// Drops the page's state, so it draws as new.
pub fn fresh(&mut self, cx: &mut Context<Self>) {
self.pass += 1;
cx.notify();
}
/// Viewport height and furthest scroll, in pixels.
pub fn scroll_extent(&self) -> (Pixels, Pixels) {
(
@@ -192,7 +201,7 @@ impl Gallery {
impl Render for Gallery {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let page = &pages::ALL[self.page];
let body = (page.render)(window, cx);
let body = window.with_id(("pass", self.pass), |window| (page.render)(window, cx));
let sidebar = self.sidebar(cx);
let switcher = self.switcher(cx);
let theme = cx.theme();
@@ -208,7 +217,7 @@ impl Render for Gallery {
.text_size(theme.text_size(TextSize::Base))
.child(sidebar)
.child(
on_axis(div().id("page"))
on_axis(div().id(("page", self.pass)))
.flex_1()
.h_full()
.overflow_y_scroll()
+13 -3
View File
@@ -1,18 +1,19 @@
use std::rc::Rc;
use gpui::{
App, ElementId, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, prelude::*,
App, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, transparent_black,
};
use crate::{
primitives::FocusRing,
theme::{ActiveTheme, Elevation, Radius, TextSize},
typography::tabular,
};
type OnToggle = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A chart's key: a dot in each series' color and its name; with `on_toggle`, a press hides or shows one.
/// A chart's key: a dot in each series' color and its name; with `on_toggle`, a press or Enter hides or shows one.
#[derive(IntoElement)]
pub struct ChartLegend {
id: ElementId,
@@ -68,7 +69,16 @@ impl RenderOnce for ChartLegend {
.when(hidden, |entry| entry.opacity(0.4))
.when_some(on_toggle, |entry, on_toggle| {
entry
.px_1()
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(transparent_black())
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| on_toggle(&key, window, cx))
})
.child(div().size(theme.status_dot()).rounded_full().bg(color))
+45 -6
View File
@@ -1,15 +1,18 @@
use std::{cell::Cell, rc::Rc};
use std::{
cell::{Cell, RefCell},
rc::Rc,
};
use gpui::{
Context, IntoElement, Modifiers, MouseMoveEvent, ParentElement, Render, Styled, TestAppContext,
VisualTestContext, Window, div, point, px,
Context, IntoElement, KeyUpEvent, Keystroke, Modifiers, MouseMoveEvent, ParentElement, Render,
SharedString, Styled, TestAppContext, VisualTestContext, Window, black, div, point, px,
};
use jiff::civil::date;
use super::{
BarChart, Bullet, BulletChart, CalendarHeatmap, ChordDiagram, FunnelChart, GanttChart,
HeatmapChart, LineChart, NetworkGraph, ParallelCoordinates, ProgressChart, RadarChart,
SankeyChart, Series, Task, Treemap,
BarChart, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram, FunnelChart,
GanttChart, HeatmapChart, LineChart, NetworkGraph, ParallelCoordinates, ProgressChart,
RadarChart, SankeyChart, Series, Task, Treemap,
};
use crate::theme::Theme;
@@ -169,3 +172,39 @@ fn a_part_under_the_pointer_lets_go_when_its_data_shrinks(cx: &mut TestAppContex
}
}
}
/// A legend of two series that hears which one is toggled.
struct Keyed(Rc<RefCell<Vec<SharedString>>>);
impl Render for Keyed {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.0.clone();
div().child(
ChartLegend::new("legend")
.entry(black(), "Training", false)
.entry(black(), "Held out", true)
.on_toggle(move |name, _, _| heard.borrow_mut().push(name.clone())),
)
}
}
#[gpui::test]
fn tab_reaches_a_legend_entry_and_enter_toggles_it(cx: &mut TestAppContext) {
cx.update(Theme::init);
let heard = Rc::new(RefCell::new(Vec::new()));
let seen = heard.clone();
let (_, cx) = cx.add_window_view(|_, _| Keyed(seen));
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| {
window.focus_next();
window.focus_next();
});
settle(cx);
cx.simulate_keystrokes("enter");
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").expect("a key"),
});
settle(cx);
assert_eq!(*heard.borrow(), ["Held out"]);
}
+319
View File
@@ -0,0 +1,319 @@
use std::{collections::HashSet, rc::Rc};
use gpui::{
App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
Point, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, fill,
point, prelude::*, size, transparent_black,
};
use super::lens::{Lens, START, STEP, radius, turned};
use crate::{
charts::{ChartLegend, ChartTooltip, anchored},
primitives::{FocusRing, checked_ratio, tab_stop},
theme::{ActiveTheme, Radius},
};
/// A point of an embedding: its key, its label, its group, and where it sits, each axis from -1 to 1.
#[derive(Clone, Debug, PartialEq)]
pub struct Embedded {
pub key: SharedString,
pub label: SharedString,
pub group: usize,
pub at: [f32; 3],
}
/// What a drag turns the view for each pixel.
const DRAG: f32 = 0.01;
/// A box's width and height in pixels.
fn extent(bounds: Bounds<Pixels>) -> (f32, f32) {
(f32::from(bounds.size.width), f32::from(bounds.size.height))
}
/// The view's own state: its turn, where a drag was last, the point under the pointer, the groups the legend hid, and the plot's bounds.
struct View {
turn: (f32, f32),
from: Option<Point<Pixels>>,
hovered: Option<usize>,
hidden: HashSet<usize>,
bounds: Bounds<Pixels>,
}
/// Points of an embedding scattered by where they sit, colored by group, flat or turned in three dimensions. Turned, a drag or the arrow keys spin it, nearer points drawn larger and stronger. The pointer names the point under it; the legend hides and shows groups.
#[derive(IntoElement)]
pub struct EmbeddingVisualizer {
id: ElementId,
points: Rc<Vec<Embedded>>,
radius: f32,
groups: Vec<SharedString>,
three: bool,
ratio: f32,
}
impl EmbeddingVisualizer {
/// `groups` names each group a point's `group` indexes.
pub fn new(
id: impl Into<ElementId>,
points: impl IntoIterator<Item = Embedded>,
groups: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let points: Vec<Embedded> = points.into_iter().collect();
let groups: Vec<SharedString> = groups.into_iter().map(Into::into).collect();
for point in &points {
assert!(
point.group < groups.len(),
"point {} in group {} of {}",
point.key,
point.group,
groups.len()
);
assert!(
point.at.iter().all(|axis| (-1.0..=1.0).contains(axis)),
"point {} sits outside -1 to 1",
point.key
);
}
Self {
id: id.into(),
radius: radius(&points),
points: Rc::new(points),
groups,
three: false,
ratio: 4.0 / 3.0,
}
}
/// Turns the view in three dimensions.
pub fn three_dimensions(mut self, three: bool) -> Self {
self.three = three;
self
}
/// The view's width over height.
pub fn ratio(mut self, ratio: f32) -> Self {
self.ratio = checked_ratio(ratio);
self
}
}
impl RenderOnce for EmbeddingVisualizer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let view = window.use_keyed_state((self.id.clone(), "view"), cx, |_, _| View {
turn: START,
from: None,
hovered: None,
hidden: HashSet::new(),
bounds: Bounds::default(),
});
let focus = tab_stop((self.id.clone(), "focus").into(), self.three, window, cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let rem = window.rem_size();
let (dot, reach) = (
theme.status_dot().to_pixels(rem),
f32::from(theme.chart().inset.to_pixels(rem)),
);
let (three, points, radius) = (self.three, self.points.clone(), self.radius);
let (turn, hovered, hidden, bounds) = {
let view = view.read(cx);
(
three.then_some(view.turn),
view.hovered,
view.hidden.clone(),
view.bounds,
)
};
let lens = Lens {
turn,
size: extent(bounds),
radius,
};
let tint = move |group: usize| colors.chart[group % colors.chart.len()];
let tooltip = hovered.filter(|ix| *ix < points.len()).map(|ix| {
let point = &points[ix];
let (x, y, _) = lens.place(point.at);
let card = ChartTooltip::new(point.label.clone()).row(
Some(tint(point.group)),
self.groups[point.group].clone(),
"",
);
anchored((x, y), x > lens.size.0 * 0.6, card).debug_selector(|| "embedding-tip".into())
});
let legend = self.groups.iter().enumerate().fold(
ChartLegend::new((self.id.clone(), "legend")),
|legend, (ix, name)| legend.entry(tint(ix), name.clone(), hidden.contains(&ix)),
);
let (toggled, groups) = (view.clone(), self.groups.clone());
let legend = legend.on_toggle(move |name, _, cx| {
let ix = groups
.iter()
.position(|group| group == name)
.expect("a listed group");
toggled.update(cx, |view, cx| {
if !view.hidden.remove(&ix) {
view.hidden.insert(ix);
}
view.hovered = None;
log::info!("embedding: {name} hidden {}", view.hidden.contains(&ix));
cx.notify();
})
});
let (measure, moved, pressed, lifted, keys, left) = (
view.clone(),
view.clone(),
view.clone(),
view.clone(),
view.clone(),
view.clone(),
);
let (painted, hover_points) = (points.clone(), points.clone());
let mut area = div().id(self.id.clone());
area.style().aspect_ratio = Some(self.ratio);
let area = area
.debug_selector(|| "embedding-area".into())
.relative()
.w_full()
.rounded(theme.radius(Radius::Lg))
.bg(theme.colors.sunken)
.border_1()
.border_color(transparent_black())
.when(three, |area| {
area.track_focus(&focus).focus_ring(cx).cursor_grab()
})
.child(
canvas(
move |bounds, window, cx| {
if measure.read(cx).bounds != bounds {
measure.update(cx, |view, cx| {
view.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
move |bounds, _, window, _| {
let lens = Lens {
size: extent(bounds),
..lens
};
let mut placed: Vec<_> = painted
.iter()
.enumerate()
.filter(|(_, point)| !hidden.contains(&point.group))
.map(|(ix, point)| (ix, point.group, lens.place(point.at)))
.collect();
placed.sort_by(|a, b| a.2.2.total_cmp(&b.2.2));
for (ix, group, (x, y, near)) in placed {
let depth = if turn.is_some() {
0.6 + 0.8 * near
} else {
1.0
};
let side = dot * depth * if Some(ix) == hovered { 1.8 } else { 1.0 };
let center = point(
bounds.left() + Pixels::from(x),
bounds.top() + Pixels::from(y),
);
let alpha = if turn.is_some() {
0.35 + 0.65 * near
} else {
0.85
};
window.paint_quad(
fill(
Bounds::centered_at(center, size(side, side)),
tint(group).alpha(alpha),
)
.corner_radii(side / 2.0),
);
}
},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(tooltip)
.on_mouse_move(move |event, _, cx| {
moved.update(cx, |view, cx| {
if let (true, Some(from), Some(MouseButton::Left)) =
(three, view.from, event.pressed_button)
{
let delta = event.position - from;
view.turn = turned(
view.turn,
(f32::from(delta.x) * DRAG, f32::from(delta.y) * DRAG),
);
(view.from, view.hovered) = (Some(event.position), None);
cx.notify();
return;
}
let bounds = view.bounds;
let at = (
f32::from(event.position.x - bounds.left()),
f32::from(event.position.y - bounds.top()),
);
let lens = Lens {
turn: three.then_some(view.turn),
size: extent(bounds),
radius,
};
let under = lens.nearest(&hover_points, &view.hidden, at, reach);
if under != view.hovered {
view.hovered = under;
cx.notify();
}
})
})
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
pressed.update(cx, |view, _| view.from = Some(event.position))
})
.on_mouse_up(MouseButton::Left, move |_, _, cx| {
lifted.update(cx, |view, _| {
if view.from.take().is_some() && three {
log::info!(
"embedding: turned to {:.2}, {:.2}",
view.turn.0,
view.turn.1
);
}
})
})
.on_hover(move |inside, _, cx| {
if !inside {
left.update(cx, |view, cx| {
(view.from, view.hovered) = (None, None);
cx.notify();
})
}
})
.on_key_down(move |event, _, cx| {
let by = match event.keystroke.key.as_str() {
"left" => (-STEP, 0.0),
"right" => (STEP, 0.0),
"up" => (0.0, -STEP),
"down" => (0.0, STEP),
_ => return,
};
cx.stop_propagation();
keys.update(cx, |view, cx| {
view.turn = turned(view.turn, by);
view.hovered = None;
log::info!(
"embedding: turned to {:.2}, {:.2}",
view.turn.0,
view.turn.1
);
cx.notify();
})
});
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(legend)
.child(area)
}
}
+191
View File
@@ -0,0 +1,191 @@
use std::{
collections::HashSet,
f32::consts::{PI, TAU},
};
use super::embed::Embedded;
/// How far the eye sits from the middle, in the points' own units.
const EYE: f32 = 4.0;
/// Where a turned view starts.
pub(crate) const START: (f32, f32) = (0.6, 0.35);
/// What a key turns the view.
pub(crate) const STEP: f32 = PI / 12.0;
/// Farthest the view tips up or down, short of looking straight along the upright axis.
const TIP: f32 = 1.4;
/// How the view shows its points: flat or turned, in a box this many pixels across and down, fitting points out to `radius` from the middle.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Lens {
pub turn: Option<(f32, f32)>,
pub size: (f32, f32),
pub radius: f32,
}
impl Lens {
/// Where a point lands, in pixels from the box's top left, and how near it sits, 0 far to 1 near. Flat, x runs across and y up, and z is dropped. Turned by yaw about the upright axis, then by pitch, the view scales by the box's shorter side, nearer points spread wider, and a point out to `radius` stays inside at any turn.
pub(crate) fn place(&self, [x, y, z]: [f32; 3]) -> (f32, f32, f32) {
let (w, h) = self.size;
let Some((yaw, pitch)) = self.turn else {
return (w * (0.5 + x * 0.45), h * (0.5 - y * 0.45), 0.5);
};
let (x1, z1) = (x * yaw.cos() + z * yaw.sin(), z * yaw.cos() - x * yaw.sin());
let (y2, z2) = (
y * pitch.cos() - z1 * pitch.sin(),
y * pitch.sin() + z1 * pitch.cos(),
);
let widest = self.radius * EYE / (EYE * EYE - self.radius * self.radius).sqrt();
let scale = w.min(h) * 0.45 / widest * EYE / (EYE - z2);
(
w / 2.0 + x1 * scale,
h / 2.0 - y2 * scale,
(z2 / self.radius + 1.0) / 2.0,
)
}
/// The point nearest `at` within `reach`, both in pixels; hidden groups are skipped.
pub(crate) fn nearest(
&self,
points: &[Embedded],
hidden: &HashSet<usize>,
at: (f32, f32),
reach: f32,
) -> Option<usize> {
points
.iter()
.enumerate()
.filter(|(_, point)| !hidden.contains(&point.group))
.map(|(ix, point)| {
let (x, y, _) = self.place(point.at);
(ix, (x - at.0).hypot(y - at.1))
})
.filter(|(_, far)| *far <= reach)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(ix, _)| ix)
}
}
/// How far the farthest point sits from the middle, and never less than 1: a cloud inside the unit ball keeps the ball's scale.
pub(crate) fn radius(points: &[Embedded]) -> f32 {
points
.iter()
.map(|point| point.at.iter().map(|axis| axis * axis).sum::<f32>().sqrt())
.fold(1.0, f32::max)
}
/// A turn moved by `yaw` and `pitch`; the pitch stops short of either pole.
pub(crate) fn turned((yaw, pitch): (f32, f32), by: (f32, f32)) -> (f32, f32) {
(
(yaw + by.0).rem_euclid(TAU),
(pitch + by.1).clamp(-TIP, TIP),
)
}
#[cfg(test)]
mod tests {
use std::{
collections::HashSet,
f32::consts::{FRAC_PI_2, TAU},
};
use super::{Lens, radius, turned};
use crate::generative::Embedded;
const BOX: (f32, f32) = (400.0, 300.0);
fn lens(turn: Option<(f32, f32)>, radius: f32) -> Lens {
Lens {
turn,
size: BOX,
radius,
}
}
#[test]
fn flat_points_fill_the_box_and_a_quarter_turn_swings_one_back() {
let (x, y, near) = lens(None, 1.0).place([1.0, 1.0, 0.5]);
assert!(
(x - 380.0).abs() < 1e-3 && (y - 15.0).abs() < 1e-3 && near == 0.5,
"{x}, {y}, {near}"
);
let (x, y, near) = lens(Some((0.0, 0.0)), 1.0).place([1.0, 0.0, 0.0]);
let ball = 300.0 * 0.45 * (15.0f32).sqrt() / 4.0;
assert!(
(x - 200.0 - ball).abs() < 1e-3
&& (y - 150.0).abs() < 1e-3
&& (near - 0.5).abs() < 1e-6,
"the unit ball keeps its scale: {x}, {y}, {near}"
);
let (x, _, near) = lens(Some((FRAC_PI_2, 0.0)), 1.0).place([1.0, 0.0, 0.0]);
assert!(
(x - 200.0).abs() < 1e-3 && near < 0.5,
"right swings to the back: {x}, {near}"
);
}
#[test]
fn every_corner_stays_inside_at_any_turn_and_one_reaches_the_edge() {
let corners: Vec<Embedded> = (0..8)
.map(|corner: usize| Embedded {
key: corner.to_string().into(),
label: corner.to_string().into(),
group: 0,
at: [0, 1, 2].map(|axis| if corner >> axis & 1 == 1 { 1.0 } else { -1.0 }),
})
.collect();
let fit = radius(&corners);
assert!((fit - 3f32.sqrt()).abs() < 1e-6, "{fit}");
let mut widest: f32 = 0.0;
for yaw in 0..36 {
for pitch in -14..=14 {
let turn = (yaw as f32 * TAU / 36.0, pitch as f32 / 10.0);
for corner in &corners {
let (x, y, near) = lens(Some(turn), fit).place(corner.at);
widest = widest
.max((x - BOX.0 / 2.0).abs())
.max((y - BOX.1 / 2.0).abs());
assert!(
(0.0..=1.0).contains(&near),
"{:?} at {turn:?}: near {near}",
corner.at
);
}
}
}
assert!(
widest <= BOX.1 * 0.45 + 1e-3 && widest > BOX.1 * 0.43,
"{widest}"
);
}
#[test]
fn the_nearest_point_within_reach_and_not_hidden() {
let point = |key: &str, group, x| Embedded {
key: key.to_string().into(),
label: key.to_string().into(),
group,
at: [x, 0.0, 0.0],
};
let points = [point("a", 0, 0.0), point("b", 1, 0.5)];
let (flat, mut hidden) = (lens(None, 1.0), HashSet::new());
assert_eq!(flat.nearest(&points, &hidden, (285.0, 150.0), 8.0), Some(1));
assert_eq!(flat.nearest(&points, &hidden, (300.0, 150.0), 8.0), None);
hidden.insert(1);
assert_eq!(flat.nearest(&points, &hidden, (285.0, 150.0), 8.0), None);
assert_eq!(
radius(&points),
1.0,
"a cloud inside the ball fits the ball"
);
}
#[test]
fn a_turn_wraps_around_and_stops_short_of_the_poles() {
let (yaw, pitch) = turned((6.0, 1.3), (0.5, 0.5));
assert!(
(yaw - (6.5 - TAU)).abs() < 1e-5 && pitch == 1.4,
"{yaw}, {pitch}"
);
assert_eq!(turned((0.0, -1.3), (0.0, -0.5)).1, -1.4);
}
}
+7
View File
@@ -2,10 +2,13 @@ mod audio;
mod brush;
mod compare;
mod downloads;
mod embed;
mod grid;
mod hardware;
mod lens;
mod mask;
mod models;
mod playground;
mod prompt;
mod queue;
mod ratio;
@@ -15,6 +18,7 @@ mod style;
mod tests;
mod tuning;
mod variation;
mod versions;
mod video;
mod voice;
@@ -22,10 +26,12 @@ pub use audio::AudioGenerationPlayer;
pub use brush::MaskBrush;
pub use compare::{ABCompareView, Verdict};
pub use downloads::{Download, DownloadState, ModelDownloadManager};
pub use embed::{Embedded, EmbeddingVisualizer};
pub use grid::{GenerationGrid, Outcome};
pub use hardware::{HardwareMonitor, Reading};
pub use mask::{InpaintCanvas, MaskStroke};
pub use models::{ModelCard, ModelState, ModelStatus};
pub use playground::PromptPlayground;
pub use prompt::PromptEnhancer;
pub use queue::{GenerationQueue, Job, JobState};
pub use ratio::AspectRatioPicker;
@@ -33,5 +39,6 @@ pub use seed::SeedInput;
pub use style::{StylePreset, StylePresetPicker};
pub use tuning::{FineTuneJobCard, TunePhase};
pub use variation::VariationPicker;
pub use versions::{PromptDiff, PromptVersion, PromptVersionHistory};
pub use video::{Shot, VideoGenerationTimeline};
pub use voice::{TTSVoicePicker, Voice};
+266
View File
@@ -0,0 +1,266 @@
use std::{ops::Range, rc::Rc, time::Duration};
use gpui::{
App, ElementId, Entity, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, StyledText, Window, div, prelude::*,
};
use super::prompt::OnText;
use crate::{
buttons::{Button, ButtonVariant},
forms::{Input, TextInput},
motion::Spinner,
primitives::IconName,
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::{
Ellipsis,
format::{plural, took},
},
};
/// The `{names}` in a template, each once, in the order they first appear; a name is letters, digits and underscores.
pub(crate) fn variables(template: &str) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
break;
};
let name = &after[..close];
let valid = !name.is_empty() && name.chars().all(|ch| ch.is_alphanumeric() || ch == '_');
if !valid {
rest = after;
continue;
}
if !names.iter().any(|known| known == name) {
names.push(name.to_string());
}
rest = &after[close + 1..];
}
names
}
/// The template with each named value put in; a name left blank keeps its `{name}`, and its byte range is returned so it can be marked.
pub(crate) fn fill(template: &str, values: &[(String, String)]) -> (String, Vec<Range<usize>>) {
let (mut text, mut blanks) = (template.to_string(), Vec::new());
for (name, value) in values.iter().filter(|(_, value)| !value.is_empty()) {
text = text.replace(&format!("{{{name}}}"), value);
}
for (name, _) in values.iter().filter(|(_, value)| value.is_empty()) {
let slot = format!("{{{name}}}");
blanks.extend(text.match_indices(&slot).map(|(at, _)| at..at + slot.len()));
}
blanks.sort_by_key(|range| range.start);
(text, blanks)
}
/// A prompt to try: a template whose `{names}` become fields, the prompt as it will go with blanks marked, Run once every name has a value, and what came back with its tokens and time.
#[derive(IntoElement)]
pub struct PromptPlayground {
id: ElementId,
template: Entity<TextInput>,
running: bool,
output: Option<SharedString>,
stats: Option<(usize, Duration)>,
on_run: Option<OnText>,
}
impl PromptPlayground {
/// `template` is the owner's field for the prompt.
pub fn new(id: impl Into<ElementId>, template: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
template: template.clone(),
running: false,
output: None,
stats: None,
on_run: None,
}
}
/// While the model answers.
pub fn running(mut self, running: bool) -> Self {
self.running = running;
self
}
/// What came back, and its tokens and time.
pub fn output(mut self, text: impl Into<SharedString>, tokens: usize, took: Duration) -> Self {
self.output = Some(text.into());
self.stats = Some((tokens, took));
self
}
/// Gets the prompt with its values in.
pub fn on_run(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_run = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PromptPlayground {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let template = self.template.read(cx).text().to_string();
let names = variables(&template);
let fields: Vec<(String, Entity<TextInput>)> = names
.into_iter()
.map(|name| {
let placeholder = name.clone();
let field = window.use_keyed_state(
(self.id.clone(), format!("var-{name}")),
cx,
|window, cx| TextInput::new(window, cx).placeholder(placeholder),
);
(name, field)
})
.collect();
let values: Vec<(String, String)> = fields
.iter()
.map(|(name, field)| (name.clone(), field.read(cx).text().to_string()))
.collect();
let (filled, blanks) = fill(&template, &values);
let ready = !template.trim().is_empty() && blanks.is_empty() && !self.running;
let theme = cx.theme();
let colors = theme.colors.clone();
let label = |text: &'static str| {
div()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(text)
};
let blank = HighlightStyle {
background_color: Some(colors.warning_subtle),
..HighlightStyle::default()
};
let sent = filled.clone();
let run = self.on_run.map(|run| {
Button::new((self.id.clone(), "run"), "Run")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.icon(IconName::Play)
.disabled(!ready)
.on_click(move |_, window, cx| {
log::info!("prompt playground: ran {} characters", sent.len());
run(&sent, window, cx)
})
});
let stats = self.stats.map(|(tokens, spent)| {
format!(
"{} · {}",
plural(tokens as u64, "token", "tokens"),
took(spent)
)
});
div()
.w_full()
.flex()
.flex_col()
.gap_3()
.child(label("Template"))
.child(Input::new(&self.template))
.when(!fields.is_empty(), |column| {
column
.child(label("Values"))
.child(div().flex().flex_col().gap_2().children(fields.iter().map(
|(name, field)| {
div()
.flex()
.items_center()
.gap_3()
.child(
div()
.flex_none()
.w(theme.label_width() * 0.6)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(Ellipsis::new(format!("{{{name}}}"))),
)
.child(div().flex_1().min_w_0().child(Input::new(field)))
},
)))
})
.child(label("Prompt"))
.child(
div()
.p_3()
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(
StyledText::new(filled)
.with_highlights(blanks.into_iter().map(|range| (range, blank))),
),
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(div().flex().children(run))
.children(stats.map(|stats| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(stats)
})),
)
.when(self.running, |column| {
column.child(
div()
.flex()
.items_center()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(Spinner::new((self.id.clone(), "running")).size(IconSize::Sm))
.child("Running"),
)
})
.when_some(self.output.filter(|_| !self.running), |column, output| {
column.child(
div()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(output),
)
})
}
}
#[cfg(test)]
mod tests {
use super::{fill, variables};
#[test]
fn names_come_once_in_order_and_odd_braces_are_skipped() {
assert_eq!(
variables("Write {tone} notes on {topic} in a {tone} voice"),
["tone", "topic"]
);
assert_eq!(variables("{a b} then {c} and {} and {d"), ["c"]);
assert_eq!(variables("{{x}}"), ["x"]);
}
#[test]
fn values_go_in_and_blanks_keep_their_names() {
let values = [
("topic".to_string(), "dunes".to_string()),
("tone".to_string(), String::new()),
];
let (text, blanks) = fill("On {topic}, {tone}; {topic}.", &values);
assert_eq!(text, "On dunes, {tone}; dunes.");
let marked: Vec<&str> = blanks.iter().map(|range| &text[range.clone()]).collect();
assert_eq!(marked, ["{tone}"]);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::{
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
type OnText = Rc<dyn Fn(&str, &mut Window, &mut App)>;
pub(super) type OnText = Rc<dyn Fn(&str, &mut Window, &mut App)>;
/// Byte ranges of `new` that `old` lacks, word by word: neighbors joined, and anything but letters and digits trimmed from each end.
pub(crate) fn added_words(old: &str, new: &str) -> Vec<Range<usize>> {
+241
View File
@@ -0,0 +1,241 @@
use gpui::{
AppContext, Bounds, Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Render,
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use jiff::Timestamp;
use super::{press, settle, setup, tab};
use crate::{
forms::TextInput,
generative::{
Embedded, EmbeddingVisualizer, PromptPlayground, PromptVersion, PromptVersionHistory,
lens::{Lens, START, STEP, radius, turned},
},
};
/// An embedding of two points, 400 wide, flat or turned.
struct Space(bool);
const A: [f32; 3] = [0.8, 0.3, 0.2];
const B: [f32; 3] = [-0.6, -0.4, 0.0];
fn points() -> Vec<Embedded> {
let point = |key: &str, group, at| Embedded {
key: key.to_string().into(),
label: key.to_string().into(),
group,
at,
};
vec![point("a", 0, A), point("b", 1, B)]
}
impl Render for Space {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().w(px(400.0)).child(
EmbeddingVisualizer::new("space", points(), ["Docs", "Code"]).three_dimensions(self.0),
)
}
}
fn space(three: bool, cx: &mut TestAppContext) -> &mut VisualTestContext {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Space(three));
cx.update(|window, _| window.activate_window());
settle(cx);
cx
}
/// The plot inside the area's border, and where `at` lands on it under `turn`.
fn aim(at: [f32; 3], turn: Option<(f32, f32)>, cx: &mut VisualTestContext) -> gpui::Point<Pixels> {
let area = cx.debug_bounds("embedding-area").expect("the area draws");
let plot = Bounds::new(
area.origin + point(px(1.0), px(1.0)),
area.size - gpui::size(px(2.0), px(2.0)),
);
let lens = Lens {
turn,
size: (f32::from(plot.size.width), f32::from(plot.size.height)),
radius: radius(&points()),
};
let (x, y, _) = lens.place(at);
plot.origin + point(px(x), px(y))
}
fn hover(at: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_move(at, None, Modifiers::none());
settle(cx);
}
#[gpui::test]
fn a_flat_view_names_the_point_under_the_pointer_and_skips_hidden_groups(cx: &mut TestAppContext) {
let cx = space(false, cx);
tab(1, cx);
let first = cx.update(|window, cx| window.focused(cx));
tab(2, cx);
let wrapped = cx.update(|window, cx| window.focused(cx));
assert!(
first.is_some() && first == wrapped,
"flat, the plot takes no Tab"
);
press("enter", cx);
hover(aim(A, None, cx), cx);
assert!(
cx.debug_bounds("embedding-tip").is_none(),
"Docs is hidden, so a names nothing"
);
let at = aim(B, None, cx);
hover(at, cx);
let tip = cx.debug_bounds("embedding-tip").expect("b is named");
assert!((tip.origin - at).x.abs() < px(0.5) && (tip.origin - at).y.abs() < px(0.5));
}
#[gpui::test]
fn a_turned_view_takes_tab_and_spins_with_the_arrows(cx: &mut TestAppContext) {
let cx = space(true, cx);
tab(3, cx);
for _ in 0..3 {
press("right", cx);
}
let turn = (0..3).fold(START, |turn, _| turned(turn, (STEP, 0.0)));
let at = aim(A, Some(turn), cx);
hover(at, cx);
let tip = cx
.debug_bounds("embedding-tip")
.expect("a is named where it turned to");
assert!((tip.origin - at).x.abs() < px(0.5) && (tip.origin - at).y.abs() < px(0.5));
}
#[gpui::test]
#[should_panic(expected = "sits outside -1 to 1")]
fn a_point_past_the_cube_fails_loud() {
EmbeddingVisualizer::new(
"space",
[Embedded {
key: "far".into(),
label: "far".into(),
group: 0,
at: [1.2, 0.0, 0.0],
}],
["Docs"],
);
}
/// A playground over a template with two names, and the prompts it ran.
struct Trying {
template: Entity<TextInput>,
ran: Vec<String>,
}
impl Render for Trying {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let heard = cx.entity();
div()
.w(px(480.0))
.child(
PromptPlayground::new("try", &self.template).on_run(move |prompt, _, cx| {
heard.update(cx, |view, cx| {
view.ran.push(prompt.to_string());
cx.notify();
})
}),
)
}
}
#[gpui::test]
fn run_waits_for_every_value_then_sends_the_filled_prompt(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|window, cx| {
let template = cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text("Notes on {topic} in a {tone} voice", cx);
input
});
Trying {
template,
ran: Vec::new(),
}
});
cx.update(|window, _| window.activate_window());
settle(cx);
tab(2, cx);
cx.simulate_input("dunes");
settle(cx);
tab(2, cx);
press("enter", cx);
let ran = |cx: &mut VisualTestContext| view.read_with(cx, |view, _| view.ran.clone());
assert!(ran(cx).is_empty(), "with tone blank, Run is out of reach");
cx.update(|window, _| window.blur());
tab(3, cx);
cx.simulate_input("calm");
settle(cx);
tab(1, cx);
press("enter", cx);
assert_eq!(ran(cx), ["Notes on dunes in a calm voice"]);
}
/// Three versions of a prompt, the one chosen, and the versions chosen and restored.
struct History {
chosen: usize,
chose: Vec<usize>,
restored: Vec<usize>,
}
impl Render for History {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (choose, restore) = (cx.entity(), cx.entity());
let at = Timestamp::from_second(1_790_000_000).expect("a time");
let version = |key: &str, text: &str, note: &str| PromptVersion {
key: key.to_string().into(),
text: text.to_string().into(),
author: "Mira".into(),
at,
note: note.to_string().into(),
};
let versions = [
version("v3", "Write plain, short notes.", "Shorter"),
version("v2", "Write plain notes.", "Plain words"),
version("v1", "Write notes.", "First"),
];
div().w(px(480.0)).child(
PromptVersionHistory::new("history", versions, self.chosen)
.on_choose(move |ix, _, cx| {
choose.update(cx, |view, cx| {
view.chosen = ix;
view.chose.push(ix);
cx.notify();
})
})
.on_restore(move |ix, _, cx| {
restore.update(cx, |view, cx| {
view.restored.push(ix);
cx.notify();
})
}),
)
}
}
#[gpui::test]
fn a_version_is_chosen_by_keys_and_only_an_older_one_restores(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| History {
chosen: 0,
chose: Vec::new(),
restored: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
let heard = |cx: &mut VisualTestContext| {
view.read_with(cx, |view, _| (view.chose.clone(), view.restored.clone()))
};
tab(4, cx);
press("enter", cx);
assert_eq!(heard(cx), (vec![], vec![]), "the newest offers no Restore");
cx.update(|window, _| window.blur());
tab(2, cx);
press("enter", cx);
tab(2, cx);
press("enter", cx);
assert_eq!(heard(cx), (vec![1], vec![1]));
}
+1
View File
@@ -3,6 +3,7 @@ use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext}
use crate::{forms, primitives::FocusNext, theme::Theme};
mod canvas;
mod data;
mod models;
mod results;
mod settings;
+351
View File
@@ -0,0 +1,351 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, FontWeight, HighlightStyle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StrikethroughStyle,
Styled, StyledText, Window, div, prelude::*, transparent_black,
};
use jiff::Timestamp;
use similar::{ChangeTag, TextDiff};
use crate::{
buttons::{Button, ButtonVariant},
forms::Pick,
primitives::tab_stop,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::{Ellipsis, RelativeTime},
};
/// Runs of text, each kept, taken out or put in, over the byte range it holds.
type Runs = Vec<(ChangeTag, Range<usize>)>;
/// `new` against `old` as one text: each run kept, taken out, or put in, with the byte range it holds; runs of one kind join, and a space parts a run taken out from a run put in where they touch.
pub(crate) fn changes(old: &str, new: &str) -> (String, Runs) {
let (mut text, mut runs) = (String::new(), Runs::new());
let mut push = |tag: ChangeTag, part: &str| {
let start = text.len();
text.push_str(part);
match runs.last_mut() {
Some((last, range)) if *last == tag => range.end = text.len(),
_ => runs.push((tag, start..text.len())),
}
};
let (mut before, mut blank) = (ChangeTag::Equal, true);
for change in TextDiff::from_unicode_words(old, new).iter_all_changes() {
let (tag, part) = (change.tag(), change.value());
let touching = before != ChangeTag::Equal && tag != ChangeTag::Equal && before != tag;
if touching && !blank && !part.starts_with(char::is_whitespace) {
push(ChangeTag::Equal, " ");
}
push(tag, part);
(before, blank) = (tag, part.ends_with(char::is_whitespace));
}
(text, runs)
}
/// Two versions of a prompt as one text: words taken out struck through on the danger wash, words put in on the success wash.
#[derive(IntoElement)]
pub struct PromptDiff {
old: SharedString,
new: SharedString,
}
impl PromptDiff {
pub fn new(old: impl Into<SharedString>, new: impl Into<SharedString>) -> Self {
Self {
old: old.into(),
new: new.into(),
}
}
}
impl RenderOnce for PromptDiff {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let (text, runs) = changes(&self.old, &self.new);
let styles = runs.into_iter().filter_map(|(tag, range)| {
let style = match tag {
ChangeTag::Equal => return None,
ChangeTag::Delete => HighlightStyle {
background_color: Some(colors.danger_subtle),
color: Some(colors.fg_muted),
strikethrough: Some(StrikethroughStyle {
thickness: theme.underline_thickness(),
color: Some(colors.danger),
}),
..HighlightStyle::default()
},
ChangeTag::Insert => HighlightStyle {
background_color: Some(colors.success_subtle),
..HighlightStyle::default()
},
};
Some((range, style))
});
div()
.w_full()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(StyledText::new(text).with_highlights(styles))
}
}
/// A prompt as saved: its key, its words, who saved it, when, and a note on what changed.
#[derive(Clone, Debug, PartialEq)]
pub struct PromptVersion {
pub key: SharedString,
pub text: SharedString,
pub author: SharedString,
pub at: Timestamp,
pub note: SharedString,
}
/// A prompt's versions, newest first, and what the chosen one changed from the one before it. A press or Enter chooses a version; Restore brings back an older one.
#[derive(IntoElement)]
pub struct PromptVersionHistory {
id: ElementId,
versions: Vec<PromptVersion>,
chosen: usize,
on_choose: Option<Pick>,
on_restore: Option<Pick>,
}
impl PromptVersionHistory {
/// `versions` run newest first; `chosen` indexes them.
pub fn new(
id: impl Into<ElementId>,
versions: impl IntoIterator<Item = PromptVersion>,
chosen: usize,
) -> Self {
let versions: Vec<PromptVersion> = versions.into_iter().collect();
assert!(
chosen < versions.len(),
"version {chosen} of {}",
versions.len()
);
Self {
id: id.into(),
versions,
chosen,
on_choose: None,
on_restore: None,
}
}
pub fn on_choose(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_choose = Some(Rc::new(handler));
self
}
/// Gets an older version to bring back.
pub fn on_restore(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
self.on_restore = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PromptVersionHistory {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let pickable = self.on_choose.is_some();
let focuses: Vec<_> = (0..self.versions.len())
.map(|ix| {
let focus = tab_stop(
(self.id.clone(), format!("focus-{ix}")).into(),
pickable,
window,
cx,
);
let focused = focus.is_focused(window);
(focus, focused)
})
.collect();
let theme = cx.theme();
let colors = theme.colors.clone();
let chosen = self.chosen;
let before = self
.versions
.get(chosen + 1)
.map(|version| version.text.clone())
.unwrap_or_default();
let shown = &self.versions[chosen];
let restore = self
.on_restore
.clone()
.filter(|_| chosen > 0)
.map(|restore| {
Button::new((self.id.clone(), "restore"), "Restore")
.variant(ButtonVariant::Secondary)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
log::info!("prompt versions: restore {chosen}");
restore(chosen, window, cx)
})
});
let heading = match self.versions.len() - chosen {
1 => "The first version".to_string(),
place => format!("Changes from version {}", place - 1),
};
let rows = self.versions.iter().zip(focuses).enumerate().map(
|(ix, (version, (focus, focused)))| {
let on = ix == chosen;
let choose = self.on_choose.clone();
div()
.id((self.id.clone(), format!("version-{ix}")))
.w_full()
.flex()
.flex_col()
.gap_0p5()
.px_2()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
transparent_black()
})
.when(on, |row| row.bg(colors.active))
.when_some(choose, |row, choose| {
row.track_focus(&focus)
.when(!on, |row| row.hover(|style| style.bg(colors.hover)))
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| {
if on {
return;
}
log::info!("prompt versions: chose {ix}");
choose(ix, window, cx)
})
})
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg)
.child(Ellipsis::new(version.note.clone())),
)
.child(
div()
.flex()
.gap_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(
div()
.min_w_0()
.child(Ellipsis::new(format!("{} ·", version.author))),
)
.child(div().flex_none().child(RelativeTime::new(
(self.id.clone(), format!("when-{ix}")),
version.at,
))),
)
},
);
div()
.w_full()
.flex()
.flex_wrap()
.items_start()
.gap_4()
.child(
div()
.flex_1()
.min_w(theme.label_width())
.flex()
.flex_col()
.gap_0p5()
.children(rows),
)
.child(
div()
.flex_1()
.min_w(theme.label_width() * 1.5)
.flex()
.flex_col()
.gap_3()
.p_4()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
.child(heading),
)
.children(restore),
)
.child(PromptDiff::new(before, shown.text.clone())),
)
}
}
#[cfg(test)]
mod tests {
use similar::ChangeTag;
use super::changes;
#[test]
fn a_change_keeps_words_and_marks_what_went_and_came() {
let marked = |old: &str, new: &str| {
let (text, runs) = changes(old, new);
runs.iter()
.map(|(tag, range)| (*tag, text[range.clone()].to_string()))
.collect::<Vec<_>>()
};
let went_and_came = marked("a warm tone", "a calm tone");
assert_eq!(
went_and_came,
[
(ChangeTag::Equal, "a ".into()),
(ChangeTag::Delete, "warm".into()),
(ChangeTag::Equal, " ".into()),
(ChangeTag::Insert, "calm".into()),
(ChangeTag::Equal, " tone".into())
],
"a space parts a word taken out from the word put in"
);
let added = marked("Write short notes.", "Write plain, short notes.");
assert!(
added.contains(&(ChangeTag::Insert, "plain, ".into())),
"{added:?}"
);
let text: String = marked("a very warm tone", "a calm tone")
.into_iter()
.map(|(_, part)| part)
.collect();
assert_eq!(text, "a very warm calm tone", "no doubled space");
let sentences = [
"Write short notes.",
"Write plain, short notes.",
"a very warm tone",
"a calmtone",
"You are a helpful assistant.",
"You answer support questions. Write plain replies.",
"Say sorry once. Offer the next step.",
"Always say sorry.",
"x y ",
"x z",
];
for old in sentences {
for new in sentences {
let (text, _) = changes(old, new);
assert!(!text.contains(" "), "{old:?} to {new:?}: {text:?}");
}
}
}
}
+4 -4
View File
@@ -246,10 +246,10 @@
## T25f Generative · Data and prompts
- [ ] DatasetViewer
- [ ] EmbeddingVisualizer — 2D, 3D projection
- [ ] PromptPlayground
- [ ] PromptDiff / PromptVersionHistory
- [x] DatasetViewer → `tables::DataTable`, with row detail
- [x] EmbeddingVisualizer — 2D, 3D projection
- [x] PromptPlayground
- [x] PromptDiff / PromptVersionHistory
## T26 Media — `src/media`