diff --git a/AGENTS.md b/AGENTS.md index 034161a..810f29c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/examples/gallery/capture.rs b/examples/gallery/capture.rs index 59d2654..bfaa854 100644 --- a/examples/gallery/capture.rs +++ b/examples/gallery/capture.rs @@ -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)?; diff --git a/examples/gallery/pages/generative/data.rs b/examples/gallery/pages/generative/data.rs new file mode 100644 index 0000000..6bf33a4 --- /dev/null +++ b/examples/gallery/pages/generative/data.rs @@ -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 = 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 { + 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 { + 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) + }), + ), + )) +} diff --git a/examples/gallery/pages/generative/mod.rs b/examples/gallery/pages/generative/mod.rs index 0ccae50..ee5361d 100644 --- a/examples/gallery/pages/generative/mod.rs +++ b/examples/gallery/pages/generative/mod.rs @@ -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() } diff --git a/examples/gallery/shell.rs b/examples/gallery/shell.rs index 08889a7..7ec440e 100644 --- a/examples/gallery/shell.rs +++ b/examples/gallery/shell.rs @@ -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.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) -> 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() diff --git a/src/charts/parts.rs b/src/charts/parts.rs index d6efa5c..9577838 100644 --- a/src/charts/parts.rs +++ b/src/charts/parts.rs @@ -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; -/// 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)) diff --git a/src/charts/tests.rs b/src/charts/tests.rs index 3a6b505..9ad9a72 100644 --- a/src/charts/tests.rs +++ b/src/charts/tests.rs @@ -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>>); + +impl Render for Keyed { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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"]); +} diff --git a/src/generative/embed.rs b/src/generative/embed.rs new file mode 100644 index 0000000..aa9e7f8 --- /dev/null +++ b/src/generative/embed.rs @@ -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) -> (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>, + hovered: Option, + hidden: HashSet, + bounds: Bounds, +} + +/// 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>, + radius: f32, + groups: Vec, + three: bool, + ratio: f32, +} + +impl EmbeddingVisualizer { + /// `groups` names each group a point's `group` indexes. + pub fn new( + id: impl Into, + points: impl IntoIterator, + groups: impl IntoIterator>, + ) -> Self { + let points: Vec = points.into_iter().collect(); + let groups: Vec = 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) + } +} diff --git a/src/generative/lens.rs b/src/generative/lens.rs new file mode 100644 index 0000000..da95836 --- /dev/null +++ b/src/generative/lens.rs @@ -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, + at: (f32, f32), + reach: f32, + ) -> Option { + 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::().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 = (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); + } +} diff --git a/src/generative/mod.rs b/src/generative/mod.rs index 98faddd..63b5f6e 100644 --- a/src/generative/mod.rs +++ b/src/generative/mod.rs @@ -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}; diff --git a/src/generative/playground.rs b/src/generative/playground.rs new file mode 100644 index 0000000..d21ce6f --- /dev/null +++ b/src/generative/playground.rs @@ -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 { + let mut names: Vec = 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>) { + 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, + running: bool, + output: Option, + stats: Option<(usize, Duration)>, + on_run: Option, +} + +impl PromptPlayground { + /// `template` is the owner's field for the prompt. + pub fn new(id: impl Into, template: &Entity) -> 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, 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)> = 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}"]); + } +} diff --git a/src/generative/prompt.rs b/src/generative/prompt.rs index a648280..9edb44f 100644 --- a/src/generative/prompt.rs +++ b/src/generative/prompt.rs @@ -14,7 +14,7 @@ use crate::{ theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, }; -type OnText = Rc; +pub(super) type OnText = Rc; /// 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> { diff --git a/src/generative/tests/data.rs b/src/generative/tests/data.rs new file mode 100644 index 0000000..f6a9fc7 --- /dev/null +++ b/src/generative/tests/data.rs @@ -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 { + 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) -> 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 { + 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, 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, + ran: Vec, +} + +impl Render for Trying { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, + restored: Vec, +} + +impl Render for History { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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])); +} diff --git a/src/generative/tests/mod.rs b/src/generative/tests/mod.rs index 766c4e1..8bba9c6 100644 --- a/src/generative/tests/mod.rs +++ b/src/generative/tests/mod.rs @@ -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; diff --git a/src/generative/versions.rs b/src/generative/versions.rs new file mode 100644 index 0000000..fcfbdeb --- /dev/null +++ b/src/generative/versions.rs @@ -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)>; + +/// `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, new: impl Into) -> 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, + chosen: usize, + on_choose: Option, + on_restore: Option, +} + +impl PromptVersionHistory { + /// `versions` run newest first; `chosen` indexes them. + pub fn new( + id: impl Into, + versions: impl IntoIterator, + chosen: usize, + ) -> Self { + let versions: Vec = 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::>() + }; + 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:?}"); + } + } + } +} diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index fa70b54..7b2519b 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -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`