From 396531c075e5d44b4dbf3a9675689e2c131a39a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 13:32:44 -0400 Subject: [PATCH] Generative: the video strip scrolls and reveals; tags wrap in their row The timeline's scroll box is the flex row itself, so its reach is its shots; a focused shot brings itself whole into view through layout::bring_into_view, which table headers now share; Extend sits with the caption under the strip. A voice's tags cap at the row's width and wrap inside it. --- TASKS.md | 1 + src/generative/tests/sound.rs | 96 +++++++++++++++++++++++++++++++++++ src/generative/video.rs | 94 +++++++++++++++++++++++++--------- src/generative/voice.rs | 2 + src/layout/mod.rs | 1 + src/layout/scroll.rs | 18 ++++++- src/tables/header.rs | 15 ++---- 7 files changed, 189 insertions(+), 38 deletions(-) diff --git a/TASKS.md b/TASKS.md index 02b8a6d..8c4b618 100644 --- a/TASKS.md +++ b/TASKS.md @@ -297,3 +297,4 @@ Per-component lines live in `tasks/`. Tags there: | T25b | 2 | PASS | | | T25c | 1 | FAIL | a canvas with no handlers still took Tab, the pen and the ring; strokes from the host went unchecked | | T25c | 2 | PASS | | +| T25d | 1 | FAIL | the timeline's strip never scrolled, its child stretched to the box; a focused shot past the edge stayed hidden; many voice tags ran past a 280px row | diff --git a/src/generative/tests/sound.rs b/src/generative/tests/sound.rs index e105f05..09dc4a0 100644 --- a/src/generative/tests/sound.rs +++ b/src/generative/tests/sound.rs @@ -185,3 +185,99 @@ fn a_peak_past_full_fails_loud() { let _ = AudioGenerationPlayer::new("sound", "rain", "Orchid", vec![1.5], Duration::from_secs(1)); } + +/// Four five-second shots in a strip 280 wide, and what they heard. +struct Long(Heard); + +impl Render for Long { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (chosen, extended) = (self.0.clone(), self.0.clone()); + let shot = |key: &str| Shot { + key: key.to_string().into(), + prompt: "a pan".into(), + length: Duration::from_secs(5), + outcome: Outcome::Pending(None), + }; + div().w(px(280.0)).child( + VideoGenerationTimeline::new("long", ["a", "b", "c", "d"].map(shot), Duration::ZERO) + .on_select(move |key, _, _| chosen.borrow_mut().push(format!("choose {key}"))) + .on_extend(move |_, _| extended.borrow_mut().push("extend".into())), + ) + } +} + +fn long(cx: &mut TestAppContext) -> (Heard, &mut gpui::VisualTestContext) { + setup(cx); + let heard = Heard::default(); + let store = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Long(store)); + cx.update(|window, _| window.activate_window()); + settle(cx); + (heard, cx) +} + +#[gpui::test] +fn a_long_strip_scrolls_sideways(cx: &mut TestAppContext) { + let (heard, cx) = long(cx); + cx.simulate_event(gpui::ScrollWheelEvent { + position: point(px(140.0), px(50.0)), + delta: gpui::ScrollDelta::Pixels(point(px(-2000.0), px(0.0))), + modifiers: Modifiers::none(), + touch_phase: gpui::TouchPhase::Moved, + }); + settle(cx); + cx.simulate_click(point(px(250.0), px(50.0)), Modifiers::none()); + settle(cx); + assert_eq!( + *heard.borrow(), + ["choose d"], + "the last shot comes under the press" + ); +} + +#[gpui::test] +fn tab_brings_a_hidden_shot_into_view_and_reaches_extend(cx: &mut TestAppContext) { + let (heard, cx) = long(cx); + tab(4, cx); + settle(cx); + cx.simulate_click(point(px(250.0), px(50.0)), Modifiers::none()); + settle(cx); + tab(1, cx); + press("enter", cx); + assert_eq!(*heard.borrow(), ["choose d", "extend"]); +} + +/// A voice with six tags in a list 280 wide. +struct Tagged; + +impl Render for Tagged { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let voice = Voice { + key: "iris".into(), + name: "Iris".into(), + about: "a voice".into(), + tags: [ + "English", + "American", + "Female", + "Adult", + "Warm", + "Narration", + ] + .map(gpui::SharedString::from) + .to_vec(), + }; + div() + .w(px(280.0)) + .child(TTSVoicePicker::new("voices", [voice])) + } +} + +#[gpui::test] +fn many_tags_wrap_inside_their_row(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Tagged); + settle(cx); + let tags = cx.debug_bounds("voice-tags-iris").expect("the tags draw"); + assert!(tags.right() <= px(280.0), "{tags:?}"); +} diff --git a/src/generative/video.rs b/src/generative/video.rs index 10a7a89..504240a 100644 --- a/src/generative/video.rs +++ b/src/generative/video.rs @@ -2,8 +2,8 @@ use std::{rc::Rc, time::Duration}; use gpui::{ App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, relative, - transparent_black, + ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, + prelude::*, relative, transparent_black, }; use super::Outcome; @@ -12,7 +12,7 @@ use crate::{ debug::ruler, documents::source, forms::{OnValue, Run}, - layout::on_axis, + layout::{bring_into_view, on_axis}, motion::Skeleton, primitives::{Icon, IconName, Image, tab_stop}, theme::{ActiveTheme, ControlSize, IconSize, TextSize}, @@ -97,6 +97,16 @@ impl RenderOnce for VideoGenerationTimeline { .is_none_or(|key| self.shots.iter().any(|shot| &shot.key == key)); assert!(named, "the chosen shot is not listed"); let pickable = self.on_select.is_some(); + let scroll = window + .use_keyed_state((self.id.clone(), "scroll"), cx, |_, _| ScrollHandle::new()) + .read(cx) + .clone(); + let revealed = + window.use_keyed_state( + (self.id.clone(), "revealed"), + cx, + |_, _| None::, + ); let focuses: Vec<_> = self .shots .iter() @@ -211,6 +221,36 @@ impl RenderOnce for VideoGenerationTimeline { } else { transparent_black() }) + .when(pick.is_some(), |tile| { + let (focus, revealed, scroll, key) = + (focus.clone(), revealed.clone(), scroll.clone(), key.clone()); + tile.child( + canvas( + move |bounds, window, cx| { + let focused = focus.is_focused(window); + let held = revealed.read(cx).as_ref() == Some(&key); + if focused && !held { + revealed.update(cx, |revealed, _| { + *revealed = Some(key.clone()) + }); + if bring_into_view(&scroll, bounds) { + log::info!( + "video timeline: shot {key} scrolled into view" + ); + window.request_animation_frame(); + } + } else if !focused && held { + revealed.update(cx, |revealed, _| *revealed = None); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + }) .when_some(pick, |tile, pick| { tile.track_focus(&focus) .cursor_pointer() @@ -253,36 +293,40 @@ impl RenderOnce for VideoGenerationTimeline { .gap_2() .child( on_axis(div().id((self.id.clone(), "sideways"))) + .track_scroll(&scroll) .w_full() + .flex() .overflow_x_scroll() + .pb_1() .child( div() + .relative() + .flex_none() .flex() - .items_end() - .gap_3() - .pb_1() + .flex_col() + .gap_1() + .child(ruler_row) + .child(div().flex().children(tiles)) .child( div() - .relative() - .flex_none() - .flex() - .flex_col() - .gap_1() - .child(ruler_row) - .child(div().flex().children(tiles)) - .child( - div() - .absolute() - .top_0() - .bottom_0() - .left(relative(playhead)) - .border_l_1() - .border_color(colors.accent), - ), - ) - .children(extend.map(|extend| div().flex_none().child(extend))), + .absolute() + .top_0() + .bottom_0() + .left(relative(playhead)) + .border_l_1() + .border_color(colors.accent), + ), ), ) - .children(caption.map(Caption::new)) + .when(caption.is_some() || extend.is_some(), |column| { + column.child( + div() + .flex() + .items_center() + .gap_3() + .child(div().flex_1().min_w_0().children(caption.map(Caption::new))) + .children(extend.map(|extend| div().flex_none().child(extend))), + ) + }) } } diff --git a/src/generative/voice.rs b/src/generative/voice.rs index 2a7d2e7..ad9d719 100644 --- a/src/generative/voice.rs +++ b/src/generative/voice.rs @@ -215,7 +215,9 @@ impl RenderOnce for TTSVoicePicker { ) .child( div() + .debug_selector(|| format!("voice-tags-{}", voice.key)) .flex_none() + .max_w_full() .flex() .flex_wrap() .gap_1() diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 54a67be..369b577 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -21,6 +21,7 @@ pub use floating::FloatingPanel; pub use grid::{Masonry, SimpleGrid}; pub use page::{AppShell, MasterDetail, Page}; pub use panes::{PaneGroup, PaneId, PaneLayout}; +pub(crate) use scroll::bring_into_view; pub use scroll::{ScrollArea, Scrollbar, on_axis}; pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader}; pub use sheet::{Drawer, Sheet}; diff --git a/src/layout/scroll.rs b/src/layout/scroll.rs index d3d34dc..879191d 100644 --- a/src/layout/scroll.rs +++ b/src/layout/scroll.rs @@ -5,7 +5,7 @@ use std::{ }; use gpui::{ - AnyElement, App, Axis, Div, ElementId, EmptyView, Entity, EntityId, InteractiveElement, + AnyElement, App, Axis, Bounds, Div, ElementId, EmptyView, Entity, EntityId, InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, point, prelude::*, }; @@ -16,6 +16,22 @@ use crate::{motion, theme::ActiveTheme}; const IDLE: Duration = Duration::from_millis(900); +/// Sets `scroll`'s sideways offset whole so `item`, as painted, sits inside the box, its start first when it is wider; true when it moved. +pub(crate) fn bring_into_view(scroll: &ScrollHandle, item: Bounds) -> bool { + let (frame, offset) = (scroll.bounds(), scroll.offset()); + let shift = if item.left() < frame.left() { + frame.left() - item.left() + } else if item.right() > frame.right() { + (frame.right() - item.right()).max(frame.left() - item.left()) + } else { + Pixels::ZERO + }; + if shift != Pixels::ZERO { + scroll.set_offset(point(offset.x + shift, offset.y)); + } + shift != Pixels::ZERO +} + /// Keeps a scroll box's wheel to its own axes; gpui turns a wheel along the other axis onto a box that scrolls one way, while the page scrolls too. pub fn on_axis(mut element: E) -> E { element.style().restrict_scroll_to_axis = Some(true); diff --git a/src/tables/header.rs b/src/tables/header.rs index 54b824d..f3412f3 100644 --- a/src/tables/header.rs +++ b/src/tables/header.rs @@ -1,12 +1,12 @@ use gpui::{ App, Bounds, Div, DragMoveEvent, ElementId, Entity, EntityId, FocusHandle, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Rems, SharedString, - Stateful, StatefulInteractiveElement, Styled, Window, canvas, div, point, prelude::*, + Stateful, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, transparent_black, }; use super::{Column, table::View}; -use crate::layout::seeded::Seeded; +use crate::layout::{bring_into_view, seeded::Seeded}; use crate::{ primitives::{DragGhost, FocusRing, Icon, IconName}, theme::{ActiveTheme, IconSize, TextSize}, @@ -96,17 +96,8 @@ fn reveal( return; } let scroll = view.read(cx).sideways.clone(); - let (frame, offset) = (scroll.bounds(), scroll.offset()); - let shift = if head.left() < frame.left() { - frame.left() - head.left() - } else if head.right() > frame.right() { - (frame.right() - head.right()).max(frame.left() - head.left()) - } else { - Pixels::ZERO - }; view.update(cx, |view, _| view.revealed = Some(key.clone())); - if shift != Pixels::ZERO { - scroll.set_offset(point(offset.x + shift, offset.y)); + if bring_into_view(&scroll, head) { log::info!("data table: header {key} scrolled into view"); window.request_animation_frame(); }