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

+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:?}");
}
}
}
}