diff --git a/TASKS.md b/TASKS.md index 6335591..2e42486 100644 --- a/TASKS.md +++ b/TASKS.md @@ -301,3 +301,4 @@ Per-component lines live in `tasks/`. Tags there: | T25d | 2 | PASS | | | T25e | 1 | FAIL | a description row never wrapped, so a 280px model card broke its facts mid-word; the fine-tune bar's share had no test | | T25e | 2 | PASS | | +| T25f | 1 | FAIL | a drag on the turned embedding froze past the plot's edge; which version a diff sets against, and its heading, had no test | diff --git a/src/charts/parts.rs b/src/charts/parts.rs index 9577838..6b7dd55 100644 --- a/src/charts/parts.rs +++ b/src/charts/parts.rs @@ -64,9 +64,6 @@ impl RenderOnce for ChartLegend { div() .id((self.id.clone(), format!("entry-{ix}"))) .flex() - .items_center() - .gap_1p5() - .when(hidden, |entry| entry.opacity(0.4)) .when_some(on_toggle, |entry, on_toggle| { entry .px_1() @@ -81,8 +78,15 @@ impl RenderOnce for ChartLegend { }) .on_click(move |_, window, cx| on_toggle(&key, window, cx)) }) - .child(div().size(theme.status_dot()).rounded_full().bg(color)) - .child(name) + .child( + div() + .flex() + .items_center() + .gap_1p5() + .when(hidden, |key| key.opacity(0.4)) + .child(div().size(theme.status_dot()).rounded_full().bg(color)) + .child(name), + ) }), ) } diff --git a/src/generative/embed.rs b/src/generative/embed.rs index aa9e7f8..568fead 100644 --- a/src/generative/embed.rs +++ b/src/generative/embed.rs @@ -1,12 +1,13 @@ 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, + App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, 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 super::lens::{DRAG, Lens, START, STEP, radius, turned}; use crate::{ charts::{ChartLegend, ChartTooltip, anchored}, primitives::{FocusRing, checked_ratio, tab_stop}, @@ -22,18 +23,18 @@ pub struct Embedded { 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)) } +/// A turning drag, marked with its view. +struct Spin(EntityId); + /// 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>, + from: Point, hovered: Option, hidden: HashSet, bounds: Bounds, @@ -59,6 +60,9 @@ impl EmbeddingVisualizer { ) -> Self { let points: Vec = points.into_iter().collect(); let groups: Vec = groups.into_iter().map(Into::into).collect(); + for (ix, name) in groups.iter().enumerate() { + assert!(!groups[..ix].contains(name), "group {name} named twice"); + } for point in &points { assert!( point.group < groups.len(), @@ -100,7 +104,7 @@ 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, + from: Point::default(), hovered: None, hidden: HashSet::new(), bounds: Bounds::default(), @@ -158,7 +162,8 @@ impl RenderOnce for EmbeddingVisualizer { cx.notify(); }) }); - let (measure, moved, pressed, lifted, keys, left) = ( + let owner = view.entity_id(); + let (measure, moved, pressed, spun, keys, left) = ( view.clone(), view.clone(), view.clone(), @@ -236,19 +241,10 @@ impl RenderOnce for EmbeddingVisualizer { ) .children(tooltip) .on_mouse_move(move |event, _, cx| { + if event.pressed_button.is_some() { + return; + } 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()), @@ -266,24 +262,33 @@ impl RenderOnce for EmbeddingVisualizer { } }) }) - .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 - ); + .when(three, |area| { + area.on_mouse_down(MouseButton::Left, move |event, _, cx| { + pressed.update(cx, |view, _| view.from = event.position) + }) + .on_drag(Spin(owner), |_, _, _, cx| { + log::info!("embedding: a drag turns the view"); + cx.new(|_| EmptyView) + }) + .on_drag_move(move |event: &DragMoveEvent, _, cx| { + if event.drag(cx).0 != owner { + return; } + spun.update(cx, |view, cx| { + let delta = event.event.position - view.from; + view.turn = turned( + view.turn, + (f32::from(delta.x) * DRAG, f32::from(delta.y) * DRAG), + ); + (view.from, view.hovered) = (event.event.position, None); + cx.notify(); + }) }) }) .on_hover(move |inside, _, cx| { if !inside { left.update(cx, |view, cx| { - (view.from, view.hovered) = (None, None); + view.hovered = None; cx.notify(); }) } diff --git a/src/generative/lens.rs b/src/generative/lens.rs index da95836..c013858 100644 --- a/src/generative/lens.rs +++ b/src/generative/lens.rs @@ -11,6 +11,8 @@ const EYE: f32 = 4.0; pub(crate) const START: (f32, f32) = (0.6, 0.35); /// What a key turns the view. pub(crate) const STEP: f32 = PI / 12.0; +/// What a drag turns the view for each pixel. +pub(crate) const DRAG: f32 = 0.01; /// Farthest the view tips up or down, short of looking straight along the upright axis. const TIP: f32 = 1.4; diff --git a/src/generative/tests/data.rs b/src/generative/tests/data.rs index f6a9fc7..e707fda 100644 --- a/src/generative/tests/data.rs +++ b/src/generative/tests/data.rs @@ -1,6 +1,6 @@ use gpui::{ - AppContext, Bounds, Context, Entity, IntoElement, Modifiers, ParentElement, Pixels, Render, - Styled, TestAppContext, VisualTestContext, Window, div, point, px, + AppContext, Bounds, Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, + Pixels, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; use jiff::Timestamp; @@ -9,7 +9,7 @@ use crate::{ forms::TextInput, generative::{ Embedded, EmbeddingVisualizer, PromptPlayground, PromptVersion, PromptVersionHistory, - lens::{Lens, START, STEP, radius, turned}, + lens::{DRAG, Lens, START, STEP, radius, turned}, }, }; @@ -105,6 +105,33 @@ fn a_turned_view_takes_tab_and_spins_with_the_arrows(cx: &mut TestAppContext) { assert!((tip.origin - at).x.abs() < px(0.5) && (tip.origin - at).y.abs() < px(0.5)); } +#[gpui::test] +fn a_drag_keeps_turning_past_the_plots_edge(cx: &mut TestAppContext) { + let cx = space(true, cx); + let middle = cx + .debug_bounds("embedding-area") + .expect("the area draws") + .center(); + let (none, down) = (Modifiers::none(), |y: f32| middle + point(px(0.0), px(y))); + cx.simulate_mouse_down(middle, MouseButton::Left, none); + cx.simulate_mouse_move(down(50.0), MouseButton::Left, none); + cx.simulate_mouse_move(down(250.0), MouseButton::Left, none); + cx.simulate_mouse_up(down(250.0), MouseButton::Left, none); + settle(cx); + let at = aim(A, Some(turned(START, (0.0, 250.0 * DRAG))), cx); + hover(at, cx); + let tip = cx + .debug_bounds("embedding-tip") + .expect("a is named where the whole drag turned it"); + assert!((tip.origin - at).x.abs() < px(0.5) && (tip.origin - at).y.abs() < px(0.5)); +} + +#[gpui::test] +#[should_panic(expected = "named twice")] +fn a_group_named_twice_fails_loud() { + EmbeddingVisualizer::new("space", points(), ["Docs", "Docs"]); +} + #[gpui::test] #[should_panic(expected = "sits outside -1 to 1")] fn a_point_past_the_cube_fails_loud() { diff --git a/src/generative/versions.rs b/src/generative/versions.rs index fcfbdeb..4cbb014 100644 --- a/src/generative/versions.rs +++ b/src/generative/versions.rs @@ -91,6 +91,19 @@ impl RenderOnce for PromptDiff { } } +/// What the chosen version is set against: a heading naming the version before it, and that version's text, empty for the first. +pub(crate) fn compared(versions: &[PromptVersion], chosen: usize) -> (SharedString, SharedString) { + let heading = match versions.len() - chosen { + 1 => "The first version".into(), + place => format!("Changes from version {}", place - 1).into(), + }; + let base = versions + .get(chosen + 1) + .map(|version| version.text.clone()) + .unwrap_or_default(); + (heading, base) +} + /// 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 { @@ -163,11 +176,7 @@ impl RenderOnce for PromptVersionHistory { 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 (heading, before) = compared(&self.versions, chosen); let shown = &self.versions[chosen]; let restore = self .on_restore @@ -182,10 +191,6 @@ impl RenderOnce for PromptVersionHistory { 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; @@ -297,7 +302,33 @@ impl RenderOnce for PromptVersionHistory { mod tests { use similar::ChangeTag; - use super::changes; + use jiff::Timestamp; + + use super::{PromptVersion, changes, compared}; + + #[test] + fn a_version_compares_with_the_one_before_it() { + let version = |n: usize| PromptVersion { + key: format!("v{n}").into(), + text: format!("text {n}").into(), + author: "Mira".into(), + at: Timestamp::UNIX_EPOCH, + note: format!("note {n}").into(), + }; + let versions: Vec = (1..=4).rev().map(version).collect(); + assert_eq!( + compared(&versions, 2), + ("Changes from version 1".into(), "text 1".into()) + ); + assert_eq!( + compared(&versions, 3), + ("The first version".into(), "".into()) + ); + assert_eq!( + compared(&versions, 0), + ("Changes from version 3".into(), "text 3".into()) + ); + } #[test] fn a_change_keeps_words_and_marks_what_went_and_came() {