mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 13:37:37 -06:00
The menu bar's own menus (Edit has 30 entries) had no scroll area, only their submenus did, so on a short screen Edit ran past the bottom of the window. The font menu was capped at 420 points and never moved up, the Preview panel's and the viewer's popup lists neither scrolled nor moved, and the Timeline's Blending Mode and Expression Language submenus didn't scroll. egui's scroll bars also only appeared while the pointer was over the list, so a list could only be scrolled with a wheel or touchpad. - widgets::popup_list: the one popup list behind popup_menu, the font menu, the Preview panel's dropdowns and the viewer's popups: as tall as the window allows, scrolling beyond, moved up to fit the window. - widgets::menu_scroll: the same for menus and submenus (the menu bar's, Blending Mode, the Expression Language menu, the viewer's Select menu). - Scroll bars always show (solid scroll style), so they can be dragged. - A first window that doesn't fit the screen is maximized: the 1680 x 1020 default on a smaller screen is only shrunk to the monitor's size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the screen even when they fitted the window. Test: ui_scrolling a_menu_taller_than_the_window_scrolls (Edit in a 520-point window scrolls its last entry into view and it takes the click); overflows_screen unit test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
20 KiB
Rust
440 lines
20 KiB
Rust
//! Smaller panels: Preview, Audio, History, Markers and placeholders.
|
|
|
|
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
|
use serde_json::json;
|
|
|
|
use crate::dock::PanelKind;
|
|
use crate::icons::Icon;
|
|
use crate::theme::Tokens;
|
|
use crate::{EffectcraftApp, widgets};
|
|
|
|
pub fn placeholder(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, p: PanelKind) {
|
|
let t = app.tokens;
|
|
ui.painter().text(rect.center(), Align2::CENTER_CENTER, format!("{} — coming soon", p.title()), Tokens::ui(12.0), t.text_faint);
|
|
}
|
|
|
|
/// Preview panel: transport controls, the shortcut whose options are shown, and that
|
|
/// shortcut's options (each Preview shortcut keeps its own; see `effectcraft_engine::preview`).
|
|
/// Every change runs `playback.settings.set`.
|
|
pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|
use effectcraft_engine::preview::{PlayFrom, PreviewRange, PreviewResolution, PreviewShortcut};
|
|
let t = app.tokens;
|
|
let ctx = ui.ctx().clone();
|
|
let p = ui.painter().with_clip_rect(rect);
|
|
// The panel scrolls when it is shorter than its controls (the default workspace shows just
|
|
// the transport row, as in After Effects).
|
|
let scroll_id = egui::Id::new("preview-scroll");
|
|
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
|
|
if ui.rect_contains_pointer(rect) {
|
|
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
|
|
}
|
|
let panel_clip = ui.clip_rect();
|
|
ui.set_clip_rect(rect.intersect(panel_clip));
|
|
let y = rect.min.y + 12.0 - scroll;
|
|
let bw = 30.0;
|
|
let total = bw * 5.0 + 16.0;
|
|
let mut x = rect.center().x - total / 2.0;
|
|
let buttons: [(Icon, &str, &str); 5] = [
|
|
(Icon::First, "time.start", "First Frame"),
|
|
(Icon::StepBack, "time.previousFrame", "Previous Frame"),
|
|
(Icon::Play, "playback.toggle", "Play/Stop"),
|
|
(Icon::StepFwd, "time.nextFrame", "Next Frame"),
|
|
(Icon::Last, "time.end", "Last Frame"),
|
|
];
|
|
let current = app.session.prefs.preview.current;
|
|
for (icon, cmd, tip) in buttons {
|
|
let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0));
|
|
let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon };
|
|
if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() {
|
|
// The play button plays with the options of the shortcut shown below.
|
|
let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) };
|
|
let _ = crate::menus::invoke(app, &ctx, cmd, params);
|
|
}
|
|
app.auto.add(&format!("preview.{}", tip.replace([' ', '/'], "")), r, tip);
|
|
x += bw + 4.0;
|
|
}
|
|
let o = app.session.prefs.preview.get(current).clone();
|
|
let mut changes: Vec<serde_json::Value> = vec![];
|
|
let x0 = rect.min.x + 12.0;
|
|
let xv = rect.min.x + 92.0;
|
|
let wv = (rect.max.x - xv - 12.0).clamp(80.0, 240.0);
|
|
let label = |yy: f32, k: &str| {
|
|
p.text(pos2(x0, yy), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim);
|
|
};
|
|
let mut yy = y + 46.0;
|
|
|
|
// Shortcut.
|
|
label(yy, "Shortcut");
|
|
let names: Vec<String> = PreviewShortcut::ALL.iter().map(|s| s.label().to_string()).collect();
|
|
if let Some(i) = dropdown_row(
|
|
app,
|
|
ui,
|
|
Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)),
|
|
"shortcut",
|
|
current.label(),
|
|
&names,
|
|
PreviewShortcut::ALL.iter().position(|s| *s == current),
|
|
) {
|
|
let s = PreviewShortcut::ALL[i];
|
|
changes.push(json!({"shortcut": s.id(), "current": s.id()}));
|
|
}
|
|
yy += 26.0;
|
|
|
|
// Include Video / Audio / Overlays / Layer Controls, Loop, Cache Before Playback.
|
|
let checks: [(&str, &str, bool); 6] = [
|
|
("includeVideo", "Video", o.include_video),
|
|
("includeAudio", "Audio", o.include_audio),
|
|
("includeOverlays", "Overlays", o.include_overlays),
|
|
("includeLayerControls", "Layer Controls", o.include_layer_controls),
|
|
("loop", "Loop", o.loop_),
|
|
("cacheBeforePlayback", "Cache Before Playback", o.cache_before_playback),
|
|
];
|
|
for (i, (key, text, on)) in checks.into_iter().enumerate() {
|
|
let col = if i < 4 { i % 2 } else { 0 };
|
|
let row = if i < 4 { i / 2 } else { i - 2 };
|
|
let cx = x0 + col as f32 * 110.0 - 2.0;
|
|
let cy = yy + row as f32 * 22.0;
|
|
if check_row(app, ui, &p, pos2(cx, cy), key, text, on) {
|
|
changes.push(json!({"shortcut": current.id(), key: !on}));
|
|
}
|
|
}
|
|
yy += 4.0 * 22.0 + 6.0;
|
|
|
|
// Range (and the pre/post-roll of Play Around Current Time).
|
|
label(yy, "Range");
|
|
let ranges: Vec<String> = PreviewRange::ALL.iter().map(|r| r.label().to_string()).collect();
|
|
let range_ids = ["workArea", "workAreaExtended", "entireDuration", "aroundCurrentTime"];
|
|
if let Some(i) = dropdown_row(
|
|
app,
|
|
ui,
|
|
Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)),
|
|
"range",
|
|
o.range.label(),
|
|
&ranges,
|
|
PreviewRange::ALL.iter().position(|r| *r == o.range),
|
|
) {
|
|
changes.push(json!({"shortcut": current.id(), "range": range_ids[i]}));
|
|
}
|
|
yy += 24.0;
|
|
if o.range == PreviewRange::AroundCurrentTime {
|
|
for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
|
|
let lx = xv + k as f32 * 100.0;
|
|
p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
|
|
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::new(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
|
|
app.auto.add(&format!("preview.{key}"), r, text);
|
|
if let Some(nv) = nv {
|
|
changes.push(json!({"shortcut": current.id(), key: nv}));
|
|
}
|
|
}
|
|
yy += 22.0;
|
|
}
|
|
|
|
// Play From.
|
|
label(yy, "Play From");
|
|
let froms: Vec<String> = PlayFrom::ALL.iter().map(|f| f.label().to_string()).collect();
|
|
if let Some(i) = dropdown_row(
|
|
app,
|
|
ui,
|
|
Rect::from_min_size(pos2(xv, yy - 10.0), vec2(wv, 20.0)),
|
|
"playFrom",
|
|
o.play_from.label(),
|
|
&froms,
|
|
PlayFrom::ALL.iter().position(|f| *f == o.play_from),
|
|
) {
|
|
changes.push(json!({"shortcut": current.id(), "playFrom": (["rangeStart", "currentTime"][i])}));
|
|
}
|
|
yy += 26.0;
|
|
|
|
// Frame Rate, Skip, Resolution.
|
|
let comp_fps = app.session.active_comp().map(|c| c.frame_rate.as_f64());
|
|
label(yy, "Frame Rate");
|
|
const RATES: [f64; 12] = [8.0, 10.0, 12.0, 12.5, 15.0, 23.976, 24.0, 25.0, 29.97, 30.0, 50.0, 60.0];
|
|
let auto = format!("({:.2}) Auto", comp_fps.unwrap_or(0.0));
|
|
let mut rates = vec![auto.clone()];
|
|
rates.extend(RATES.iter().map(|r| fmt_rate(*r)));
|
|
let rate_text = o.frame_rate.map(fmt_rate).unwrap_or(auto);
|
|
let rate_cur = match o.frame_rate {
|
|
None => Some(0),
|
|
Some(r) => RATES.iter().position(|x| (x - r).abs() < 1e-3).map(|i| i + 1),
|
|
};
|
|
if let Some(i) = dropdown_row(app, ui, Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)), "frameRate", &rate_text, &rates, rate_cur) {
|
|
let v = if i == 0 { json!("auto") } else { json!(RATES[i - 1]) };
|
|
changes.push(json!({"shortcut": current.id(), "frameRate": v}));
|
|
}
|
|
let sx = xv + 106.0;
|
|
p.text(pos2(sx, yy), Align2::LEFT_CENTER, "Skip", Tokens::ui(12.0), t.text_dim);
|
|
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::new("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
|
|
app.auto.add("preview.skip", r, "Skip");
|
|
if let Some(nv) = nv {
|
|
changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
|
|
}
|
|
yy += 24.0;
|
|
label(yy, "Resolution");
|
|
let ress: Vec<String> = PreviewResolution::ALL.iter().map(|r| r.label().to_string()).collect();
|
|
let res_ids = ["auto", "full", "half", "third", "quarter", "custom"];
|
|
if let Some(i) = dropdown_row(
|
|
app,
|
|
ui,
|
|
Rect::from_min_size(pos2(xv, yy - 10.0), vec2(96.0, 20.0)),
|
|
"resolution",
|
|
o.resolution.label(),
|
|
&ress,
|
|
PreviewResolution::ALL.iter().position(|r| *r == o.resolution),
|
|
) {
|
|
changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]}));
|
|
}
|
|
if o.resolution == PreviewResolution::Custom {
|
|
let (r, nv, _) =
|
|
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::new("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
|
|
app.auto.add("preview.customResolution", r, "Custom Resolution");
|
|
if let Some(nv) = nv {
|
|
changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
|
|
}
|
|
}
|
|
yy += 24.0;
|
|
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "fullScreen", "Full Screen", o.full_screen) {
|
|
changes.push(json!({"shortcut": current.id(), "fullScreen": !o.full_screen}));
|
|
}
|
|
yy += 24.0;
|
|
|
|
// What stopping does.
|
|
p.text(pos2(x0, yy), Align2::LEFT_CENTER, format!("On ({}) stop:", current.label()), Tokens::ui(11.5), t.text_dim);
|
|
yy += 20.0;
|
|
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "playCachedFrames", "If caching, play cached frames", o.play_cached_frames) {
|
|
changes.push(json!({"shortcut": current.id(), "playCachedFrames": !o.play_cached_frames}));
|
|
}
|
|
yy += 22.0;
|
|
if check_row(app, ui, &p, pos2(x0 - 2.0, yy), "moveTimeToPreviewTime", "Move time to preview time", o.move_time_to_preview_time) {
|
|
changes.push(json!({"shortcut": current.id(), "moveTimeToPreviewTime": !o.move_time_to_preview_time}));
|
|
}
|
|
yy += 24.0;
|
|
for c in changes {
|
|
if let Err(e) = app.session.execute("playback.settings.set", c) {
|
|
app.ui.status = e.to_string();
|
|
}
|
|
}
|
|
|
|
// Cache bar over the preview range.
|
|
if let (Some(c), Some(cid)) = (app.session.active_comp_arc(), app.session.active_comp_id()) {
|
|
let pl = match app.playback.plan.filter(|_| app.playback.playing) {
|
|
Some(pl) => pl,
|
|
None => effectcraft_engine::preview::plan(&o, &c, app.session.time()),
|
|
};
|
|
let cached_set = app.frames.cached_frames(&app.shown_series(cid));
|
|
let total = pl.frames().count();
|
|
let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count();
|
|
let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0));
|
|
p.rect_filled(bar, 3.0, t.field_bg);
|
|
let f = (cached as f32 / total.max(1) as f32).min(1.0);
|
|
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f, bar.height())), 3.0, t.cache_green);
|
|
let status = if app.playback.playing && app.playback.caching { " — caching before playback" } else { "" };
|
|
p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint);
|
|
}
|
|
ui.set_clip_rect(panel_clip);
|
|
let content = yy + 30.0 - (rect.min.y - scroll);
|
|
let max = (content - rect.height()).max(0.0);
|
|
scroll = scroll.min(max);
|
|
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
|
|
if max > 0.0 {
|
|
// A thin scroll indicator on the right edge.
|
|
let h = (rect.height() * rect.height() / content).max(12.0);
|
|
let top = rect.min.y + (rect.height() - h) * (scroll / max);
|
|
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6));
|
|
app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll");
|
|
}
|
|
}
|
|
|
|
fn fmt_rate(r: f64) -> String {
|
|
if (r - r.round()).abs() < 1e-6 { format!("{r:.0}") } else { format!("{r}") }
|
|
}
|
|
|
|
/// A checkbox with its label; returns true when clicked.
|
|
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
|
|
let t = app.tokens;
|
|
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
|
|
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked();
|
|
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
|
app.auto.add(&format!("preview.{key}"), r, text);
|
|
clicked
|
|
}
|
|
|
|
/// A dropdown with its popup list (automation ids `preview.<key>` and `preview.<key>.<i>`);
|
|
/// returns the chosen index.
|
|
fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option<usize>) -> Option<usize> {
|
|
let t = app.tokens;
|
|
let pid = egui::Id::new(("pv-pop", key));
|
|
if widgets::dropdown(ui, r, text, &t, pid.with("btn")).clicked() {
|
|
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
|
|
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
|
|
}
|
|
app.auto.add(&format!("preview.{key}"), r, text);
|
|
if !widgets::popup_is_open(ui, pid) {
|
|
return None;
|
|
}
|
|
widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, items.len(), 0, |ui| {
|
|
ui.set_min_width(r.width().max(140.0));
|
|
let mut chosen = None;
|
|
for (i, label) in items.iter().enumerate() {
|
|
let resp = ui.selectable_label(current == Some(i), label.as_str());
|
|
app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label);
|
|
if resp.clicked() {
|
|
chosen = Some(i);
|
|
}
|
|
}
|
|
chosen
|
|
})
|
|
}
|
|
|
|
/// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the
|
|
/// audio preview; the selected layer's Audio Levels on the right.
|
|
pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|
use crate::audio::METER_FLOOR;
|
|
let t = app.tokens;
|
|
let p = ui.painter().with_clip_rect(rect);
|
|
let top = rect.min.y + 26.0;
|
|
let bottom = rect.max.y - 22.0;
|
|
if bottom - top < 40.0 {
|
|
return;
|
|
}
|
|
let y_of = |db: f32| top + (bottom - top) * (db.clamp(METER_FLOOR, 0.0) / METER_FLOOR);
|
|
let m = app.meter;
|
|
let green = Color32::from_rgb(0x3c, 0xc8, 0x5a);
|
|
let yellow = Color32::from_rgb(0xe6, 0xc8, 0x3c);
|
|
let red = Color32::from_rgb(0xe6, 0x46, 0x3c);
|
|
let seg_col = |db: f32| {
|
|
if db > -3.0 {
|
|
red
|
|
} else if db > -12.0 {
|
|
yellow
|
|
} else {
|
|
green
|
|
}
|
|
};
|
|
for c in 0..2 {
|
|
let x = rect.min.x + 18.0 + c as f32 * 16.0;
|
|
let bar = Rect::from_min_max(pos2(x, top), pos2(x + 12.0, bottom));
|
|
p.rect_filled(bar, 1.0, t.field_bg);
|
|
// Lit segments, 1.5 dB each, coloured by their level.
|
|
let lvl = m.level_db[c];
|
|
let mut db = METER_FLOOR;
|
|
while db < lvl.min(0.0) {
|
|
let hi = (db + 1.5).min(lvl);
|
|
p.rect_filled(Rect::from_min_max(pos2(bar.min.x + 1.0, y_of(hi)), pos2(bar.max.x - 1.0, y_of(db) - 0.5)), 0.0, seg_col(db));
|
|
db += 1.5;
|
|
}
|
|
if m.peak_db[c] > METER_FLOOR {
|
|
let py = y_of(m.peak_db[c]);
|
|
p.line_segment([pos2(bar.min.x, py), pos2(bar.max.x, py)], Stroke::new(2.0, seg_col(m.peak_db[c])));
|
|
}
|
|
// Clip indicator (click to reset).
|
|
let clip = Rect::from_min_max(pos2(bar.min.x, top - 14.0), pos2(bar.max.x, top - 4.0));
|
|
p.rect_filled(clip, 1.0, if m.clipped[c] { red } else { t.field_bg });
|
|
let id = if c == 0 { "audio.clipLeft" } else { "audio.clipRight" };
|
|
if ui.interact(clip, egui::Id::new(id), Sense::click()).clicked() {
|
|
app.meter.clipped[c] = false;
|
|
}
|
|
app.auto.add(id, clip, if c == 0 { "Left clip indicator" } else { "Right clip indicator" });
|
|
app.auto.add(if c == 0 { "audio.meterLeft" } else { "audio.meterRight" }, bar, &format!("{:.1} dB", m.level_db[c]));
|
|
p.text(pos2(bar.center().x, bottom + 9.0), Align2::CENTER_CENTER, if c == 0 { "L" } else { "R" }, Tokens::ui(10.0), t.text_dim);
|
|
}
|
|
let sx = rect.min.x + 54.0;
|
|
for db in [0, -6, -12, -18, -24, -30, -36, -42, -48] {
|
|
let y = y_of(db as f32);
|
|
p.line_segment([pos2(sx - 4.0, y), pos2(sx - 1.0, y)], Stroke::new(1.0, t.text_faint));
|
|
p.text(pos2(sx + 2.0, y), Align2::LEFT_CENTER, format!("{db:.1}"), Tokens::ui(10.0), t.text_faint);
|
|
}
|
|
p.text(pos2(sx + 30.0, top - 9.0), Align2::LEFT_CENTER, "dB", Tokens::ui(10.0), t.text_dim);
|
|
// Selected layer's Audio Levels.
|
|
let lx = rect.min.x + 120.0;
|
|
if rect.max.x - lx < 80.0 {
|
|
return;
|
|
}
|
|
let sel = app.session.state.selected_layers.first().copied();
|
|
let levels = sel.and_then(|id| {
|
|
let c = app.session.active_comp()?;
|
|
let l = c.layer(id)?;
|
|
let pr = l.props.sub("audio")?.get("levels")?;
|
|
Some((l.name.clone(), pr.value.as_vec2()))
|
|
});
|
|
match levels {
|
|
Some((name, [a, b])) => {
|
|
p.text(pos2(lx, top - 9.0), Align2::LEFT_CENTER, &name, Tokens::ui(11.0), t.text_dim);
|
|
for (i, (k, v)) in [("Left", a), ("Right", b)].into_iter().enumerate() {
|
|
let y = top + 12.0 + i as f32 * 20.0;
|
|
p.text(pos2(lx, y), Align2::LEFT_CENTER, k, Tokens::ui(12.0), t.text_dim);
|
|
p.text(pos2(lx + 44.0, y), Align2::LEFT_CENTER, format!("{v:+.1} dB"), Tokens::ui(12.0), t.hot_text);
|
|
}
|
|
}
|
|
None => {
|
|
p.text(pos2(lx, top + 12.0), Align2::LEFT_CENTER, "No audio layer selected", Tokens::ui(11.0), t.text_faint);
|
|
}
|
|
}
|
|
if app.meter.active() || app.audio.is_some() {
|
|
ui.ctx().request_repaint();
|
|
}
|
|
}
|
|
|
|
pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|
let t = app.tokens;
|
|
let p = ui.painter().with_clip_rect(rect);
|
|
// The branching history (EffectCraft's History panel): every state, branches indented under
|
|
// the state they grew from; click any state to go there (`edit.history.goto`).
|
|
let nodes = app.session.history_tree();
|
|
let row_h = 22.0;
|
|
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
|
|
// Keep the current state in view.
|
|
let visible = ((rect.height() - 12.0) / row_h).max(1.0) as usize;
|
|
let first = (cur + 2).saturating_sub(visible).min(nodes.len().saturating_sub(visible));
|
|
let mut y = rect.min.y + 6.0;
|
|
let mut jump: Option<usize> = None;
|
|
for n in nodes.iter().skip(first).take(visible + 1) {
|
|
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h));
|
|
y += row_h;
|
|
let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click());
|
|
if n.current {
|
|
p.rect_filled(r, 0.0, t.row_selected);
|
|
} else if resp.hovered() {
|
|
p.rect_filled(r, 0.0, t.hover);
|
|
}
|
|
let x = r.min.x + 12.0 + n.depth as f32 * 14.0;
|
|
if n.depth > 0 {
|
|
// Branch marker.
|
|
let c = pos2(x - 7.0, r.center().y);
|
|
p.line_segment([pos2(c.x, r.min.y), c], egui::Stroke::new(1.0, t.text_faint));
|
|
p.line_segment([c, pos2(c.x + 5.0, c.y)], egui::Stroke::new(1.0, t.text_faint));
|
|
}
|
|
let col = if !n.current && (n.future || !n.line) { t.text_faint } else { t.text };
|
|
p.text(pos2(x, r.center().y), Align2::LEFT_CENTER, &n.label, Tokens::ui(12.0), col);
|
|
app.auto.add(&format!("history.state.{}", n.index), r, &n.label);
|
|
if resp.clicked() {
|
|
jump = Some(n.index);
|
|
}
|
|
}
|
|
if let Some(i) = jump {
|
|
let ctx = ui.ctx().clone();
|
|
if let Err(e) = crate::menus::invoke(app, &ctx, "edit.history.goto", serde_json::json!({"index": i})) {
|
|
app.ui.status = e;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|
let t = app.tokens;
|
|
let p = ui.painter().with_clip_rect(rect);
|
|
let Some(c) = app.session.active_comp_arc() else { return };
|
|
let mut y = rect.min.y + 8.0;
|
|
if c.markers.is_empty() {
|
|
p.text(rect.center(), Align2::CENTER_CENTER, "No composition markers", Tokens::ui(12.0), t.text_faint);
|
|
}
|
|
for m in &c.markers {
|
|
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 22.0));
|
|
y += 22.0;
|
|
let tc = crate::panels::timecode(&app.session, &c, m.time);
|
|
p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(11.5), t.timecode);
|
|
p.text(pos2(r.min.x + 120.0, r.center().y), Align2::LEFT_CENTER, &m.comment, Tokens::ui(12.0), t.text);
|
|
if ui.interact(r, egui::Id::new(("mk", m.time.0)), Sense::click()).clicked() {
|
|
app.session.set_time(m.time);
|
|
}
|
|
}
|
|
}
|