// Oak Video Editor - Non-Linear Video Editor // Copyright (C) 2026 Oak Team // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . //! The timeline panel (时间线): the design's 31px toolbar (tools, snap //! toggle) above the full-width [`TimelineView`](gpui::timeline::TimelineView) //! over the engine's sequence model. //! //! # Layout (fixed 2026-08) //! //! ```text //! ┌─────────────────────────────────────────┬─────────────┐ //! │ toolbar row (fixed 31px): tools + − ⏵ │ │ //! ├─────────────────────────────────────────┤ right-side │ //! │ timeline (ruler takes remaining width, │ controls │ //! │ clip area below) │ (fixed 140px│ //! │ │ zoom / │ //! │ │ track hgt) │ //! └─────────────────────────────────────────┴─────────────┘ //! ``` //! //! The zoom and track-height sliders used to sit at the right end of the //! toolbar, where they overflowed into the ruler's timecode labels (the //! toolbar is exactly 31px but the sliders' value rows are taller, and at //! narrow widths the sliders squeezed into the ruler's right side). They //! now live in a fixed-width trailing slot beside the timeline body, and the //! timeline wrapper is `min_w_0` so the ruler always keeps the remaining //! space — no overlap at 1600×900 or down to ~1100px wide. use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::timeline::{ Frame, TimelineView, TrackData, TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, }; use gpui::{div, img, prelude::*, px, Context, Entity, Window}; use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; use gpui_widgets::project_explorer::FootageDrag; use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; use gpui_widgets::tooltip::tooltip_view; use gpui_widgets::value::ValueKind; use crate::i18n; use crate::oakui::icons; use crate::oakui::AppEngine; use crate::panels::ids::TIMELINE; /// Toolbar height, per the design (31px). const TOOLBAR_HEIGHT: f32 = 31.0; /// Fixed width of the trailing controls slot (zoom / track-height sliders). /// Kept constant so the sliders can never intrude into the ruler's labels. const RIGHT_CONTROLS_WIDTH: f32 = 140.0; /// The demo tool set, by i18n key, with the matching toolbar icon (the /// legacy C++ icon set). Only the visual selection is implemented; each /// tool's behavior arrives with the real tool system later. const TOOLS: [(&str, &str); 8] = [ ("timeline.tool.select", crate::oakui::icons::ICON_ARROW), ("timeline.tool.razor", crate::oakui::icons::ICON_RAZOR), ("timeline.tool.ripple", crate::oakui::icons::ICON_RIPPLE), ("timeline.tool.slip", crate::oakui::icons::ICON_SLIP), ("timeline.tool.roll", crate::oakui::icons::ICON_ROLLING), ("timeline.tool.zoom", crate::oakui::icons::ICON_ZOOM), ("timeline.tool.slide", crate::oakui::icons::ICON_SLIDE), ( "timeline.tool.track_select", crate::oakui::icons::ICON_TRACK_SELECT, ), ]; /// The timeline panel. pub struct TimelinePanel { timeline: Entity>, engine: Entity, zoom: Entity, height: Entity, snap: Entity, /// The currently selected tool (visual only). selected_tool: usize, /// The drop point of an in-flight footage drag: the display track under /// the cursor plus the start frame. `None` outside the clip area or while /// no footage drag is active. footage_drop: Option, } /// A footage drop target resolved from the cursor: the display track under /// the pointer and the clip's start frame. struct FootageDropTarget { /// The pointed track's kind. track_kind: TrackKind, /// The pointed display track index. track_index: usize, /// The start frame at the pointer. time: Frame, } impl TimelinePanel { /// Builds the panel around `timeline` (created by the app shell so it can /// sync the playhead). pub fn new( engine: Entity, timeline: Entity>, window: &mut Window, cx: &mut Context, ) -> Self { let zoom = cx.new(|cx| { Slider::new( 10, SliderModel::new(ValueKind::Float, 0.5, 8.0, 0.1, 2.0), window, cx, ) }); let height = cx.new(|cx| { Slider::new( 11, SliderModel::new(ValueKind::Float, 24.0, 160.0, 8.0, 64.0), window, cx, ) }); let snap = cx.new(|cx| CheckBox::new(12, CheckState::Checked, window, cx)); // Zoom slider → timeline zoom (pixels per frame). cx.subscribe(&zoom, |this, _zoom, event: &SliderEvent, cx| { if let SliderEvent::ValueChanged { value, .. } = event { let zoom = value.to_f64() as f32; this.timeline.update(cx, |timeline, cx| { timeline.state.set_zoom(zoom, px(0.0)); cx.notify(); }); } }) .detach(); // Track-height slider → engine model (persisted per sequence). cx.subscribe(&height, |this, _height, event: &SliderEvent, cx| { if let SliderEvent::ValueChanged { value, .. } = event { let height = value.to_f64() as f32; this.engine .update(cx, |engine, cx| engine.set_track_height(px(height), cx)); } }) .detach(); // Snap toggle → timeline view state. cx.subscribe(&snap, |this, _snap, event: &CheckBoxEvent, cx| { let CheckBoxEvent::Toggled { state, .. } = event; let enabled = *state == CheckState::Checked; this.timeline.update(cx, |timeline, cx| { timeline.state.snap_enabled = enabled; cx.notify(); }); }) .detach(); Self { timeline, engine, zoom, height, snap, selected_tool: 0, footage_drop: None, } } /// Resolves the footage-drop target under the cursor: converts the /// pointer (relative to the timeline body) into a display track + start /// frame using the timeline view's zoom/scroll state and the engine's /// track heights — the same affine mapping the timeline itself uses (see /// [`TimelineState::frame_at_point`] and the view's track-row walk). /// Hovering outside the clip area (above the ruler) clears the target. fn update_footage_drop(&mut self, event: &DragMoveEvent, cx: &mut Context) { let now = event.event.position - event.bounds.origin; // The clip area starts below the ruler and right of the track // headers column. if f32::from(now.y) < RULER_HEIGHT { self.footage_drop = None; return; } let clip_x = f32::from(now.x - px(HEADER_WIDTH)).max(0.0); let clip_y = now.y - px(RULER_HEIGHT); let state = self.timeline.read(cx).state.clone(); let seq_len = self.engine.read(cx).sequence_length(); let time = state.frame_at_point(px(clip_x)).clamp(Frame::ZERO, seq_len); // Walk the display rows top-down, clamping each to the minimum row // height exactly like the timeline's own `track_at_y`. let (track_kind, track_index) = { let engine = self.engine.read(cx); let mut acc = 0.0f32; let mut found = None; for index in 0..engine.track_count() { if let Some(track) = engine.track(index) { acc += f32::from(track.height()).max(MIN_TRACK_HEIGHT); if f32::from(clip_y) < acc { found = Some((track.kind(), index)); break; } } } found.unwrap_or_else(|| { let last = engine.track_count().saturating_sub(1); engine .track(last) .map(|t| (t.kind(), last)) .unwrap_or((TrackKind::Video, 0)) }) }; self.footage_drop = Some(FootageDropTarget { track_kind, track_index, time, }); } /// Applies a finished footage drop: routes the payload's footage id with /// the last hovered track + frame to the engine, which resolves the /// footage, validates the track and places the clip (undoable). fn finish_footage_drop(&mut self, drag: &FootageDrag, cx: &mut Context) { let Some(target) = self.footage_drop.take() else { return; }; let FootageDropTarget { track_kind, track_index, time, } = target; self.engine.update(cx, |engine, cx| { engine.drop_footage(drag.0, track_kind, track_index, time, cx); }); } } impl Render for TimelinePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); // --- toolbar row (fixed 31px, above the ruler) -------------------- let mut toolbar = div() .debug_selector(|| "timeline-toolbar".into()) .h(px(TOOLBAR_HEIGHT)) .flex_shrink_0() .flex() .items_center() .gap_2() .px_2() .overflow_hidden() .border_b_1() .border_color(colors.border) .bg(colors.container); // A tool button: a 16px icon on a 24px hit target with a localized // tooltip; the selected tool is highlighted. let tool_button = |index: usize, icon_name: &'static str, key: &'static str, cx: &mut Context| { let tool = i18n::tr(key); let selected = self.selected_tool == index; let background = if selected { colors.selected } else { colors.background }; let hover_bg = colors.selected; let path = icons::icon_path(icon_name, cx); div() .id(SharedString::from(format!("tool-{index}"))) .size(px(24.0)) .flex() .items_center() .justify_center() .rounded_sm() .cursor_pointer() .bg(background) .hover(move |style| style.bg(hover_bg)) .tooltip(move |window, cx| tooltip_view(tool.into(), window, cx)) .on_click(cx.listener(move |this, _event: &ClickEvent, _window, _cx| { println!("[timeline] tool: {tool} (placeholder)"); this.selected_tool = index; })) .child(img(path).size(px(16.0))) }; for (index, (tool_key, icon_name)) in TOOLS.iter().enumerate() { toolbar = toolbar.child(tool_button(index, icon_name, tool_key, cx)); } // A plain icon button (no selection state), e.g. zoom in/out. let icon_btn = |id: &'static str, icon_name: &'static str, key: &'static str, cx: &mut Context| { let label = i18n::tr(key); let hover_bg = colors.container; let path = icons::icon_path(icon_name, cx); div() .id(id) .size(px(24.0)) .flex() .items_center() .justify_center() .rounded_sm() .cursor_pointer() .text_color(colors.text) .hover(move |style| style.bg(hover_bg)) .tooltip(move |window, cx| tooltip_view(label.into(), window, cx)) .child(img(path).size(px(16.0))) }; // The snap toggle: the magnet icon next to the checkbox box. The icon // is decorative (the box itself is clickable, as in the widget's // default row). let snap_row = div() .flex() .items_center() .gap_1() .text_color(colors.text) .child( div() .id("snap-toggle") .size(px(24.0)) .flex() .items_center() .justify_center() .cursor_pointer() .tooltip(move |window, cx| { tooltip_view(i18n::tr("timeline.snap").into(), window, cx) }) .child(img(icons::icon_path(icons::ICON_SNAP, cx)).size(px(16.0))), ) .child(self.snap.clone()); let toolbar = toolbar .child(icon_btn( "toolbar-zoom-in", icons::ICON_ZOOM_IN, "timeline.zoom_in", cx, )) .child(icon_btn( "toolbar-zoom-out", icons::ICON_ZOOM_OUT, "timeline.zoom_out", cx, )) .child( div() .w_1() .h_full() .border_l_1() .border_color(colors.border), ) .child(snap_row); // --- trailing controls slot (fixed width, right of the body) ------- let right_controls = div() .debug_selector(|| "timeline-right-controls".into()) .w(px(RIGHT_CONTROLS_WIDTH)) .flex_shrink_0() .flex() .flex_col() .justify_center() .gap_1() .px_2() .border_l_1() .border_color(colors.border) .bg(colors.container) .child( div() .flex() .flex_col() .gap_1() .text_xs() .text_color(colors.disabled) .child(i18n::tr("timeline.zoom")) .child(self.zoom.clone()), ) .child( div() .flex() .flex_col() .gap_1() .text_xs() .text_color(colors.disabled) .child(i18n::tr("timeline.track_height")) .child(self.height.clone()), ); div() .size_full() .flex() .flex_col() .overflow_hidden() .child(toolbar) .child( div() .debug_selector(|| "timeline-body".into()) .flex_1() .min_h_0() .flex() .flex_row() .child( div() .debug_selector(|| "timeline-canvas".into()) .flex_1() .min_w_0() // Footage drop target: hover resolves the track + // frame (see [`TimelinePanel::update_footage_drop`]), // the release routes the payload to the engine. .on_drag_move(cx.listener( |this, event: &DragMoveEvent, _window, cx| { this.update_footage_drop(event, cx); }, )) .on_drop(cx.listener(|this, drag: &FootageDrag, _window, cx| { if std::env::var("OAK_DEBUG_DRAG").is_ok() { eprintln!("[drag] timeline drop: {drag:?}"); } this.finish_footage_drop(drag, cx); })) .child(self.timeline.clone()), ) .child(right_controls), ) } } impl EventEmitter for TimelinePanel {} impl DockPanel for TimelinePanel { fn panel_id(&self) -> gpui::dock::PanelId { TIMELINE } fn title(&self, _cx: &App) -> SharedString { i18n::tr("panel.timeline").into() } fn tab_content(&self, _cx: &App) -> AnyElement { div().child(i18n::tr("panel.timeline")).into_any_element() } } #[cfg(test)] mod tests { use super::*; use crate::oakui::MockEngine; use gpui::{px, size, TestAppContext, VisualTestContext}; /// Builds a `TimelinePanel` in a window of the given logical size and /// returns a `VisualTestContext` for bounds assertions. fn panel_window( cx: &mut TestAppContext, width: f32, height: f32, ) -> ( &'static mut VisualTestContext, Entity>, ) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(width), px(height)), |window, cx| { let engine = cx.new(|cx| crate::oakui::MockEngine::demo(cx)); let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0)); TimelinePanel::new(engine, timeline, window, cx) }); cx.run_until_parked(); let panel = window.root(cx).expect("timeline panel root"); let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); (cx, panel) } /// The toolbar row must sit entirely above the timeline body, the right /// controls must sit to the right of the timeline canvas (never /// overlapping it), and the controls slot must keep its fixed width — at /// the default 1600×900 and down to ~1100px wide. #[gpui::test] async fn toolbar_ruler_and_right_controls_never_overlap(cx: &mut TestAppContext) { for width in [1600.0, 1280.0, 1100.0] { let (cx, _panel) = panel_window(cx, width, 900.0); let toolbar = cx .debug_bounds("timeline-toolbar") .expect("toolbar row rendered"); let body = cx .debug_bounds("timeline-body") .expect("timeline body row rendered"); let canvas = cx .debug_bounds("timeline-canvas") .expect("timeline canvas rendered"); let right = cx .debug_bounds("timeline-right-controls") .expect("right controls slot rendered"); // The toolbar is exactly 31px tall and ends where the body starts. assert!( (f32::from(toolbar.size.height) - TOOLBAR_HEIGHT).abs() < 0.5, "toolbar height {width}: {} != {TOOLBAR_HEIGHT}", toolbar.size.height ); assert!( toolbar.bottom() <= body.top(), "toolbar overlaps the body at width {width}" ); // The controls slot is fixed-width and never overlaps the canvas. assert!( (f32::from(right.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5, "right slot width {width}: {} != {RIGHT_CONTROLS_WIDTH}", right.size.width ); assert!( canvas.right() <= right.left(), "right controls overlap the timeline canvas at width {width}" ); // The timeline (ruler) keeps the remaining width: canvas right // edge equals the slot's left edge exactly. assert!( (f32::from(canvas.right()) - f32::from(right.left())).abs() < 0.5, "canvas and controls slot are not flush at width {width}" ); // The right controls are inside the body's vertical bounds. assert!( right.top() >= body.top() && right.bottom() <= body.bottom(), "right controls escape the body at width {width}" ); } } /// Resizing a window keeps the same invariants (the timeline body shrinks /// while the toolbar and the right slot stay fixed). #[gpui::test] async fn resizing_keeps_toolbar_and_right_slot_fixed(cx: &mut TestAppContext) { let (cx, _panel) = panel_window(cx, 1600.0, 900.0); let before = cx .debug_bounds("timeline-right-controls") .expect("right controls rendered"); assert!((f32::from(before.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5); cx.simulate_resize(size(px(1100.0), px(900.0))); cx.run_until_parked(); let after = cx .debug_bounds("timeline-right-controls") .expect("right controls rendered after resize"); let canvas = cx .debug_bounds("timeline-canvas") .expect("timeline canvas after resize"); assert!((f32::from(after.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5); assert!(canvas.right() <= after.left()); } }