feat(gpui): track-header toggle targets + explorer icon buttons

This commit is contained in:
2026-08-17 01:37:31 +08:00
parent 819029e876
commit 27c6c393c3
3 changed files with 221 additions and 20 deletions
+158 -2
View File
@@ -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<D: TimelineDataSource> Render for TimelineView<D> {
.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<D: TimelineDataSource> Render for TimelineView<D> {
.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<TimelineView<OneTrackSource>>,
events: Vec<TimelineEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<Self::Track> {
(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<TimelineView<OneTrackSource>>,
event: &TimelineEvent,
_cx: &mut Context<Host>| {
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`.
+49 -13
View File
@@ -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<dyn Fn(usize, TrackHeaderEvent, &mut Window, &mut App)>;
/// 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<TrackToggleHandler>,
}
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)),
}
}
}
+14 -5
View File
@@ -250,7 +250,8 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
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<D: ProjectDataSource> Render for ProjectExplorer<D> {
.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<D: ProjectDataSource> Render for ProjectExplorer<D> {
))
.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<u64>, 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<gpui::SharedString>,
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)]