feat(gpui): track-header toggle targets + explorer icon buttons
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user