fix(ui): track growth direction, proxy status, effect library search

- 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)
This commit is contained in:
2026-08-20 22:28:43 +08:00
parent 5498504398
commit c5455c7521
7 changed files with 123 additions and 35 deletions
+9 -4
View File
@@ -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);
}
+19
View File
@@ -696,6 +696,25 @@ pub fn sequence_track_list(sequence: &NodeRef, kind: TrackType) -> Option<NodeRe
None
}
/// Remove `track` from the list by id and renumber the remaining tracks
/// (the precise counterpart of `tracklist_remove_last` for undo paths
/// that must not eat the wrong track).
pub fn tracklist_remove(list: &NodeRef, track: &NodeRef) {
let mut p = list.lock();
let ids: Vec<NodeId> = {
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<NodeRef> {
+9
View File
@@ -136,6 +136,15 @@ pub fn addable_effects() -> Vec<super::engine::EffectEntry> {
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
}
+13 -7
View File
@@ -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<usize, String> {
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<NodeId> = {
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
+21 -7
View File
@@ -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::<oaknode::footage::FootageBehavior>())
{
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<RealTrack> = 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<dyn Iterator<Item = (usize, &NodeId)>> = 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));
}
}
+44 -15
View File
@@ -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<E: AppEngine> {
engine: Entity<E>,
/// The search box state: live-filters the list by name / type id
/// (case-insensitive substring).
search: Entity<EditableTextState>,
}
impl<E: AppEngine> EffectLibraryPanel<E> {
@@ -43,7 +47,12 @@ impl<E: AppEngine> EffectLibraryPanel<E> {
// 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<E: AppEngine> Render for EffectLibraryPanel<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<E: AppEngine> Render for EffectLibraryPanel<E> {
.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<String> = 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<Option<String>> = 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<E: AppEngine> Render for EffectLibraryPanel<E> {
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()
+8 -2
View File
@@ -88,10 +88,16 @@ impl<E: AppEngine> Render for StatusBar<E> {
};
// 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()