feat(app): track-header toggles, effect library, panel titles (design parity)
- track headers: name + visible/mute/lock toggles, undoable through graphops, honored by the montage (muted tracks are skipped) and by the edit guards (locked tracks reject trims/moves/deletes) - project explorer gains a panel title row; the tree/icons toggle is now small icon buttons with tooltips - new effect library dock panel (every addable effect; double-click appends to the selected clip's chain) - screenshots refreshed (zh/en, window/manager/preferences)
This commit is contained in:
+25
@@ -57,6 +57,7 @@ use gpui_widgets::viewer::PlaybackClock;
|
||||
|
||||
use crate::dialogs::{ExportDialogContent, PreferencesContent};
|
||||
use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine};
|
||||
use crate::panels::effect_library::EffectLibraryPanel;
|
||||
use crate::panels::history::HistoryPanel;
|
||||
use crate::panels::ids::*;
|
||||
use crate::panels::inspector::InspectorPanel;
|
||||
@@ -121,6 +122,7 @@ pub(crate) mod menu_ids {
|
||||
pub const FOCUS_INSPECTOR: usize = 605;
|
||||
pub const FOCUS_HISTORY: usize = 606;
|
||||
pub const FOCUS_TIMELINE: usize = 607;
|
||||
pub const FOCUS_EFFECT_LIBRARY: usize = 608;
|
||||
|
||||
pub const ABOUT: usize = 801;
|
||||
}
|
||||
@@ -219,6 +221,7 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
|
||||
INSPECTOR => "inspector",
|
||||
HISTORY => "history",
|
||||
TIMELINE => "timeline",
|
||||
EFFECT_LIBRARY => "effect-library",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
@@ -270,6 +273,10 @@ impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
|
||||
cx.new(|cx| HistoryPanel::new(window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"effect-library" => Some(PanelHandle::new(
|
||||
cx.new(|cx| EffectLibraryPanel::new(self.engine.clone(), window, cx)),
|
||||
cx,
|
||||
)),
|
||||
"timeline" => Some(PanelHandle::new(
|
||||
cx.new(|cx| {
|
||||
let timeline =
|
||||
@@ -360,6 +367,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
});
|
||||
|
||||
let project = cx.new(|cx| ProjectExplorerPanel::new(engine.clone(), window, cx));
|
||||
let effect_library = cx.new(|cx| EffectLibraryPanel::new(engine.clone(), window, cx));
|
||||
let source_viewer =
|
||||
cx.new(|cx| SourceViewerPanel::new(engine.clone(), source_clock.clone(), window, cx));
|
||||
let program_viewer = cx.new(|cx| {
|
||||
@@ -382,6 +390,16 @@ impl<E: AppEngine> OakApp<E> {
|
||||
// as tabs, timeline full width at the bottom.
|
||||
dock.update(cx, |dock, cx| {
|
||||
dock.add_panel(PanelHandle::new(project, cx), None, cx);
|
||||
// The effect library tabs behind the project bin, per the
|
||||
// design's left-top tab group (项目 | 效果库).
|
||||
dock.add_panel(
|
||||
PanelHandle::new(effect_library, cx),
|
||||
Some(DropTarget {
|
||||
panel: Some(PROJECT),
|
||||
zone: DropZone::Center,
|
||||
}),
|
||||
cx,
|
||||
);
|
||||
dock.add_panel(
|
||||
PanelHandle::new(source_viewer, cx),
|
||||
Some(DropTarget {
|
||||
@@ -453,6 +471,11 @@ impl<E: AppEngine> OakApp<E> {
|
||||
if let Some(path) = layout.find_panel(PROGRAM_VIEWER) {
|
||||
layout.set_tabs_active(&path, PROGRAM_VIEWER);
|
||||
}
|
||||
// The project bin is the active tab of its group (the effect library
|
||||
// sits behind it).
|
||||
if let Some(path) = layout.find_panel(PROJECT) {
|
||||
layout.set_tabs_active(&path, PROJECT);
|
||||
}
|
||||
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
|
||||
|
||||
// --- status bar ----------------------------------------------------
|
||||
@@ -661,6 +684,7 @@ impl<E: AppEngine> OakApp<E> {
|
||||
FOCUS_INSPECTOR => self.focus_panel(INSPECTOR, cx),
|
||||
FOCUS_HISTORY => self.focus_panel(HISTORY, cx),
|
||||
FOCUS_TIMELINE => self.focus_panel(TIMELINE, cx),
|
||||
FOCUS_EFFECT_LIBRARY => self.focus_panel(EFFECT_LIBRARY, cx),
|
||||
other => println!("[menu] placeholder action for item {other}"),
|
||||
}
|
||||
}
|
||||
@@ -1657,6 +1681,7 @@ fn make_menus(dark: bool) -> Vec<MenuBarEntry> {
|
||||
MenuItem::new(FOCUS_INSPECTOR, tr("menu.window.inspector")),
|
||||
MenuItem::new(FOCUS_HISTORY, tr("menu.window.history")),
|
||||
MenuItem::new(FOCUS_TIMELINE, tr("menu.window.timeline")),
|
||||
MenuItem::new(FOCUS_EFFECT_LIBRARY, tr("menu.window.effect_library")),
|
||||
]),
|
||||
),
|
||||
MenuBarEntry::new(
|
||||
|
||||
@@ -234,6 +234,7 @@ const EN: &[(&str, &str)] = &[
|
||||
("menu.window.inspector", "Inspector"),
|
||||
("menu.window.history", "History"),
|
||||
("menu.window.timeline", "Timeline"),
|
||||
("menu.window.effect_library", "Effect Library"),
|
||||
// --- Tools ---
|
||||
("menu.tools.select", "Select"),
|
||||
("menu.tools.razor", "Razor"),
|
||||
@@ -248,6 +249,9 @@ const EN: &[(&str, &str)] = &[
|
||||
("panel.inspector", "Inspector"),
|
||||
("panel.history", "History"),
|
||||
("panel.timeline", "Timeline"),
|
||||
("panel.effect_library", "Effect Library"),
|
||||
// --- effect library ---
|
||||
("effect_library.hint", "Double-click to add to the selected clip"),
|
||||
// --- status bar ---
|
||||
("status.ready", "Ready"),
|
||||
("status.cache", "Cache: Enabled"),
|
||||
@@ -438,6 +442,7 @@ const ZH: &[(&str, &str)] = &[
|
||||
("menu.window.inspector", "检查器"),
|
||||
("menu.window.history", "历史记录"),
|
||||
("menu.window.timeline", "时间线"),
|
||||
("menu.window.effect_library", "效果库"),
|
||||
// --- Tools ---
|
||||
("menu.tools.select", "选择"),
|
||||
("menu.tools.razor", "剃刀"),
|
||||
@@ -452,6 +457,9 @@ const ZH: &[(&str, &str)] = &[
|
||||
("panel.inspector", "检查器"),
|
||||
("panel.history", "历史记录"),
|
||||
("panel.timeline", "时间线"),
|
||||
("panel.effect_library", "效果库"),
|
||||
// --- effect library ---
|
||||
("effect_library.hint", "双击添加到选中片段"),
|
||||
// --- status bar ---
|
||||
("status.ready", "就绪"),
|
||||
("status.cache", "缓存:已启用"),
|
||||
|
||||
@@ -1012,6 +1012,98 @@ pub fn track_height(p: &ProjectRef, track: NodeId) -> Option<f64> {
|
||||
track_behavior(&g.graph, track).map(|t| t.height)
|
||||
}
|
||||
|
||||
/// A track's muted flag (`None` when the id is stale). Muted means
|
||||
/// "silenced" on audio tracks and "hidden" on video/subtitle tracks
|
||||
/// (Olive parity: one flag drives both).
|
||||
pub fn track_muted(p: &ProjectRef, track: NodeId) -> Option<bool> {
|
||||
let g = lock(p);
|
||||
track_behavior(&g.graph, track).map(|t| t.muted)
|
||||
}
|
||||
|
||||
/// A track's locked flag (`None` when the id is stale).
|
||||
pub fn track_locked(p: &ProjectRef, track: NodeId) -> Option<bool> {
|
||||
let g = lock(p);
|
||||
track_behavior(&g.graph, track).map(|t| t.locked)
|
||||
}
|
||||
|
||||
/// An undoable track-flag set (the closures capture the previous value, so
|
||||
/// the undo restores it exactly). Shared by the mute/hide and lock toggles.
|
||||
fn set_track_flag(
|
||||
p: &ProjectRef,
|
||||
track: NodeId,
|
||||
field: TrackFlag,
|
||||
value: bool,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let old = {
|
||||
let g = lock(p);
|
||||
let t = track_behavior(&g.graph, track)
|
||||
.ok_or_else(|| "set track flag: the node is not a track".to_string())?;
|
||||
match field {
|
||||
TrackFlag::Muted => t.muted,
|
||||
TrackFlag::Locked => t.locked,
|
||||
}
|
||||
};
|
||||
if old == value {
|
||||
return Ok(());
|
||||
}
|
||||
let (p1, p2) = (p.clone(), p.clone());
|
||||
push(
|
||||
oakundo::undocommand::UndoCommand::from_closures(
|
||||
move || {
|
||||
let mut g = lock(&p1);
|
||||
if let Some(t) = g
|
||||
.graph
|
||||
.get_mut(track)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
{
|
||||
match field {
|
||||
TrackFlag::Muted => t.muted = value,
|
||||
TrackFlag::Locked => t.locked = value,
|
||||
}
|
||||
}
|
||||
},
|
||||
move || {
|
||||
let mut g = lock(&p2);
|
||||
if let Some(t) = g
|
||||
.graph
|
||||
.get_mut(track)
|
||||
.and_then(|e| e.behavior.as_any_mut())
|
||||
.and_then(|a| a.downcast_mut::<TrackBehavior>())
|
||||
{
|
||||
match field {
|
||||
TrackFlag::Muted => t.muted = old,
|
||||
TrackFlag::Locked => t.locked = old,
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
/// The track flags the undoable setters cover.
|
||||
#[derive(Clone, Copy)]
|
||||
enum TrackFlag {
|
||||
/// Muted (audio) / hidden (video, subtitle).
|
||||
Muted,
|
||||
/// Locked against clip edits.
|
||||
Locked,
|
||||
}
|
||||
|
||||
/// Set a track's muted flag (undoable "Set Track Muted"). On video and
|
||||
/// subtitle tracks this is the visibility (show/hide) toggle.
|
||||
pub fn set_track_muted(p: &ProjectRef, track: NodeId, muted: bool) -> Result<(), String> {
|
||||
set_track_flag(p, track, TrackFlag::Muted, muted, "Set Track Muted")
|
||||
}
|
||||
|
||||
/// Set a track's locked flag (undoable "Set Track Locked"). Locked tracks
|
||||
/// reject clip edits (the app layer refuses trim/move/split/delete).
|
||||
pub fn set_track_locked(p: &ProjectRef, track: NodeId, locked: bool) -> Result<(), String> {
|
||||
set_track_flag(p, track, TrackFlag::Locked, locked, "Set Track Locked")
|
||||
}
|
||||
|
||||
/// Set a track's height in internal units (NOT undoable, mirroring the
|
||||
/// facade's `oakengine_track_set_height`).
|
||||
pub fn set_track_height(p: &ProjectRef, track: NodeId, height: f64) {
|
||||
@@ -1454,3 +1546,75 @@ pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A project with one sequence holding one track of `kind`; returns the
|
||||
/// project, the sequence and the track's node id.
|
||||
fn project_with_track(kind: TrackType) -> (ProjectRef, NodeId, NodeId) {
|
||||
let project = create_project();
|
||||
let seq = create_sequence(&project, "Seq");
|
||||
let index = add_track(&project, seq, kind).expect("add a track");
|
||||
let track = {
|
||||
let g = lock(&project);
|
||||
track_ids(&g.graph, seq, kind)[index]
|
||||
};
|
||||
(project, seq, track)
|
||||
}
|
||||
|
||||
/// The track flag setters flip the flag as ONE undoable entry each;
|
||||
/// undo restores the previous value and redo re-applies.
|
||||
#[test]
|
||||
fn track_flag_setters_toggle_and_undo() {
|
||||
let _g = test_lock();
|
||||
oakundo::global::clear().unwrap();
|
||||
let (project, _seq, track) = project_with_track(TrackType::Video);
|
||||
|
||||
assert_eq!(track_muted(&project, track), Some(false));
|
||||
assert_eq!(track_locked(&project, track), Some(false));
|
||||
|
||||
set_track_muted(&project, track, true).expect("mute the track");
|
||||
assert_eq!(track_muted(&project, track), Some(true));
|
||||
set_track_locked(&project, track, true).expect("lock the track");
|
||||
assert_eq!(track_locked(&project, track), Some(true));
|
||||
|
||||
oakundo::global::undo().unwrap();
|
||||
assert_eq!(track_locked(&project, track), Some(false));
|
||||
oakundo::global::undo().unwrap();
|
||||
assert_eq!(track_muted(&project, track), Some(false));
|
||||
oakundo::global::redo().unwrap();
|
||||
assert_eq!(track_muted(&project, track), Some(true));
|
||||
oakundo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// Setting a flag to its current value pushes no undo row.
|
||||
#[test]
|
||||
fn track_flag_setter_noop_when_unchanged() {
|
||||
let _g = test_lock();
|
||||
oakundo::global::clear().unwrap();
|
||||
let (project, _seq, track) = project_with_track(TrackType::Audio);
|
||||
let before = oakundo::global::count().unwrap();
|
||||
|
||||
set_track_muted(&project, track, false).expect("mute already false");
|
||||
set_track_locked(&project, track, false).expect("lock already false");
|
||||
assert_eq!(oakundo::global::count().unwrap(), before);
|
||||
oakundo::global::clear().unwrap();
|
||||
}
|
||||
|
||||
/// A stale (non-track) id is rejected, not silently ignored.
|
||||
#[test]
|
||||
fn track_flag_setters_reject_non_tracks() {
|
||||
let _g = test_lock();
|
||||
oakundo::global::clear().unwrap();
|
||||
let (project, seq, _track) = project_with_track(TrackType::Video);
|
||||
assert!(set_track_muted(&project, seq, true).is_err());
|
||||
assert!(set_track_locked(&project, seq, true).is_err());
|
||||
oakundo::global::clear().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+196
-1
@@ -1205,6 +1205,41 @@ impl AppEngine for MockEngine {
|
||||
self.apply_effect_event(event, cx);
|
||||
}
|
||||
|
||||
fn addable_effects(&self) -> Vec<(String, String)> {
|
||||
// The demo list is the real factory's video-effect table, so the
|
||||
// effect library shows the same entries the real engine would.
|
||||
crate::oakui::effectchain::addable_effects()
|
||||
}
|
||||
|
||||
fn add_effect(
|
||||
&mut self,
|
||||
index: usize,
|
||||
type_id: &str,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<(), String> {
|
||||
let Some((_, name)) = crate::oakui::effectchain::addable_effects()
|
||||
.into_iter()
|
||||
.find(|(id, _)| id == type_id)
|
||||
else {
|
||||
return Err(format!("unknown effect \"{type_id}\""));
|
||||
};
|
||||
let id = EffectId(self.next_effect_id);
|
||||
self.next_effect_id += 1;
|
||||
let card = MockEffect {
|
||||
id,
|
||||
kind: EffectCardKind::Effect,
|
||||
title: name.into(),
|
||||
subtitle: None,
|
||||
enabled: true,
|
||||
expanded: false,
|
||||
badge: None,
|
||||
};
|
||||
let index = index.min(self.effects.len());
|
||||
self.effects.insert(index, card);
|
||||
cx.notify();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context<Self>) {
|
||||
self.apply_node_graph_event(event, cx);
|
||||
}
|
||||
@@ -1223,6 +1258,9 @@ impl AppEngine for MockEngine {
|
||||
new_frame,
|
||||
} => {
|
||||
if let Some((track, index)) = self.mock_clip_position(*clip) {
|
||||
if self.tracks[track].locked {
|
||||
return;
|
||||
}
|
||||
let clip = &mut self.tracks[track].clips[index];
|
||||
match edge {
|
||||
gpui::timeline::TrimEdge::Start => {
|
||||
@@ -1245,6 +1283,11 @@ impl AppEngine for MockEngine {
|
||||
let Some((track, index)) = self.mock_clip_position(*clip) else {
|
||||
return;
|
||||
};
|
||||
if self.tracks[track].locked
|
||||
|| self.tracks.get(*new_track).map(|t| t.locked).unwrap_or(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut clip = self.tracks[track].clips.remove(index);
|
||||
let length = clip.range.end.0 - clip.range.start.0;
|
||||
clip.range = FrameRange::new(*new_start, Frame(new_start.0 + length));
|
||||
@@ -1263,6 +1306,30 @@ impl AppEngine for MockEngine {
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
TimelineEvent::TrackToggleRequested { track, toggle } => {
|
||||
// The demo model applies the toggles directly (no undo in
|
||||
// mock mode); the muted flag doubles as video visibility,
|
||||
// mirroring the real engine.
|
||||
let Some(track) = self.tracks.get_mut(*track) else {
|
||||
return;
|
||||
};
|
||||
match toggle {
|
||||
gpui::timeline::TrackHeaderEvent::ToggleLock => {
|
||||
track.locked = !track.locked;
|
||||
}
|
||||
gpui::timeline::TrackHeaderEvent::ToggleMute => {
|
||||
track.muted = !track.muted;
|
||||
}
|
||||
gpui::timeline::TrackHeaderEvent::ToggleSolo => {
|
||||
track.solo = !track.solo;
|
||||
}
|
||||
gpui::timeline::TrackHeaderEvent::ToggleVisibility => {
|
||||
track.visible = !track.visible;
|
||||
track.muted = !track.visible;
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
TimelineEvent::WorkAreaPreview { start, end } => {
|
||||
self.set_workarea_preview(*start, *end, cx);
|
||||
}
|
||||
@@ -1281,6 +1348,9 @@ impl AppEngine for MockEngine {
|
||||
let Some((track, index)) = self.mock_clip_position(clip) else {
|
||||
return;
|
||||
};
|
||||
if self.tracks[track].locked {
|
||||
return;
|
||||
}
|
||||
self.split_mock_clip(track, index, time);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -1291,6 +1361,7 @@ impl AppEngine for MockEngine {
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, track)| !track.locked)
|
||||
.flat_map(|(track_index, track)| {
|
||||
track
|
||||
.clips
|
||||
@@ -1311,6 +1382,9 @@ impl AppEngine for MockEngine {
|
||||
let Some((track, index)) = self.mock_clip_position(clip) else {
|
||||
return;
|
||||
};
|
||||
if self.tracks[track].locked {
|
||||
return;
|
||||
}
|
||||
let removed = self.tracks[track].clips.remove(index);
|
||||
if ripple {
|
||||
// Shift the following clips on the same track left by the removed
|
||||
@@ -1994,10 +2068,131 @@ mod tests {
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) {
|
||||
async fn track_toggle_requests_flip_the_track_flags(cx: &mut TestAppContext) {
|
||||
use gpui::timeline::TrackHeaderEvent;
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
|
||||
// V1 (display index 1) starts unlocked + visible.
|
||||
let v1 = engine.read(app).track(1).expect("V1");
|
||||
assert!(!v1.is_locked());
|
||||
assert!(v1.is_visible());
|
||||
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 1,
|
||||
toggle: TrackHeaderEvent::ToggleLock,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert!(engine.read(app).track(1).expect("V1").is_locked());
|
||||
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 1,
|
||||
toggle: TrackHeaderEvent::ToggleVisibility,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
let v1 = engine.read(app).track(1).expect("V1");
|
||||
assert!(!v1.is_visible(), "the visibility toggle hides the track");
|
||||
|
||||
// A1 (display index 2) mutes.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 2,
|
||||
toggle: TrackHeaderEvent::ToggleMute,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
assert!(engine.read(app).track(2).expect("A1").is_muted());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn locked_tracks_reject_clip_edits(cx: &mut TestAppContext) {
|
||||
use gpui::timeline::TrackHeaderEvent;
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
|
||||
// Lock V1 (the B-roll track, display index 1).
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested {
|
||||
track: 1,
|
||||
toggle: TrackHeaderEvent::ToggleLock,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
// Trim, move and delete are all refused.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::ClipTrimRequested {
|
||||
clip: ClipId(12),
|
||||
edge: TrimEdge::Start,
|
||||
new_frame: Frame(280),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::ClipMoveRequested {
|
||||
clip: ClipId(12),
|
||||
new_track: 0,
|
||||
new_start: Frame(300),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
engine.delete_clip(ClipId(12), false, cx);
|
||||
});
|
||||
let v1 = engine.read(app).track(1).expect("V1");
|
||||
let b_roll = v1
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.id() == ClipId(12))
|
||||
.expect("B-roll survives the rejected delete");
|
||||
assert_eq!(
|
||||
b_roll.range(),
|
||||
FrameRange::new(Frame(240), Frame(600)),
|
||||
"trim and move were rejected"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn add_effect_appends_a_named_card(cx: &mut TestAppContext) {
|
||||
cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
let effects = engine.read(app).addable_effects();
|
||||
assert!(!effects.is_empty(), "the demo list is the factory table");
|
||||
let (type_id, name) = effects[0].clone();
|
||||
let before = engine.read(app).effects().len();
|
||||
engine.update(app, |engine, cx| {
|
||||
engine
|
||||
.add_effect(usize::MAX, &type_id, cx)
|
||||
.expect("add the effect");
|
||||
});
|
||||
let stack = engine.read(app).effects();
|
||||
assert_eq!(stack.len(), before + 1);
|
||||
assert_eq!(
|
||||
stack.last().map(|e| e.title().to_string()).as_deref(),
|
||||
Some(name.as_str()),
|
||||
"the card was appended at the chain end"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn mock_split_at_playhead_and_ripple_delete(cx: &mut TestAppContext) { cx.update(|app| {
|
||||
let engine = demo_engine(app);
|
||||
|
||||
// Park the program playhead inside 开场 (0–240) and split there.
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.request_frame(Monitor::Program, Frame(120), cx);
|
||||
|
||||
+105
-4
@@ -71,7 +71,7 @@ use gpui::effect_stack::{
|
||||
use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent};
|
||||
use gpui::timeline::{
|
||||
ClipData, ClipId, Frame, FrameRange, FrameRate, Marker, TimelineDataSource, TimelineEvent,
|
||||
TrackData, TrackKind, TrimEdge,
|
||||
TrackData, TrackHeaderEvent, TrackKind, TrimEdge,
|
||||
};
|
||||
use gpui::{prelude::*, px, App, Context, Entity, Hsla, Pixels, RenderImage, SharedString};
|
||||
use gpui_widgets::audio_meter::AudioMeterDataSource;
|
||||
@@ -1134,14 +1134,19 @@ impl RealEngine {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The muted flag doubles as the video/subtitle visibility toggle
|
||||
// (Olive parity: the eye button flips `muted`).
|
||||
let (muted, locked) = graphops::track_behavior(graph, track)
|
||||
.map(|t| (t.muted, t.locked))
|
||||
.unwrap_or((false, false));
|
||||
RealTrack {
|
||||
kind: track_kind_of(kind),
|
||||
name: name.into(),
|
||||
height,
|
||||
locked: false,
|
||||
muted: false,
|
||||
locked,
|
||||
muted,
|
||||
solo: false,
|
||||
visible: true,
|
||||
visible: !muted,
|
||||
clips,
|
||||
track,
|
||||
track_index,
|
||||
@@ -1197,6 +1202,19 @@ impl RealEngine {
|
||||
.then_some(block)
|
||||
}
|
||||
|
||||
/// Whether the track hosting clip `block` is locked (locked tracks
|
||||
/// reject every clip edit: trim, move, split, delete).
|
||||
fn clip_track_locked(&self, block: NodeId) -> bool {
|
||||
let Some(project) = self.project_ref() else {
|
||||
return false;
|
||||
};
|
||||
let guard = graphops::lock(project);
|
||||
graphops::clip_track(&guard.graph, block)
|
||||
.and_then(|track| graphops::track_behavior(&guard.graph, track))
|
||||
.map(|t| t.locked)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The selected clip's block node, or `None` when no single clip is
|
||||
/// selected.
|
||||
fn selected_clip_node(&self) -> Option<NodeId> {
|
||||
@@ -1750,6 +1768,9 @@ impl AppEngine for RealEngine {
|
||||
let Some(block) = self.clip_block(*clip) else {
|
||||
return;
|
||||
};
|
||||
if self.clip_track_locked(block) {
|
||||
return;
|
||||
}
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -1786,6 +1807,9 @@ impl AppEngine for RealEngine {
|
||||
let Some(block) = self.clip_block(*clip) else {
|
||||
return;
|
||||
};
|
||||
if self.clip_track_locked(block) {
|
||||
return;
|
||||
}
|
||||
let Some(project) = self.project.clone() else {
|
||||
return;
|
||||
};
|
||||
@@ -1818,6 +1842,30 @@ impl AppEngine for RealEngine {
|
||||
| TimelineEvent::TrackSelected { .. }
|
||||
| TimelineEvent::TransitionChanged { .. }
|
||||
| TimelineEvent::ZoomChanged(_) => {}
|
||||
TimelineEvent::TrackToggleRequested { track, toggle } => {
|
||||
// The header toggles map onto the undoable track flag
|
||||
// setters. The muted flag doubles as the video/subtitle
|
||||
// visibility toggle (Olive parity); the model has no solo
|
||||
// flag yet, so solo requests are inert.
|
||||
let (Some(t), Some(project)) =
|
||||
(self.tracks.get(*track), self.project.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let result = match toggle {
|
||||
TrackHeaderEvent::ToggleLock => {
|
||||
graphops::set_track_locked(&project, t.track, !t.locked)
|
||||
}
|
||||
TrackHeaderEvent::ToggleMute => {
|
||||
graphops::set_track_muted(&project, t.track, !t.muted)
|
||||
}
|
||||
TrackHeaderEvent::ToggleVisibility => {
|
||||
graphops::set_track_muted(&project, t.track, t.visible)
|
||||
}
|
||||
TrackHeaderEvent::ToggleSolo => Ok(()),
|
||||
};
|
||||
self.apply_edit(result, "toggle track flag", cx);
|
||||
}
|
||||
TimelineEvent::WorkAreaPreview { start, end } => {
|
||||
self.set_workarea_preview(*start, *end, cx);
|
||||
}
|
||||
@@ -1836,6 +1884,9 @@ impl AppEngine for RealEngine {
|
||||
let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else {
|
||||
return;
|
||||
};
|
||||
if self.clip_track_locked(block) {
|
||||
return;
|
||||
}
|
||||
let result = graphops::split_clip(&project, block, time.0);
|
||||
self.apply_edit(result, "split clip", cx);
|
||||
}
|
||||
@@ -1848,6 +1899,7 @@ impl AppEngine for RealEngine {
|
||||
let targets: Vec<NodeId> = self
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|track| !track.locked)
|
||||
.flat_map(|track| {
|
||||
track.clips.iter().filter_map(|clip| {
|
||||
if clip.range.start.0 < frame.0 && frame.0 < clip.range.end.0 {
|
||||
@@ -1970,6 +2022,9 @@ impl AppEngine for RealEngine {
|
||||
let (Some(block), Some(project)) = (self.clip_block(clip), self.project.clone()) else {
|
||||
return;
|
||||
};
|
||||
if self.clip_track_locked(block) {
|
||||
return;
|
||||
}
|
||||
let result = if ripple {
|
||||
graphops::ripple_delete_clip(&project, block)
|
||||
} else {
|
||||
@@ -3028,6 +3083,52 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// Track header toggles through the app seam: a `TrackToggleRequested`
|
||||
/// event lands as ONE undoable engine command, the timeline snapshot
|
||||
/// reflects the new flag, and undo restores it. Visibility maps onto the
|
||||
/// track's muted flag (Olive parity).
|
||||
#[gpui::test]
|
||||
async fn real_engine_track_toggles_are_undoable(cx: &mut gpui::TestAppContext) {
|
||||
use gpui::timeline::TrackHeaderEvent;
|
||||
let _media = media_lock();
|
||||
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Video, cx)));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Audio, cx)));
|
||||
|
||||
// Display order: video tracks first (index 0), then audio (index 1).
|
||||
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()));
|
||||
assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
|
||||
|
||||
let toggle = |cx: &mut gpui::TestAppContext, track: usize, t: TrackHeaderEvent| {
|
||||
cx.update(|app| {
|
||||
engine.update(app, |engine, cx| {
|
||||
engine.apply_timeline_event(
|
||||
&TimelineEvent::TrackToggleRequested { track, toggle: t },
|
||||
cx,
|
||||
);
|
||||
})
|
||||
});
|
||||
};
|
||||
toggle(cx, 0, TrackHeaderEvent::ToggleVisibility);
|
||||
assert!(
|
||||
!cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()),
|
||||
"the visibility toggle hides the video track"
|
||||
);
|
||||
toggle(cx, 0, TrackHeaderEvent::ToggleLock);
|
||||
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_locked()));
|
||||
toggle(cx, 1, TrackHeaderEvent::ToggleMute);
|
||||
assert!(cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
|
||||
|
||||
// Three toggle commands, three undos.
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
|
||||
assert!(!cx.read(|app| engine.read(app).track(1).expect("A1").is_muted()));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
|
||||
assert!(!cx.read(|app| engine.read(app).track(0).expect("V1").is_locked()));
|
||||
cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx)));
|
||||
assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible()));
|
||||
}
|
||||
|
||||
/// M12 P2 acceptance: a real project with a sequence + footage clip
|
||||
/// builds a NON-EMPTY node graph with the wires the node editor shows:
|
||||
/// the footage feeds the clip's `tex_in` (a real edge), and every clip
|
||||
|
||||
+54
-1
@@ -74,7 +74,8 @@ fn clip_media(g: &oaknode::graph::Graph, block_id: NodeId) -> Option<String> {
|
||||
|
||||
/// The video montage at sequence time `time`: every clip covering `time`
|
||||
/// on video tracks, ordered bottom-to-top (track index 0 is topmost, so
|
||||
/// it is composited last).
|
||||
/// it is composited last). Hidden tracks (the muted flag doubles as the
|
||||
/// video visibility toggle, Olive parity) contribute nothing.
|
||||
pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<MontageClip> {
|
||||
let g = lock(p);
|
||||
let mut clips = Vec::new();
|
||||
@@ -92,6 +93,9 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
|
||||
let Some(track) = track_behavior(&g.graph, track_id) else {
|
||||
continue;
|
||||
};
|
||||
if track.muted {
|
||||
continue;
|
||||
}
|
||||
for &block_id in &track.blocks {
|
||||
let Some(clip) = clip_behavior(&g.graph, block_id) else {
|
||||
continue;
|
||||
@@ -120,6 +124,7 @@ pub fn video_montage(p: &ProjectRef, seq: NodeId, time: Rational) -> Vec<Montage
|
||||
|
||||
/// The audio montage over `range`: every audio clip overlapping the
|
||||
/// range, media times resolved from the clip ranges, audio stream 1.
|
||||
/// Muted tracks are silenced (skipped entirely).
|
||||
pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<MontageClip> {
|
||||
let g = lock(p);
|
||||
let mut clips = Vec::new();
|
||||
@@ -137,6 +142,9 @@ pub fn audio_montage(p: &ProjectRef, seq: NodeId, range: TimeRange) -> Vec<Monta
|
||||
let Some(track) = track_behavior(&g.graph, track_id) else {
|
||||
continue;
|
||||
};
|
||||
if track.muted {
|
||||
continue;
|
||||
}
|
||||
for &block_id in &track.blocks {
|
||||
let Some(clip) = clip_behavior(&g.graph, block_id) else {
|
||||
continue;
|
||||
@@ -532,4 +540,49 @@ mod tests {
|
||||
oakundo::global::clear().unwrap();
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
|
||||
/// Hidden video tracks and muted audio tracks (both are the track's
|
||||
/// `muted` flag) contribute nothing to the montages; undoing the flag
|
||||
/// set restores them.
|
||||
#[test]
|
||||
fn montages_skip_muted_tracks() {
|
||||
let _media = media_lock();
|
||||
let media = std::env::temp_dir().join(format!("oakapp_montage_m_{}.mp4", std::process::id()));
|
||||
oakcodec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("generate test media");
|
||||
|
||||
let (project, seq, footage) = project_with_clip(&media);
|
||||
graphops::add_track(&project, seq, TrackType::Audio).expect("add an audio track");
|
||||
graphops::place_footage_clip(&project, seq, footage, TrackType::Audio, 0, 0, 10, 0)
|
||||
.expect("place the audio clip");
|
||||
let (video_track, audio_track, tb) = {
|
||||
let g = lock(&project);
|
||||
(
|
||||
graphops::track_ids(&g.graph, seq, TrackType::Video)[0],
|
||||
graphops::track_ids(&g.graph, seq, TrackType::Audio)[0],
|
||||
graphops::sequence_time_base(&g.graph, seq).unwrap(),
|
||||
)
|
||||
};
|
||||
let at = |frame: i64| graphops::ts_to_rational(frame, tb);
|
||||
let range = TimeRange::new(at(0), at(5));
|
||||
assert_eq!(video_montage(&project, seq, at(0)).len(), 1);
|
||||
assert_eq!(audio_montage(&project, seq, range).len(), 1);
|
||||
|
||||
graphops::set_track_muted(&project, video_track, true).expect("hide the video track");
|
||||
graphops::set_track_muted(&project, audio_track, true).expect("mute the audio track");
|
||||
assert!(
|
||||
video_montage(&project, seq, at(0)).is_empty(),
|
||||
"a hidden video track contributes nothing"
|
||||
);
|
||||
assert!(
|
||||
audio_montage(&project, seq, range).is_empty(),
|
||||
"a muted audio track contributes nothing"
|
||||
);
|
||||
|
||||
oakundo::global::undo().unwrap();
|
||||
oakundo::global::undo().unwrap();
|
||||
assert_eq!(video_montage(&project, seq, at(0)).len(), 1, "undo restores the video clip");
|
||||
assert_eq!(audio_montage(&project, seq, range).len(), 1, "undo restores the audio clip");
|
||||
oakundo::global::clear().unwrap();
|
||||
let _ = std::fs::remove_file(&media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The effect library panel (效果库): every effect type the engine can add
|
||||
//! to a clip's chain, as a flat list. Double-clicking an entry appends the
|
||||
//! effect to the selected clip's effect chain (the undoable
|
||||
//! [`AppEngine::add_effect`]; the insertion index is clamped to the chain
|
||||
//! end by the backend).
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, Render,
|
||||
SharedString, Window,
|
||||
};
|
||||
|
||||
use crate::i18n;
|
||||
use crate::oakui::AppEngine;
|
||||
use crate::panels::ids::EFFECT_LIBRARY;
|
||||
|
||||
/// The effect library panel.
|
||||
pub struct EffectLibraryPanel<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EffectLibraryPanel<E> {
|
||||
/// Builds the panel over `engine`'s addable-effect table.
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
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 mut list = div()
|
||||
.id("effect-library-list")
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.overflow_y_scroll();
|
||||
for (type_id, name) in effects {
|
||||
let engine = self.engine.clone();
|
||||
let row_id = type_id.clone();
|
||||
list = list.child(
|
||||
div()
|
||||
.id(SharedString::from(format!("effect-library-{type_id}")))
|
||||
.debug_selector(move || format!("effect-library-row-{row_id}").into())
|
||||
.cursor_pointer()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.rounded_sm()
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.hover(|style| style.bg(colors.selected))
|
||||
.child(name)
|
||||
.on_click(move |event: &ClickEvent, _window, cx| {
|
||||
// Double-click appends the effect to the selected
|
||||
// clip's chain; the backend clamps the index to the
|
||||
// chain end.
|
||||
if event.click_count() == 2 {
|
||||
let type_id = type_id.clone();
|
||||
engine.update(cx, |engine, cx| {
|
||||
if let Err(err) = engine.add_effect(usize::MAX, &type_id, cx) {
|
||||
println!("[effect library] add effect failed: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(list)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.px_2()
|
||||
.py_1()
|
||||
.border_t_1()
|
||||
.border_color(colors.border)
|
||||
.text_xs()
|
||||
.text_color(colors.disabled)
|
||||
.child(i18n::tr("effect_library.hint")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for EffectLibraryPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for EffectLibraryPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
EFFECT_LIBRARY
|
||||
}
|
||||
|
||||
fn title(&self, _cx: &App) -> SharedString {
|
||||
i18n::tr("panel.effect_library").into()
|
||||
}
|
||||
|
||||
fn tab_content(&self, _cx: &App) -> AnyElement {
|
||||
div()
|
||||
.child(i18n::tr("panel.effect_library"))
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::oakui::MockEngine;
|
||||
use gpui::{px, size, TestAppContext, VisualTestContext};
|
||||
|
||||
/// The panel renders one row per addable effect of the engine (the
|
||||
/// mock exposes the real factory's video-effect table).
|
||||
#[gpui::test]
|
||||
async fn lists_every_addable_effect(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| cx.init_colors());
|
||||
let window = cx.open_window(size(px(400.0), px(600.0)), |window, cx| {
|
||||
let engine = cx.new(|cx| MockEngine::demo(cx));
|
||||
EffectLibraryPanel::new(engine, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
|
||||
|
||||
let expected = crate::oakui::effectchain::addable_effects();
|
||||
assert!(!expected.is_empty());
|
||||
for (type_id, _) in &expected {
|
||||
// `debug_bounds` takes a &'static selector; the per-row selector is
|
||||
// dynamic, so the test leaks it (process-lifetime, test-only).
|
||||
let selector: &'static str =
|
||||
Box::leak(format!("effect-library-row-{type_id}").into_boxed_str());
|
||||
assert!(
|
||||
cx.debug_bounds(selector).is_some(),
|
||||
"effect row {type_id} rendered"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
//! to "the engine", and never mutate engine state directly — every edit is a
|
||||
//! widget request event that the panel forwards through the gateway.
|
||||
|
||||
pub mod effect_library;
|
||||
pub mod history;
|
||||
pub mod inspector;
|
||||
pub mod node_editor;
|
||||
@@ -51,6 +52,8 @@ pub mod ids {
|
||||
pub const HISTORY: PanelId = PanelId::new(6);
|
||||
/// The timeline (时间线).
|
||||
pub const TIMELINE: PanelId = PanelId::new(7);
|
||||
/// The effect library (效果库).
|
||||
pub const EFFECT_LIBRARY: PanelId = PanelId::new(8);
|
||||
}
|
||||
|
||||
/// A small info chip used in viewer headers and the status bar: muted
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
//! The material bin panel (项目): the `ProjectExplorer` widget over the
|
||||
//! engine's project data.
|
||||
|
||||
use gpui::colors::DefaultColors;
|
||||
use gpui::dock::{DockPanel, PanelEvent};
|
||||
use gpui::{
|
||||
div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window,
|
||||
div, px, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString,
|
||||
Window,
|
||||
};
|
||||
use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent};
|
||||
|
||||
@@ -71,8 +73,35 @@ impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.explorer.clone())
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
// The panel title row, per the design's panel headers: the
|
||||
// widget below only shows the bare tree/icon view toggles, so
|
||||
// without this row the panel reads as anonymous.
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(28.0))
|
||||
.flex_shrink_0()
|
||||
.px_2()
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.container)
|
||||
.text_sm()
|
||||
.text_color(colors.text)
|
||||
.child(crate::i18n::tr("panel.project")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.child(self.explorer.clone()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user