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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user