diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 4b55e31fac..aec564d8b7 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -80,7 +80,7 @@ use super::{ ruler::{RulerMarker, TimelineRuler}, state::TimelineState, time::{Frame, FrameRange, SnapKind, SnapPoint, snap}, - track_header::TrackHeader, + track_header::{TrackHeader, TrackHeaderEvent, TrackToggleHandler}, }; /// Which edge of a clip a trim gesture grabbed. @@ -169,6 +169,16 @@ pub enum TimelineEvent { selected: bool, }, + /// A track header's toggle glyph was clicked. The widget changes nothing + /// itself; the host applies the toggle through its engine (Oak: the + /// undoable track flag setters) and notifies the data source. + TrackToggleRequested { + /// Index of the track whose toggle was clicked. + track: usize, + /// The requested toggle. + toggle: TrackHeaderEvent, + }, + /// The user dragged a transition wedge's edge to change its length. /// /// `new_length` is the requested transition duration in frames, clamped @@ -971,6 +981,19 @@ impl Render for TimelineView { .children(rows.iter().map(|row| { let height = row.height; let separator_y = row.y + height - TrackHeader::SEPARATOR_HEIGHT; + // The toggle glyphs report through the view as + // `TrackToggleRequested` edit requests (the view itself + // never mutates the model). + let view = cx.weak_entity(); + let on_toggle: TrackToggleHandler = + Arc::new(move |track, toggle, _window, app| { + if let Some(view) = view.upgrade() { + view.update(app, |_this, cx| { + cx.emit(TimelineEvent::TrackToggleRequested { track, toggle }); + cx.notify(); + }); + } + }); div() .h(px(height)) .relative() @@ -998,7 +1021,8 @@ impl Render for TimelineView { .locked(row.locked) .muted(row.muted) .solo(row.solo) - .visible(row.visible), + .visible(row.visible) + .on_toggle(on_toggle), ), ) .child( @@ -1438,6 +1462,7 @@ fn marker_accent_color() -> Hsla { #[cfg(test)] mod tests { use super::*; + use crate::timeline::FrameRate; #[test] fn reshape_work_area_moves_edges_within_bounds() { @@ -1467,6 +1492,137 @@ mod tests { let reshaped = reshape_work_area(None, Frame(0), Frame(40), EdgeKind::Out, 1000); assert_eq!(reshaped, FrameRange::new(Frame(0), Frame(40))); } + + /// A minimal data source for the interaction tests: one video track, no + /// clips. + struct OneTrackSource; + + /// Root view hosting the timeline, recording every emitted event. + struct Host { + timeline: Entity>, + events: Vec, + } + + impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.timeline.clone()) + } + } + + struct StubClip; + + impl ClipData for StubClip { + fn id(&self) -> ClipId { + ClipId(0) + } + + fn range(&self) -> FrameRange { + FrameRange::new(Frame::ZERO, Frame(10)) + } + + fn media_in(&self) -> Frame { + Frame::ZERO + } + + fn label(&self) -> SharedString { + "clip".into() + } + } + + struct StubTrack; + + impl TrackData for StubTrack { + type Clip = StubClip; + + fn kind(&self) -> TrackKind { + TrackKind::Video + } + + fn name(&self) -> SharedString { + "V1".into() + } + + fn height(&self) -> Pixels { + px(48.0) + } + + fn clips(&self) -> &[Self::Clip] { + &[] + } + } + + impl TimelineDataSource for OneTrackSource { + type Track = StubTrack; + + fn frame_rate(&self) -> FrameRate { + FrameRate::new(25, 1) + } + + fn sequence_length(&self) -> Frame { + Frame(1000) + } + + fn track_count(&self) -> usize { + 1 + } + + fn track(&self, index: usize) -> Option { + (index == 0).then_some(StubTrack) + } + } + + /// Clicking a track header's toggle glyph emits a + /// `TrackToggleRequested` edit request (and does NOT toggle the header's + /// track selection). + #[test] + fn track_toggle_click_emits_request() { + use crate::{Modifiers, TestAppContext, VisualTestContext, size}; + use std::ops::Deref; + + let mut test_app = TestAppContext::single(); + test_app.update(|cx| cx.init_colors()); + let window = test_app.open_window(size(px(800.), px(200.)), |window, cx| { + let source = cx.new(|_cx| OneTrackSource); + let timeline = cx.new(|cx| TimelineView::new(source, window, cx)); + let host = Host { + events: Vec::new(), + timeline, + }; + cx.subscribe( + &host.timeline, + |host: &mut Host, + _t: Entity>, + event: &TimelineEvent, + _cx: &mut Context| { + host.events.push(event.clone()); + }, + ) + .detach(); + host + }); + let any_window = *window.deref(); + let host = window.root(&mut test_app).expect("host root"); + let mut cx = VisualTestContext::from_window(any_window, &test_app).into_mut(); + + let toggle = cx + .debug_bounds("track-toggle-0-L") + .expect("the lock glyph rendered"); + cx.simulate_click(toggle.center(), Modifiers::none()); + cx.run_until_parked(); + + let events = cx.read(|app| host.read(app).events.clone()); + assert!( + events.contains(&TimelineEvent::TrackToggleRequested { + track: 0, + toggle: TrackHeaderEvent::ToggleLock, + }), + "the lock toggle click emitted its request, got {events:?}" + ); + assert!( + !events.iter().any(|e| matches!(e, TimelineEvent::TrackSelected { .. })), + "the toggle click must not bubble into a track selection" + ); + } } /// The default body color for clips on a track of `kind`. diff --git a/crates/gpui/src/timeline/track_header.rs b/crates/gpui/src/timeline/track_header.rs index a391d2fce5..510dc65360 100644 --- a/crates/gpui/src/timeline/track_header.rs +++ b/crates/gpui/src/timeline/track_header.rs @@ -16,12 +16,12 @@ //! module the header never mutates the model — the host applies the change //! through its engine and the next data read reflects it. //! -//! The element is purely visual (like [`TimelineRuler`](super::TimelineRuler)): -//! it paints the name and the toggle state glyphs, while the click handlers -//! that turn a press into a [`TrackHeaderEvent`] are attached by -//! [`TimelineView`](super::TimelineView)'s interactive wrapper. +//! The toggle glyphs are clickable when [`TrackHeader::on_toggle`] is +//! installed (the [`TimelineView`](super::TimelineView) wires it to emit +//! `TimelineEvent::TrackToggleRequested`); without a handler they render as +//! inert status glyphs. -use crate::{App, Hsla, SharedString, Window, div, hsla, prelude::*, px}; +use crate::{App, ClickEvent, ElementId, Hsla, SharedString, Window, div, hsla, prelude::*, px}; use super::data::TrackKind; @@ -42,6 +42,11 @@ pub enum TrackHeaderEvent { ToggleVisibility, } +/// The handler a host attaches to turn a toggle click into an engine +/// request: `(track index, requested toggle, window, app)`. +pub type TrackToggleHandler = + std::sync::Arc; + /// The header control for one track, rendered in the left column. /// /// The header fills its cell in the view's row layout; the bottom @@ -55,6 +60,8 @@ pub struct TrackHeader { muted: bool, solo: bool, visible: bool, + /// Click handler for the toggle glyphs; `None` renders them inert. + on_toggle: Option, } impl TrackHeader { @@ -73,6 +80,7 @@ impl TrackHeader { muted: false, solo: false, visible: true, + on_toggle: None, } } @@ -103,6 +111,13 @@ impl TrackHeader { self } + /// Builder: the handler invoked when a toggle glyph is clicked. Without + /// it the glyphs render inert (pure status display). + pub fn on_toggle(mut self, handler: TrackToggleHandler) -> Self { + self.on_toggle = Some(handler); + self + } + /// Index of the track this header controls. pub fn track_index(&self) -> usize { self.index @@ -126,9 +141,21 @@ impl TrackHeader { } } - /// A small toggle glyph (one or two letters) reflecting `active`. - fn toggle_glyph(&self, label: &str, active: bool) -> impl IntoElement { - div() + /// A small toggle glyph (one or two letters) reflecting `active`. When an + /// [`Self::on_toggle`] handler is installed the glyph is a click target + /// emitting `event`; the click stops propagating so it never toggles the + /// header row's track selection. + fn toggle_glyph( + &self, + glyph: &'static str, + active: bool, + event: TrackHeaderEvent, + ) -> impl IntoElement { + let index = self.index; + let on_toggle = self.on_toggle.clone(); + let mut target = div() + .id(ElementId::Name(format!("track-toggle-{index}-{glyph}").into())) + .debug_selector(move || format!("track-toggle-{index}-{glyph}").into()) .px_1() .rounded(px(3.)) .text_xs() @@ -142,12 +169,21 @@ impl TrackHeader { } else { hsla(0.0, 0.0, 0.5, 0.55) }) - .child(label.to_string()) + .child(glyph.to_string()); + if let Some(handler) = on_toggle { + target = target.cursor_pointer().on_click( + move |_click: &ClickEvent, _window, cx: &mut App| { + handler(index, event, _window, cx); + cx.stop_propagation(); + }, + ); + } + target } /// The kind-appropriate toggle glyphs, left of the separator. fn toggle_row(&self) -> impl IntoElement { - let lock = self.toggle_glyph("L", self.locked); + let lock = self.toggle_glyph("L", self.locked, TrackHeaderEvent::ToggleLock); match self.kind { TrackKind::Audio => div() .flex() @@ -155,15 +191,15 @@ impl TrackHeader { .items_center() .gap(px(3.)) .child(lock) - .child(self.toggle_glyph("M", self.muted)) - .child(self.toggle_glyph("S", self.solo)), + .child(self.toggle_glyph("M", self.muted, TrackHeaderEvent::ToggleMute)) + .child(self.toggle_glyph("S", self.solo, TrackHeaderEvent::ToggleSolo)), TrackKind::Video | TrackKind::Subtitle => div() .flex() .flex_row() .items_center() .gap(px(3.)) .child(lock) - .child(self.toggle_glyph("V", self.visible)), + .child(self.toggle_glyph("V", self.visible, TrackHeaderEvent::ToggleVisibility)), } } } diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index 6caab25469..54ba1f1ea5 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -250,7 +250,8 @@ impl Render for ProjectExplorer { let selected = self.selected; let control = self.control; - // Toolbar: view toggle. + // Toolbar: view toggle (small glyph buttons; the localized words stay + // as tooltips). let toolbar = div() .flex() .items_center() @@ -260,6 +261,7 @@ impl Render for ProjectExplorer { .bg(colors.container) .child(toggle_button( "gpui-widgets-explorer-tree", + "☰", crate::i18n::tr("explorer.tree", "树"), self.view == ExplorerView::Tree, &colors, @@ -269,6 +271,7 @@ impl Render for ProjectExplorer { )) .child(toggle_button( "gpui-widgets-explorer-icons", + "▦", crate::i18n::tr("explorer.icons", "图标"), self.view == ExplorerView::Icons, &colors, @@ -472,19 +475,24 @@ fn this_expanded(expanded: &HashSet, id: u64) -> bool { expanded.contains(&id) } -/// A small toggle button for the view switcher. +/// A small glyph toggle button for the view switcher: the glyph is the +/// button face, the localized word its tooltip. fn toggle_button( id: &'static str, + glyph: &'static str, label: impl Into, active: bool, colors: &gpui::colors::Colors, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> impl IntoElement { + let label: gpui::SharedString = label.into(); div() .id(id) .debug_selector(move || id.into()) - .px_2() - .py_1() + .size(px(24.0)) + .flex() + .items_center() + .justify_center() .rounded_md() .bg(if active { colors.selected @@ -497,8 +505,9 @@ fn toggle_button( colors.text }) .cursor_pointer() + .tooltip(move |window, cx| crate::tooltip::tooltip_view(label.clone(), window, cx)) .on_click(on_click) - .child(label.into()) + .child(glyph) } #[cfg(test)]