Timeline: the work area bar, with Set Work Area Bar In and Out Point (#3087)

Sequences already store a work area, and Export > Range lists Work
Area, but no command set one, so the Work Area range was grayed out.
The Timeline panel menu's Work Area Bar now shows the bar over the
whole sequence or hides it. Alt+[ and Alt+] in the Timeline move its
start and end to the playhead. On the ruler the bar's ends and middle
can be dragged, a double-click fits it to the part of the sequence the
ruler shows, and its tooltip shows the start, end and duration.

The new commands are sequence.workAreaBar, sequence.setWorkAreaIn,
sequence.setWorkAreaOut and sequence.setWorkArea. The two keys move
from the skipped table in docs/keyboard.md to the keyboard-only
commands.
This commit is contained in:
.CooCoo
2026-10-24 10:24:34 -04:00
committed by GitHub
parent cb6dee282d
commit 42aefd3a1a
15 changed files with 502 additions and 5 deletions
+75
View File
@@ -2295,6 +2295,55 @@ fn build() -> Vec<CommandSpec> {
crate::previews::delete(s, true)
}),
query!("sequence.renderBar", "Render Bar", "{}", |s, _| crate::previews::bar_json(s)),
// ================= Work area bar =================
cmd!("sequence.workAreaBar", "Work Area Bar", [], None, r#"{"show":bool?}"#, has_seq, |s, p| {
let shown = s.active_sequence().ok_or(EngineError::NoSequence)?.work_area.is_some();
let show = bool_p(p, "show").unwrap_or(!shown);
if show == shown {
return Ok(json!({"show": show}));
}
let fd = s.sequence_rate().frame_duration();
s.edit_sequence("Work Area Bar", |q, _, _| {
// shown again, the bar spans the whole sequence
q.work_area = show.then(|| TimeRange::from_bounds(Tick::ZERO, q.duration().max(fd)));
Ok(())
})?;
Ok(json!({"show": show}))
}),
cmd!(
"sequence.setWorkAreaIn",
"Set Work Area Bar In Point",
[],
None,
r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#,
has_seq,
|s, p| set_work_area_point(s, p, true)
),
cmd!(
"sequence.setWorkAreaOut",
"Set Work Area Bar Out Point",
[],
None,
r#"{"time":ticks?,"frame":i64?,"seconds":f64?,"timecode":str?}"#,
has_seq,
|s, p| set_work_area_point(s, p, false)
),
cmd!("sequence.setWorkArea", "Set Work Area", [], None, r#"{"start":ticks,"end":ticks}"#, has_seq, |s, p| {
const CMD: &str = "sequence.setWorkArea";
let tick = |key: &str| p.get(key).and_then(Value::as_i64).map(Tick).ok_or_else(|| bad(CMD, format!("need `{key}` (ticks)")));
let (start, end) = (tick("start")?, tick("end")?);
if start < Tick::ZERO {
return Err(bad(CMD, "`start` must not be negative"));
}
if end <= start {
return Err(bad(CMD, "`end` must be after `start`"));
}
s.edit_sequence("Set Work Area", |q, _, _| {
q.work_area = Some(TimeRange::from_bounds(start, end));
Ok(())
})?;
Ok(json!({"start": start.0, "end": end.0}))
}),
cmd!("sequence.matchFrame", "Match Frame", ["Sequence"], Some("F"), "{}", has_seq, |s, _| {
let t = s.playhead();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
@@ -3640,6 +3689,32 @@ fn mark(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
Ok(Value::Null)
}
/// Set Work Area Bar In / Out Point: the work area starts (`is_in`) or ends at `time`, else at the
/// playhead. An In point at or after the end moves the end to the end of the sequence, or to one
/// frame after the In point when the In point is at or past the end of the sequence; an Out point
/// at or before the start moves the start to 0. With no work area yet, the other end is the end
/// or the start of the sequence.
fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result<Value> {
let cmd = if is_in { "sequence.setWorkAreaIn" } else { "sequence.setWorkAreaOut" };
let t = sequence_time_p(s, p, "", cmd)?.unwrap_or(s.playhead());
let fd = s.sequence_rate().frame_duration();
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (start, end) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((Tick::ZERO, seq.duration()));
let (start, end) = if is_in {
(t, if end > t { end } else { seq.duration().max(t + fd) })
} else {
if t <= Tick::ZERO {
return Err(bad(cmd, "the Out point must be after the start of the sequence"));
}
(if start < t { start } else { Tick::ZERO }, t)
};
s.edit_sequence(if is_in { "Set Work Area Bar In Point" } else { "Set Work Area Bar Out Point" }, |q, _, _| {
q.work_area = Some(TimeRange::from_bounds(start, end));
Ok(())
})?;
Ok(json!({"start": start.0, "end": end.0}))
}
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
Some(match template {
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
+2
View File
@@ -1372,6 +1372,8 @@ mod trim_tests;
mod vfx_tests;
#[cfg(test)]
mod voiceover_tests;
#[cfg(test)]
mod work_area_tests;
#[cfg(test)]
mod source_placement_tests;
+2
View File
@@ -290,6 +290,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[
("timeline.decreaseAudioHeight", "Alt+-", "Timeline"),
("timeline.nextScreen", "PageDown", "Timeline"),
("timeline.prevScreen", "PageUp", "Timeline"),
("sequence.setWorkAreaIn", "Alt+[", "Timeline"),
("sequence.setWorkAreaOut", "Alt+]", "Timeline"),
("graphics.nudgeLeft", "Cmd+Left", "Program"),
("graphics.nudgeRight", "Cmd+Right", "Program"),
("graphics.nudgeUp", "Cmd+Up", "Program"),
+116
View File
@@ -0,0 +1,116 @@
//! The work area bar: the Timeline panel menu's Work Area Bar shows and hides it, Set Work Area Bar
//! In / Out Point (Alt+[ / Alt+] in the Timeline) move its ends to the playhead, `sequence.setWorkArea`
//! sets both ends (a drag of the bar), and Export ▸ Range ▸ Work Area exports it.
use filmcraft_time::{Tick, TimeRange};
use serde_json::json;
use crate::Session;
use crate::shortcuts::{Chord, Platform};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn area(s: &Session) -> Option<(Tick, Tick)> {
s.active_sequence().unwrap().work_area.map(|w| (w.start, w.end()))
}
#[test]
fn the_work_area_bar_spans_the_sequence_when_shown_and_undoes() {
let mut s = demo();
let end = s.active_sequence().unwrap().duration();
assert!(end > Tick::ZERO);
assert_eq!(area(&s), None, "the bar starts hidden");
assert!(s.execute("export.resolve", json!({"range": "workArea"})).is_err(), "no work area to export");
assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": true}));
assert_eq!(area(&s), Some((Tick::ZERO, end)));
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Work Area Bar"));
let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap();
let whole = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "entire"})).unwrap()["frames"].as_i64().unwrap();
assert_eq!(frames, whole, "the work area covers the whole sequence");
// asking for the state it is already in changes nothing
let steps = s.history.undo.len();
assert_eq!(s.execute("sequence.workAreaBar", json!({"show": true})).unwrap(), json!({"show": true}));
assert_eq!(s.history.undo.len(), steps);
assert_eq!(s.execute("sequence.workAreaBar", json!({})).unwrap(), json!({"show": false}));
assert_eq!(area(&s), None);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(area(&s), Some((Tick::ZERO, end)));
}
#[test]
fn set_work_area_in_and_out_points_follow_the_playhead() {
let mut s = demo();
let rate = s.sequence_rate();
let f = |n: i64| rate.tick_of(n);
let end = s.active_sequence().unwrap().duration();
// with no bar yet, the In point runs to the end of the sequence
s.set_playhead(f(24));
assert_eq!(s.execute("sequence.setWorkAreaIn", json!({})).unwrap(), json!({"start": f(24).0, "end": end.0}));
assert_eq!(area(&s), Some((f(24), end)));
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar In Point"));
s.set_playhead(f(72));
s.execute("sequence.setWorkAreaOut", json!({})).unwrap();
assert_eq!(area(&s), Some((f(24), f(72))));
assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Set Work Area Bar Out Point"));
let frames = s.execute("export.resolve", json!({"preset": "PNG Sequence", "range": "workArea"})).unwrap()["frames"].as_i64().unwrap();
assert!((47..=49).contains(&frames), "{frames}");
// an In point past the Out point moves the Out point to the end of the sequence
s.execute("sequence.setWorkAreaIn", json!({"frame": 96})).unwrap();
assert_eq!(area(&s), Some((f(96), end)));
// an Out point before the In point moves the In point to the start
s.execute("sequence.setWorkAreaOut", json!({"frame": 48})).unwrap();
assert_eq!(area(&s), Some((Tick::ZERO, f(48))));
// an In point at or past the end of the sequence keeps one frame
s.execute("sequence.setWorkAreaIn", json!({"time": end.0})).unwrap();
assert_eq!(area(&s), Some((end, end + rate.frame_duration())));
let before = area(&s);
assert!(s.execute("sequence.setWorkAreaOut", json!({"time": 0})).is_err(), "an Out point at 0 leaves no work area");
assert!(s.execute("sequence.setWorkAreaIn", json!({"time": -1})).is_err(), "negative times are refused");
assert_eq!(area(&s), before);
}
#[test]
fn set_work_area_takes_both_ends_and_refuses_bad_ones() {
let mut s = demo();
let rate = s.sequence_rate();
let (a, b) = (rate.tick_of(10), rate.tick_of(20));
assert_eq!(s.execute("sequence.setWorkArea", json!({"start": a.0, "end": b.0})).unwrap(), json!({"start": a.0, "end": b.0}));
assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)));
for p in [
json!({"start": b.0, "end": a.0}),
json!({"start": a.0, "end": a.0}),
json!({"start": -1, "end": b.0}),
json!({"start": a.0}),
json!({"end": b.0}),
json!({"start": 0, "end": i64::MAX}),
] {
assert!(s.execute("sequence.setWorkArea", p.clone()).is_err(), "{p}");
assert_eq!(s.active_sequence().unwrap().work_area, Some(TimeRange::from_bounds(a, b)), "{p}");
}
let mut empty = Session::default();
for id in ["sequence.workAreaBar", "sequence.setWorkAreaIn", "sequence.setWorkAreaOut", "sequence.setWorkArea"] {
assert!(empty.execute(id, json!({"start": 0, "end": 1})).is_err(), "{id} needs a sequence");
}
}
#[test]
fn alt_brackets_set_the_work_area_in_the_timeline() {
let s = Session::default();
for p in [Platform::Mac, Platform::Windows] {
for (keys, cmd) in [("Alt+[", "sequence.setWorkAreaIn"), ("Alt+]", "sequence.setWorkAreaOut")] {
let chord = Chord::parse(keys).unwrap();
assert_eq!(s.shortcuts.resolve(&chord, Some("Timeline"), p).map(|b| b.command.as_str()), Some(cmd), "{keys} {p:?}");
}
}
}
+4
View File
@@ -511,6 +511,10 @@
Reveal Sequence in Project Mostrar secuencia en el proyecto
Video Thumbnails Miniaturas de video
Audio Waveforms Formas de onda de audio
Work Area Bar Barra de área de trabajo
Start: {tc} Inicio: {tc}
End: {tc} Fin: {tc}
Duration: {tc} Duración: {tc}
Delete Preset Eliminar ajuste preestablecido
Export Preset… Exportar ajuste preestablecido…
Import Presets… Importar ajustes preestablecidos…
Can't render this file because it contains an unexpected character in line 3 and column 95.
+4
View File
@@ -508,6 +508,10 @@
Reveal Sequence in Project プロジェクトでシーケンスを表示
Video Thumbnails ビデオサムネイル
Audio Waveforms オーディオ波形
Work Area Bar ワークエリアバー
Start: {tc} 開始: {tc}
End: {tc} 終了: {tc}
Duration: {tc} 長さ: {tc}
Delete Preset プリセットを削除
Export Preset… プリセットを書き出し…
Import Presets… プリセットを読み込み…
Can't render this file because it contains an unexpected character in line 11 and column 59.
+4
View File
@@ -502,6 +502,10 @@
Reveal Sequence in Project 在项目面板中显示序列
Video Thumbnails 视频缩略图
Audio Waveforms 音频波形
Work Area Bar 工作区域栏
Start: {tc} 开始:{tc}
End: {tc} 结束:{tc}
Duration: {tc} 持续时间:{tc}
Delete Preset 删除预设
Export Preset… 导出预设…
Import Presets… 导入预设…
Can't render this file because it contains an unexpected character in line 3 and column 95.
+8
View File
@@ -339,6 +339,14 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
}
ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails"));
ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms"));
let mut work_area = app.session.active_sequence().is_some_and(|q| q.work_area.is_some());
let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Checkbox::new(&mut work_area, tl!("Work Area Bar")));
app.auto.add("panel.menu.Timeline.workAreaBar", r.rect, "Work Area Bar");
if r.changed()
&& let Err(e) = app.session.execute("sequence.workAreaBar", serde_json::json!({"show": work_area}))
{
app.ui.status = e.to_string();
}
}
if p == PanelKind::Project {
ui.separator();
+72 -2
View File
@@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex};
use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use filmcraft_project::{ClipId, ItemId, MarkerId, Sequence, TrackId, TrackItem, TrackKind};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, format_time};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeDisplay, TimeRange, format_time};
use serde_json::{Value, json};
use crate::FilmcraftApp;
@@ -22,7 +22,7 @@ use crate::icons::{self, Icon};
use crate::state::{ThumbnailMode, TimelineView, Tool};
use crate::theme::Tokens;
use super::timeline_hit::{EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer};
use super::timeline_hit::{EDGE_PX, EdgeKind, Grab, edge_geometry, grab_at, marker_at, marker_range_rect, marker_rect, marker_under_pointer, work_area_rect};
pub use super::timeline_hit::{Hit, ROLL_PX, hit, selection_trim_kind};
const TOP_H: f32 = 58.0; // timecode + toolbar (left) / ruler (right)
@@ -157,6 +157,15 @@ pub enum Drag {
start: Tick,
duration: Tick,
},
/// The work area bar dragged by an end (`edge`: that end moves) or its middle (the bar moves);
/// `start` / `end` are where it is shown now, committed on release.
WorkArea {
edge: Option<filmcraft_edit::Edge>,
/// As in [`Drag::Trim`].
from: Tick,
start: Tick,
end: Tick,
},
Pan {
last: Pos2,
start_scroll: f64,
@@ -1620,6 +1629,27 @@ fn draw_top(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, seq: &Sequenc
});
}
}
// work area bar: between the markers row and the time labels, as dragged while a drag of it runs
let shown = match app.tl.drag {
Some(Drag::WorkArea { start, end, .. }) => Some(TimeRange::from_bounds(start, end)),
_ => seq.work_area,
};
if let Some(area) = shown {
let r = work_area_rect(layout, area);
clip.rect_filled(r, 1.0, t.text_dim);
for x in [r.min.x, r.max.x] {
clip.rect_filled(Rect::from_center_size(pos2(x, r.center().y), vec2(3.0, r.height() + 2.0)), 1.0, t.text);
}
app.auto.add("timeline.workArea", r.intersect(ruler), "Work Area Bar");
let fmt = |tick: Tick| format_time(tick, rate, seq.settings.drop_frame, TimeDisplay::Timecode, 48000);
if ui.rect_contains_pointer(r.expand2(vec2(EDGE_PX, 1.0)).intersect(ruler)) {
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("work-area-tip"), egui::PopupAnchor::Pointer).show(|ui| {
ui.label(tlf!("Start: {tc}", tc = fmt(area.start)));
ui.label(tlf!("End: {tc}", tc = fmt(area.end())));
ui.label(tlf!("Duration: {tc}", tc = fmt(area.duration)));
});
}
}
// markers: 8 × 12 pt pentagons in the marker colour, on the top row
for m in &seq.markers {
let x = layout.x_of(m.start);
@@ -1835,6 +1865,7 @@ pub fn hit_json(app: &FilmcraftApp, pos: Pos2, mods: egui::Modifiers) -> Value {
};
match grab.hit() {
Hit::Ruler => json!({"hit": "ruler", "time": t.0}),
Hit::WorkArea { edge } => json!({"hit": "workArea", "edge": edge.map(|e| format!("{e:?}")), "time": t.0}),
Hit::Clip { track, clip, edge } => {
json!({"hit": "clip", "track": track.0, "clip": clip.0, "edge": edge.map(|e| format!("{e:?}")), "kind": kind, "time": t.0})
}
@@ -2348,6 +2379,8 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
(_, Grab::Edge { .. }) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: Some(_), .. })) => CursorIcon::ResizeColumn,
(Tool::Selection, Grab::Other(Hit::Transition { edge: None, .. })) => CursorIcon::Grab,
(_, Grab::Other(Hit::WorkArea { edge: Some(_) })) => CursorIcon::ResizeColumn,
(_, Grab::Other(Hit::WorkArea { edge: None })) => CursorIcon::Grab,
(_, Grab::Volume { .. }) if tool == Tool::Pen || mods.command => CursorIcon::Crosshair,
(_, Grab::Volume { .. }) => CursorIcon::ResizeVertical,
(Tool::Razor, Grab::Other(Hit::Clip { .. })) => CursorIcon::Crosshair,
@@ -2399,6 +2432,9 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
Some(Drag::Pan { last, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll })
}
(_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }),
(_, Grab::Other(Hit::WorkArea { edge })) => {
seq.work_area.filter(|_| resp.drag_started()).map(|w| Drag::WorkArea { edge, from: t, start: w.start, end: w.end() })
}
(Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }),
(Tool::Zoom, _) => {
if resp.clicked() {
@@ -2674,6 +2710,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let (start, duration) = span.unwrap_or((start, duration));
Some(Drag::Transition { id, edge, from, start, duration })
}
Drag::WorkArea { edge, from, start, end } => {
// the bar as it was at the press: the sequence keeps it until the release
let (s0, e0) = seq.work_area.map(|w| (w.start, w.end())).unwrap_or((start, end));
let fd = rate.frame_duration();
let delta = rate.snap_nearest(t_here - from);
let (start, end) = match edge {
Some(filmcraft_edit::Edge::In) => (snap(app, seq, layout, rate.snap_nearest(s0 + delta), &[]).min(e0 - fd).max(Tick::ZERO), e0),
Some(filmcraft_edit::Edge::Out) => (s0, snap(app, seq, layout, rate.snap_nearest(e0 + delta), &[]).max(s0 + fd)),
None => {
let d = delta.max(Tick::ZERO - s0);
(s0 + d, e0 + d)
}
};
Some(Drag::WorkArea { edge, from, start, end })
}
Drag::Volume(v) => Some(Drag::Volume(super::timeline_volume::drag(app, ui, seq, layout, v, p.y))),
Drag::Marquee { start, before, add, individual } => {
let r = Rect::from_two_pos(start, p);
@@ -2746,6 +2797,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
let moved = seq.all_tracks().find_map(|tr| tr.transitions.iter().find(|x| x.id == id)).is_some_and(|x| (x.start, x.duration) != (start, duration));
moved.then(|| app.session.execute("sequence.setTransition", json!({"transition": id.0, "start": start.0, "duration": duration.0})))
}
Drag::WorkArea { start, end, .. } => {
let moved = seq.work_area.is_some_and(|w| (w.start, w.end()) != (start, end));
moved.then(|| app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0})))
}
Drag::Marquee { start, before, add, individual } => {
app.session.state.selection = before;
// egui ends a drag on Escape: that cancels the box, as in the Project panel
@@ -2767,6 +2822,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: &
}
}
// ---- double-click the work area bar: it spans the part of the sequence the ruler shows
if resp.double_clicked()
&& let Some(p) = resp.interact_pointer_pos()
&& let Hit::WorkArea { .. } = hit(seq, layout, p)
{
let start = rate.snap_nearest(layout.tick_at(layout.content.min.x).max(Tick::ZERO));
let end = rate.snap_nearest(layout.tick_at(layout.content.max.x)).min(seq.duration());
// a ruler scrolled past the end of the sequence leaves the bar as it is
if end > start
&& let Err(e) = app.session.execute("sequence.setWorkArea", json!({"start": start.0, "end": end.0}))
{
app.ui.status = e.to_string();
}
}
// ---- double-click a transition: Set Transition Duration (Premiere)
if resp.double_clicked()
&& tool == Tool::Selection
+58
View File
@@ -51,6 +51,11 @@ pub(crate) fn edge_geometry(seq: &Sequence, layout: &Layout, track: TrackId, cli
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Hit {
Ruler,
/// The work area bar on the ruler; `edge` is its start (`In`) or end (`Out`) when the press is
/// on one of its ends (dragging it moves that end), `None` on its middle (dragging moves it).
WorkArea {
edge: Option<Edge>,
},
Clip {
track: TrackId,
clip: ClipId,
@@ -96,8 +101,35 @@ pub fn marker_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option<Tick> {
marker_under_pointer(seq, layout, pos).map(|m| m.start)
}
/// Where the Timeline ruler draws (and hit-tests) the work area bar, between the markers row and
/// the time labels.
pub fn work_area_rect(layout: &Layout, area: filmcraft_time::TimeRange) -> Rect {
let y = layout.ruler.min.y + 14.0;
Rect::from_min_max(pos2(layout.x_of(area.start), y), pos2(layout.x_of(area.end()), y + 6.0))
}
/// The part of the work area bar under the pointer: an end (an edge zone, narrower on a short
/// bar so its middle stays grabbable) or its middle.
pub fn work_area_at(seq: &Sequence, layout: &Layout, pos: Pos2) -> Option<Option<Edge>> {
let r = work_area_rect(layout, seq.work_area?);
let zone = zone(r.width())?;
if !r.expand2(vec2(zone, 1.0)).contains(pos) || !layout.ruler.contains(pos) {
return None;
}
Some(if (pos.x - r.min.x).abs() <= zone {
Some(Edge::In)
} else if (r.max.x - pos.x).abs() <= zone {
Some(Edge::Out)
} else {
None
})
}
pub fn hit(seq: &Sequence, layout: &Layout, pos: Pos2) -> Hit {
if layout.ruler.contains(pos) {
if let Some(edge) = work_area_at(seq, layout, pos) {
return Hit::WorkArea { edge };
}
return Hit::Ruler;
}
if !layout.content.contains(pos) {
@@ -477,6 +509,32 @@ mod tests {
assert_eq!(marker_under_pointer(&seq, &layout, at).unwrap().id.0, 9);
}
#[test]
fn the_work_area_bar_has_two_ends_and_a_middle() {
let (mut seq, l, _) = fixture(clips());
// hidden, the bar's row is plain ruler
assert_eq!(hit(&seq, &l, pos2(300.0, 17.0)), Hit::Ruler);
seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(5.0)));
let y = work_area_rect(&l, seq.work_area.unwrap()).center().y;
assert_eq!(hit(&seq, &l, pos2(197.0, y)), Hit::WorkArea { edge: Some(Edge::In) });
assert_eq!(hit(&seq, &l, pos2(204.0, y)), Hit::WorkArea { edge: Some(Edge::In) });
assert_eq!(hit(&seq, &l, pos2(350.0, y)), Hit::WorkArea { edge: None });
assert_eq!(hit(&seq, &l, pos2(503.0, y)), Hit::WorkArea { edge: Some(Edge::Out) });
assert_eq!(hit(&seq, &l, pos2(510.0, y)), Hit::Ruler, "past its end zone");
// the markers row and the time labels stay ruler
assert_eq!(hit(&seq, &l, pos2(350.0, 8.0)), Hit::Ruler);
assert_eq!(hit(&seq, &l, pos2(350.0, 30.0)), Hit::Ruler);
assert_eq!(grab_at(&seq, &l, pos2(350.0, y), Tool::Selection, Modifiers::NONE, false), Grab::Other(Hit::WorkArea { edge: None }));
// a bar a few px wide keeps its middle third; hostile points grab nothing
seq.work_area = Some(filmcraft_time::TimeRange::from_bounds(Tick::from_seconds_f64(2.0), Tick::from_seconds_f64(2.09)));
assert_eq!(hit(&seq, &l, pos2(204.5, y)), Hit::WorkArea { edge: None });
assert_eq!(hit(&seq, &l, pos2(f32::NAN, y)), Hit::None);
for pps in [0.0, f64::NAN, f64::INFINITY] {
let broken = Layout { pps, ..l.clone() };
assert!(!matches!(hit(&seq, &broken, pos2(204.5, y)), Hit::WorkArea { edge: Some(_) }), "pps {pps}");
}
}
use egui::Modifiers;
use crate::state::Tool;
+1
View File
@@ -68,6 +68,7 @@ mod timeline_tabs_ui;
mod timeline_track_height_ui;
mod timeline_transition_move_ui;
mod timeline_trim_ui;
mod timeline_work_area_ui;
mod track_color_ui;
mod track_select_drag_ui;
mod transcript_ui;
@@ -0,0 +1,153 @@
//! The work area bar on the Timeline ruler: dragging an end moves that end, dragging its middle
//! moves the bar, a double-click fits it to the visible part of the sequence, and none of these
//! moves the playhead. Each gesture is one `sequence.setWorkArea` undo step.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
// 60 frames a second: the two clicks of a double-click are one frame apart each, well
// inside egui's 0.3 s
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method} {params}");
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn bar(&mut self) -> Option<[f64; 4]> {
let v = self.ok("ui.elements", json!({"prefix": "timeline.workArea"}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == "timeline.workArea")?.clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Some([r[0], r[1], r[2], r[3]])
}
fn drag(&mut self, from: (f64, f64), dx: f64) {
self.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + dx, "y": from.1}, "steps": 8}));
self.frames(3);
}
fn area(&self) -> Option<(i64, i64)> {
self.harness.state().session.active_sequence().unwrap().work_area.map(|w| (w.start.0, w.end().0))
}
fn last_undo(&self) -> Option<String> {
self.harness.state().session.history.undo.last().map(|u| u.0.clone())
}
}
#[test]
fn dragging_and_double_clicking_the_work_area_bar() {
let mut d = Driver::demo();
d.exec("sequence.snap", json!({"on": false}));
let rate = d.harness.state().session.sequence_rate();
let (f24, f72) = (rate.tick_of(24).0, rate.tick_of(72).0);
let frame = rate.frame_duration().0;
assert_eq!(d.bar(), None, "hidden, the bar is not drawn");
d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72}));
d.frames(2);
let r = d.bar().expect("the bar is drawn once it is set");
let (y, per_frame) = (r[1] + r[3] / 2.0, r[2] / 48.0);
let playhead = d.harness.state().session.playhead();
// the Out end, 24 frames to the right
d.drag((r[0] + r[2] - 1.0, y), 24.0 * per_frame);
let (start, end) = d.area().unwrap();
assert_eq!(start, f24, "the In end stays");
assert!((end - rate.tick_of(96).0).abs() <= frame, "the Out end moved 24 frames: {end}");
assert_eq!(d.last_undo().as_deref(), Some("Set Work Area"));
// the middle, 12 frames to the left: both ends move, the length stays
let r = d.bar().unwrap();
d.drag((r[0] + r[2] / 2.0, y), -12.0 * per_frame);
let (start2, end2) = d.area().unwrap();
assert_eq!(end2 - start2, end - start);
assert!((start2 - rate.tick_of(12).0).abs() <= frame, "the bar moved 12 frames: {start2}");
assert_eq!(d.harness.state().session.playhead(), playhead, "dragging the bar leaves the playhead");
// the In end cannot cross the Out end
let r = d.bar().unwrap();
d.drag((r[0] + 1.0, y), r[2] + 40.0 * per_frame);
let (start3, end3) = d.area().unwrap();
assert_eq!((start3, end3), (end2 - frame, end2), "the In end stops one frame before the Out end");
// a double-click spans the visible part of the sequence (the whole sequence is in view)
d.exec("sequence.setWorkArea", json!({"start": f24, "end": f72}));
d.ok("ui.menu.invoke", json!({"id": "view.zoomToSequence"}));
d.frames(60);
let r = d.bar().unwrap();
let hit = d.ok("ui.timeline.hit", json!({"x": r[0] + r[2] / 2.0, "y": y}));
assert_eq!(hit["hit"], "workArea", "the control channel reports the bar: {hit}");
assert_eq!(hit["edge"], Value::Null, "{hit}");
d.ok("ui.click", json!({"x": r[0] + r[2] / 2.0, "y": y, "count": 2}));
d.frames(3);
let duration = d.harness.state().session.active_sequence().unwrap().duration().0;
let (start4, end4) = d.area().unwrap();
assert_eq!(start4, 0);
assert!((end4 - duration).abs() <= frame, "{end4} of {duration}");
assert_eq!(d.harness.state().session.playhead(), playhead, "nor does a double-click");
// hidden again from the Timeline panel menu's command
d.exec("sequence.workAreaBar", json!({"show": false}));
d.frames(2);
assert_eq!(d.bar(), None);
}
#[test]
fn the_in_end_of_a_work_area_shorter_than_a_frame_stays_at_zero() {
let mut d = Driver::demo();
d.exec("sequence.snap", json!({"on": false}));
let rate = d.harness.state().session.sequence_rate();
let frame = rate.frame_duration().0;
// zoomed in about the start of the sequence, so half a frame is a few pixels wide
d.exec("playhead.start", json!({}));
for _ in 0..5 {
d.ok("ui.menu.invoke", json!({"id": "view.zoomIn"}));
}
d.exec("sequence.setWorkArea", json!({"start": rate.tick_of(24).0, "end": rate.tick_of(72).0}));
d.frames(2);
let per_frame = d.bar().unwrap()[2] / 48.0;
assert!(per_frame > 4.0, "a frame is wide enough to grab half of it: {per_frame}");
d.exec("sequence.setWorkArea", json!({"start": 0, "end": frame / 2}));
d.frames(2);
let r = d.bar().unwrap();
let y = r[1] + r[3] / 2.0;
d.drag((r[0] + 0.5, y), 10.0 * per_frame);
assert_eq!(d.area(), Some((0, frame / 2)), "the In end stays at 0");
let status = d.harness.state().ui.status.clone();
assert!(!status.contains("must not be negative"), "the release sends a valid start: {status}");
}