From c5455c7521ac8d45ccf98146916ed582440d5c63 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 22:28:43 +0800 Subject: [PATCH] fix(ui): track growth direction, proxy status, effect library search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NLE track growth is now a display concern: video/subtitle track lists render reversed (a new track lands on top), audio lists render in order (a new track lands at the bottom); the graph list always appends. Track-add undo removes THIS track by id instead of blindly removing the last one - add_track returns the actual index of the new track (diffed against the pre-command list) instead of assuming append-at-end - status bar proxy segment reflects the real Use Proxy Media switch instead of a static "Proxy: Off" - proxy transcode PROGRESS events no longer invalidate the rendered frame cache on every tick (only completion does) — progress updates used to keep the playback cache permanently cold while generating - effect library: live search box (name/type-id substring), Built-in group header, and the addable-effects table is sorted alphabetically (built-ins first, then OFX sub-category groups) --- crates/oaktimeline/src/undogeneral.rs | 13 ++++-- crates/oaktimeline/src/util.rs | 19 +++++++++ src/oakui/effectchain.rs | 9 ++++ src/oakui/graphops.rs | 20 +++++---- src/oakui/real.rs | 28 +++++++++---- src/panels/effect_library.rs | 59 ++++++++++++++++++++------- src/panels/status_bar.rs | 10 ++++- 7 files changed, 123 insertions(+), 35 deletions(-) diff --git a/crates/oaktimeline/src/undogeneral.rs b/crates/oaktimeline/src/undogeneral.rs index eaa6b3d8f..51ac0abfb 100644 --- a/crates/oaktimeline/src/undogeneral.rs +++ b/crates/oaktimeline/src/undogeneral.rs @@ -38,8 +38,8 @@ use crate::util::{ block_set_in, block_set_length_and_media_in, block_set_length_and_media_out, block_track, clip_media_in, clip_set_media_in, same_block, track_append_block, track_create, track_insert_block_after, track_insert_block_before, track_replace_block, - track_ripple_remove_block, tracklist_append, tracklist_remove_last, tracklist_track_at, - tracklist_track_count, tracklist_type, BlockKind, NodeRef, + track_ripple_remove_block, tracklist_append, tracklist_remove, tracklist_remove_last, + tracklist_track_at, tracklist_track_count, tracklist_type, BlockKind, NodeRef, }; // `oaknode/sequence.h` element input ids (the C++ automerge branch). @@ -284,6 +284,9 @@ impl TimelineAddTrackCommand { if self.track_entry.is_some() { block_add_to_graph(&self.track, self.track_entry.take()); } + // NLE track-growth direction is a DISPLAY concern: the graph list + // always appends, and the UI renders video/subtitle lists reversed + // (new track on top) but audio lists in order (new track below). tracklist_append(&self.timeline, &self.track); } @@ -292,8 +295,10 @@ impl TimelineAddTrackCommand { // NOTE: the C++ undo disconnects the merge/direct input and removes // the track node from the project graph; the disconnects have no // Rust equivalent, but the graph removal is real: the track's arena - // entry is detached and owned here until the next redo. - let _ = tracklist_remove_last(&self.timeline); + // entry is detached and owned here until the next redo. Removes + // THIS track by id (not blindly the last one) and renumbers the + // remaining tracks. + tracklist_remove(&self.timeline, &self.track); if self.track_entry.is_none() { self.track_entry = block_remove_from_graph(&self.track); } diff --git a/crates/oaktimeline/src/util.rs b/crates/oaktimeline/src/util.rs index c23576178..db5dd00ec 100644 --- a/crates/oaktimeline/src/util.rs +++ b/crates/oaktimeline/src/util.rs @@ -696,6 +696,25 @@ pub fn sequence_track_list(sequence: &NodeRef, kind: TrackType) -> Option = { + let Some(l) = tracklist_behavior_of_mut(&mut p, list.id) else { + return; + }; + l.tracks.retain(|&t| t != track.id); + l.tracks.clone() + }; + for (i, id) in ids.iter().enumerate() { + if let Some(t) = track_behavior_of_mut(&mut p, *id) { + t.index = i as i32; + } + } +} + /// `oaknode_sequence_get_all_track_count` / `get_all_track_at`: every /// track of every track list owned by the sequence, in list order. pub fn sequence_all_tracks(sequence: &NodeRef) -> Vec { diff --git a/src/oakui/effectchain.rs b/src/oakui/effectchain.rs index 4c6e1ef92..8f82846dc 100644 --- a/src/oakui/effectchain.rs +++ b/src/oakui/effectchain.rs @@ -136,6 +136,15 @@ pub fn addable_effects() -> Vec { group: Some(meta.sub_category), }); } + // Default presentation order: built-ins first, then the OpenFX + // sub-category groups alphabetically; names alphabetical (folded) + // within each group. + out.sort_by(|a, b| { + let ga = a.group.as_deref().unwrap_or(""); + let gb = b.group.as_deref().unwrap_or(""); + ga.cmp(gb) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); out } diff --git a/src/oakui/graphops.rs b/src/oakui/graphops.rs index 4defad483..035b68004 100644 --- a/src/oakui/graphops.rs +++ b/src/oakui/graphops.rs @@ -1056,23 +1056,29 @@ pub fn set_context_position_command( )) } -/// Append a track of `kind` to the sequence (undoable "Add Track"; the +/// Add a track of `kind` to the sequence (undoable "Add Track"; the /// module's `TimelineAddTrackCommand`), returning the new track's index. pub fn add_track(p: &ProjectRef, seq: NodeId, kind: TrackType) -> Result { let list = find_or_create_track_list(p, seq, kind) .ok_or_else(|| "sequence has no track list for this type".to_string())?; + let before: Vec = { + let g = lock(p); + track_list_behavior(&g.graph, list) + .map(|l| l.tracks.clone()) + .unwrap_or_default() + }; push( oaktimeline::undogeneral::TimelineAddTrackCommand::new(node_ref(p, list)).to_command(), "Add Track", )?; let g = lock(p); - let n = track_list_behavior(&g.graph, list) - .map(|l| l.tracks.len()) + let tracks = track_list_behavior(&g.graph, list) + .map(|l| l.tracks.clone()) .ok_or_else(|| "add track command produced no track list".to_string())?; - if n == 0 { - return Err("add track command produced no track".to_string()); - } - Ok(n - 1) + tracks + .iter() + .position(|id| !before.contains(id)) + .ok_or_else(|| "add track command produced no track".to_string()) } /// Remove `track` from its list (undoable "Remove Track"; the module's diff --git a/src/oakui/real.rs b/src/oakui/real.rs index 677d75e24..d60f9dfe1 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -2021,15 +2021,15 @@ impl RealEngine { !finished.iter().any(|(footage, _)| *footage == run.footage) }); if let Some(project) = self.project.clone() { - for (footage, ok) in finished { + for (footage, ok) in &finished { let mut guard = graphops::lock(&project); if let Some(f) = guard .graph - .get_mut(footage) + .get_mut(*footage) .and_then(|e| e.behavior.as_any_mut()) .and_then(|a| a.downcast_mut::()) { - if ok { + if *ok { // The transcode wrote the final file: mark the // proxy ready and usable (state 2, enabled). f.proxy_state = 2; @@ -2038,11 +2038,17 @@ impl RealEngine { f.proxy_state = 3; } } - changed = true; } } - if changed { + // Only a FINISHED run may change any preview media (the proxy + // engages then); progress events only repaint the status bar. + // Invalidating the rendered frames on every progress tick would + // keep the playback cache permanently cold while a proxy + // generates. + if !finished.is_empty() { self.invalidate_preview_frames(cx); + } else if changed { + cx.notify(); } } @@ -2595,10 +2601,18 @@ impl RealEngine { let mut out: Vec = Vec::new(); { let guard = graphops::lock(project); - // Per-type track lists, each displayed topmost-first. + // Per-type track lists, each displayed topmost-first. NLE + // growth direction: video/subtitle tracks grow upward (the + // list is displayed reversed, so an appended track lands on + // top), audio tracks grow downward (list order = display + // order, so an appended track lands at the bottom). for kind in [TrackType::Video, TrackType::Audio, TrackType::Subtitle] { let tracks = graphops::track_ids(&guard.graph, seq, kind); - for (track_index, &track_id) in tracks.iter().enumerate().rev() { + let ordered: Box> = match kind { + TrackType::Audio => Box::new(tracks.iter().enumerate()), + _ => Box::new(tracks.iter().enumerate().rev()), + }; + for (track_index, &track_id) in ordered { out.push(Self::snapshot_track(&guard.graph, track_id, kind, track_index, tb)); } } diff --git a/src/panels/effect_library.rs b/src/panels/effect_library.rs index 175ecfbab..a6e11e642 100644 --- a/src/panels/effect_library.rs +++ b/src/panels/effect_library.rs @@ -26,6 +26,7 @@ use gpui::{ div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton, Render, SharedString, Window, }; +use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage, TextChanged}; use crate::i18n; use crate::oakui::AppEngine; @@ -35,6 +36,9 @@ use crate::panels::ids::EFFECT_LIBRARY; /// The effect library panel. pub struct EffectLibraryPanel { engine: Entity, + /// The search box state: live-filters the list by name / type id + /// (case-insensitive substring). + search: Entity, } impl EffectLibraryPanel { @@ -43,7 +47,12 @@ impl EffectLibraryPanel { // Re-read the effect table whenever the engine notifies (the table // itself is static, but the selection hint depends on the target). cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach(); - Self { engine } + let search = cx.new(|cx| EditableTextState::new(StringStorage::default(), cx)); + cx.subscribe(&search, |_this, _state, _event: &TextChanged, cx| { + cx.notify(); + }) + .detach(); + Self { engine, search } } } @@ -55,6 +64,7 @@ impl Render for EffectLibraryPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let effects = self.engine.read(cx).addable_effects(); + let query = self.search.read(cx).as_str().trim().to_lowercase(); let mut list = div() .id("effect-library-list") @@ -66,21 +76,28 @@ impl Render for EffectLibraryPanel { .p_2() .overflow_y_scroll(); - // Built-in effects render flat; OpenFX plugin entries are grouped - // under their sub-category header (Filter / Generator / Transition / - // General — the C++ `factorymenu` OpenFX branch). - let mut last_group: Option = None; + // Built-in effects render flat under a Built-in header; OpenFX + // plugin entries are grouped under their sub-category header + // (Filter / Generator / Transition / General — the C++ + // `factorymenu` OpenFX branch). The engine table arrives sorted + // (built-ins first, then groups and names alphabetically); the + // search box live-filters by name / type id. + let mut last_group: Option> = None; for entry in &effects { - match &entry.group { - Some(group) => { - if last_group.as_deref() != Some(group.as_str()) { - last_group = Some(group.clone()); - list = list.child(group_header(&colors, group)); - } - } - None => { - last_group = None; - } + if !query.is_empty() + && !entry.name.to_lowercase().contains(&query) + && !entry.type_id.to_lowercase().contains(&query) + { + continue; + } + let group_key = entry.group.clone(); + if last_group.as_ref() != Some(&group_key) { + last_group = Some(group_key); + let label = match &entry.group { + Some(group) => group.clone(), + None => i18n::tr("effect_library.group.builtin").to_string(), + }; + list = list.child(group_header(&colors, &label)); } let engine = self.engine.clone(); let row_id = entry.type_id.clone(); @@ -126,6 +143,18 @@ impl Render for EffectLibraryPanel { cx.emit(PanelEvent::Focused); }) }) + .child( + div() + .flex_shrink_0() + .p_2() + .border_b_1() + .border_color(colors.border) + .child( + text_input("effect-library-search") + .state(self.search.downgrade()) + .accepts_input(true), + ), + ) .child(list) .child( div() diff --git a/src/panels/status_bar.rs b/src/panels/status_bar.rs index cb732f951..df068495d 100644 --- a/src/panels/status_bar.rs +++ b/src/panels/status_bar.rs @@ -88,10 +88,16 @@ impl Render for StatusBar { }; // The proxy segment doubles as the in-flight proxy transcode's - // progress readout (the C++ status bar shows the running task). + // progress readout (the C++ status bar shows the running task); + // idle it reflects the global Use Proxy Media switch. let proxy_text = match engine.proxy_task_progress() { Some((label, progress)) => format!("{label} {}%", (progress * 100.0) as i32), - None => crate::i18n::tr("status.proxy").into(), + None => crate::i18n::tr(if engine.use_proxy_media() { + "status.proxy.on" + } else { + "status.proxy.off" + }) + .into(), }; div()