mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
monitors: time zoom bar under the ruler, larger transport buttons
The Program and Source monitors get a zoom scroll bar under their time ruler, drawn like the Timeline's: drag a handle at either end of the thumb to zoom in or out (down to five frames), drag the middle to scroll, or press beside the thumb to move it there. The ruler then shows only that part: playhead, markers, In/Out handles and ticks are mapped to it, clicks and scrubs set a time within it, and anything outside it is not drawn. Each monitor keeps its own range in its view state (time_zoom). The transport buttons are now 28 pt square with larger icons, spaced 6 pt apart and centred; on a narrow monitor the spacing tightens before buttons move to the overflow menu.
This commit is contained in:
1 parent
4e2d0bc2d3
commit
a17b4292fe
6 files changed
+269
-53
No files matched your search
@@ -31,7 +31,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
let stacked_controls = rect.width() < 540.0;
|
||||
let separate_source_drag = which == Which::Source && stacked_controls && rect.width() < 310.0;
|
||||
let source_drag_h = if separate_source_drag { 26.0 } else { 0.0 };
|
||||
let controls_h = 28.0 + 24.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h;
|
||||
let controls_h = 28.0 + 24.0 + 15.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h;
|
||||
let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h));
|
||||
// the Source monitor's time ruler starts at `origin` (a subclip restricted to its range)
|
||||
let mut origin = Tick::ZERO;
|
||||
@@ -430,10 +430,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which)
|
||||
});
|
||||
// ---- mini timeline / scrub bar
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0));
|
||||
let zoom_bar = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 2.0), vec2(bar.width(), 13.0));
|
||||
time_zoom_bar(app, ui, zoom_bar, which, origin, duration, rate);
|
||||
mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out);
|
||||
|
||||
// ---- transport buttons
|
||||
let row3 = Rect::from_min_size(pos2(rect.min.x, bar.max.y + 4.0), vec2(rect.width(), 32.0));
|
||||
let row3 = Rect::from_min_size(pos2(rect.min.x, zoom_bar.max.y + 4.0), vec2(rect.width(), 32.0));
|
||||
transport(app, ui, row3, which);
|
||||
}
|
||||
|
||||
@@ -600,6 +602,112 @@ pub fn fit(area: Rect, w: f32, h: f32) -> Rect {
|
||||
Rect::from_center_size(area.center(), vec2(w * s, h * s))
|
||||
}
|
||||
|
||||
/// The narrowest the time zoom bar's thumb gets, the same as on the Timeline's zoom scroll bar:
|
||||
/// room for its two handles and a part between them to grab.
|
||||
const MIN_ZOOM_THUMB_W: f32 = 34.0;
|
||||
|
||||
/// The fewest frames the time ruler can be zoomed in to.
|
||||
const MIN_ZOOM_FRAMES: i64 = 5;
|
||||
|
||||
/// A monitor's time zoom range as (start, end) fractions of the clip or sequence, made usable (it
|
||||
/// can come from `ui.set`): anything empty, reversed or not a number is the whole length.
|
||||
fn time_zoom_range(zoom: Option<[f64; 2]>) -> (f64, f64) {
|
||||
let (s, e) = zoom.map(|[s, e]| (s.clamp(0.0, 1.0), e.clamp(0.0, 1.0))).unwrap_or((0.0, 1.0));
|
||||
if s < e { (s, e) } else { (0.0, 1.0) }
|
||||
}
|
||||
|
||||
/// The times (start, end) of `origin..end` that the time ruler shows at the zoom range `zoom`.
|
||||
fn ruler_shown(zoom: Option<[f64; 2]>, origin: Tick, end: Tick) -> (Tick, Tick) {
|
||||
let (s, e) = time_zoom_range(zoom);
|
||||
let len = end.0.saturating_sub(origin.0).max(0) as f64;
|
||||
let at = |f: f64| Tick(origin.0.saturating_add((f * len) as i64));
|
||||
(if s > 0.0 { at(s) } else { origin }, if e < 1.0 { at(e) } else { end })
|
||||
}
|
||||
|
||||
/// The zoom scroll bar under the time ruler. Its thumb is the part of the clip or sequence
|
||||
/// (`origin..end`) the ruler shows: drag a handle at either end to zoom, the middle to scroll, or
|
||||
/// press beside the thumb to bring it there. A thumb filling the bar shows all of it.
|
||||
fn time_zoom_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Which, origin: Tick, end: Tick, rate: filmcraft_time::FrameRate) {
|
||||
let t = app.tokens;
|
||||
let prefix = if which == Which::Program { "program" } else { "source" };
|
||||
let len = end.0.saturating_sub(origin.0);
|
||||
let min_span = if len > 0 { (rate.frame_duration().0.saturating_mul(MIN_ZOOM_FRAMES) as f64 / len as f64).min(1.0) } else { 1.0 };
|
||||
let (s, e) = time_zoom_range(monitor_view::view(app, which).time_zoom);
|
||||
let span = e - s;
|
||||
let w = (span as f32 * bar.width()).clamp(MIN_ZOOM_THUMB_W.min(bar.width()), bar.width());
|
||||
let travel = (bar.width() - w).max(0.0);
|
||||
let range = (1.0 - span).max(0.0);
|
||||
let left = bar.min.x + if range > 0.0 { (s / range).clamp(0.0, 1.0) as f32 * travel } else { 0.0 };
|
||||
let per_point = if travel > 0.5 { range / travel as f64 } else { 0.0 };
|
||||
let thumb = Rect::from_min_max(pos2(left, bar.min.y), pos2(left + w, bar.max.y));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(bar, bar.height() / 2.0, t.separator);
|
||||
let hover = ui.rect_contains_pointer(thumb);
|
||||
p.rect_filled(thumb, thumb.height() / 2.0, if hover { t.scroll_thumb_hover } else { t.scroll_thumb });
|
||||
for x in [thumb.min.x + thumb.height() / 2.0, thumb.max.x - thumb.height() / 2.0] {
|
||||
p.circle_filled(pos2(x, thumb.center().y), 4.5, t.panel_bg);
|
||||
p.circle_stroke(pos2(x, thumb.center().y), 4.5, Stroke::new(1.5, t.icon_active));
|
||||
}
|
||||
app.auto.add(&format!("{prefix}.zoomBar"), thumb, "zoom scroll bar");
|
||||
app.auto.add(&format!("{prefix}.zoomBar.track"), bar, "zoom scroll bar track");
|
||||
let resp = ui.interact(bar, egui::Id::new((prefix, "zoomBar")), Sense::click_and_drag());
|
||||
// the start that brings the thumb's middle under `x`
|
||||
let jump = |x: f32| ((x - bar.min.x - w / 2.0) as f64 * per_point).clamp(0.0, range);
|
||||
let mut set = None;
|
||||
if resp.clicked()
|
||||
&& per_point > 0.0
|
||||
&& let Some(ns) = resp.interact_pointer_pos().filter(|pos| !thumb.contains(*pos)).map(|pos| jump(pos.x))
|
||||
{
|
||||
set = Some((ns, ns + span));
|
||||
}
|
||||
// (a drag is known to be one only after the pointer has moved: what was grabbed is what was
|
||||
// under the pointer when the button went down)
|
||||
let drag_id = egui::Id::new((prefix, "zoomBar-drag"));
|
||||
if resp.drag_started()
|
||||
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
|
||||
{
|
||||
let mut start = s;
|
||||
let mode = if !thumb.expand2(vec2(0.0, 4.0)).contains(pos) {
|
||||
if per_point > 0.0 {
|
||||
start = jump(pos.x);
|
||||
set = Some((start, start + span));
|
||||
}
|
||||
0u8
|
||||
} else if (pos.x - thumb.min.x).abs() < 9.0 {
|
||||
1
|
||||
} else if (pos.x - thumb.max.x).abs() < 9.0 {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, (pos.x, start, mode)));
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some((grab, start, mode)) = ui.data(|d| d.get_temp::<(f32, f64, u8)>(drag_id))
|
||||
{
|
||||
match mode {
|
||||
// the thumb's middle: scroll, the thumb staying under the pointer
|
||||
0 => {
|
||||
if let Some(pos) = resp.interact_pointer_pos() {
|
||||
let ns = (start + (pos.x - grab) as f64 * per_point).clamp(0.0, range);
|
||||
set = Some((ns, ns + span));
|
||||
}
|
||||
}
|
||||
// a handle: zoom, the other end staying where it is
|
||||
_ => {
|
||||
let dx = resp.drag_delta().x as f64 / bar.width().max(1.0) as f64;
|
||||
set = Some(if mode == 1 { ((s + dx).clamp(0.0, (e - min_span).max(0.0)), e) } else { (s, (e + dx).clamp((s + min_span).min(1.0), 1.0)) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
ui.data_mut(|d| d.remove::<(f32, f64, u8)>(drag_id));
|
||||
}
|
||||
if let Some((s, e)) = set {
|
||||
monitor_view::view_mut(app, which).time_zoom = (s > 0.0 || e < 1.0).then_some([s, e.min(1.0)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mini_timeline(
|
||||
app: &mut FilmcraftApp,
|
||||
@@ -615,23 +723,33 @@ fn mini_timeline(
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let duration = end;
|
||||
let dur = (end - origin).0.max(1) as f64;
|
||||
let xof = |tk: Tick| bar.min.x + (((tk - origin).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width();
|
||||
// the part the zoom bar shows
|
||||
let shown = ruler_shown(monitor_view::view(app, which).time_zoom, origin, end);
|
||||
let dur = (shown.1 - shown.0).0.max(1) as f64;
|
||||
let xof = |tk: Tick| bar.min.x + (((tk - shown.0).0 as f64 / dur) as f32).clamp(0.0, 1.0) * bar.width();
|
||||
// a time off a zoomed ruler is not drawn (past either end of it all, it sits at that end)
|
||||
let shows = |tk: Tick| (tk >= shown.0 || shown.0 <= origin) && (tk <= shown.1 || shown.1 >= end);
|
||||
let resp = ui.interact(bar, egui::Id::new((which as u8, "scrub")), Sense::click_and_drag());
|
||||
app.auto.add(if which == Which::Program { "program.scrubBar" } else { "source.scrubBar" }, bar, "scrub bar");
|
||||
let interaction = crate::panels::source_range::interact(app, ui, bar, which);
|
||||
let interaction = crate::panels::source_range::interact(app, ui, bar, which, shown);
|
||||
let p = ui.painter();
|
||||
// ticks: minor 4 pt, major 10 pt, ~20 pt spacing
|
||||
// ticks: minor 4 pt, major 10 pt, ~20 pt spacing, at fixed times so they move with a scroll
|
||||
let n = ((bar.width() / 20.0) as i32).max(2);
|
||||
for i in 0..=n {
|
||||
let x = bar.min.x + bar.width() * i as f32 / n as f32;
|
||||
let step = dur / f64::from(n);
|
||||
let first = ((shown.0 - origin).0 as f64 / step).ceil() as i64;
|
||||
for i in first..=first.saturating_add(i64::from(n)) {
|
||||
let x = bar.min.x + ((origin.0 as f64 + i as f64 * step - shown.0.0 as f64) / dur) as f32 * bar.width();
|
||||
if x > bar.max.x + 0.5 {
|
||||
break;
|
||||
}
|
||||
let h = if i % 5 == 0 { 8.0 } else { 3.5 };
|
||||
p.line_segment([pos2(x, bar.max.y - h), pos2(x, bar.max.y)], Stroke::new(1.0, t.text_faint));
|
||||
}
|
||||
let a = xof(interaction.range.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin)));
|
||||
let a_t = interaction.range.map(|r| r.start).unwrap_or(mark_in.unwrap_or(origin));
|
||||
// Out includes its frame; both the shading and its bracket end at the following boundary.
|
||||
let b = xof(interaction.range.map(|r| r.end()).unwrap_or_else(|| mark_out.map(|o| Tick(o.0.saturating_add(rate.frame_duration().0))).unwrap_or(duration)))
|
||||
.max(a);
|
||||
let b_t = interaction.range.map(|r| r.end()).unwrap_or_else(|| mark_out.map(|o| Tick(o.0.saturating_add(rate.frame_duration().0))).unwrap_or(duration));
|
||||
let a = xof(a_t);
|
||||
let b = xof(b_t).max(a);
|
||||
if mark_in.is_some() || mark_out.is_some() {
|
||||
p.rect_filled(Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y)), 0.0, Color32::from_rgba_unmultiplied(0x5c, 0x5c, 0x5c, 128));
|
||||
}
|
||||
@@ -640,8 +758,8 @@ fn mini_timeline(
|
||||
let top = bar.min.y + 8.0;
|
||||
let bottom = bar.max.y;
|
||||
let mid = (top + bottom) * 0.5;
|
||||
for (x, direction, hot) in [(a, 1.0_f32, interaction.in_hot), (b, -1.0_f32, interaction.out_hot)] {
|
||||
if hot || (direction > 0.0 && mark_in.is_none()) || (direction < 0.0 && mark_out.is_none()) {
|
||||
for (x, direction, hot, edge) in [(a, 1.0_f32, interaction.in_hot, a_t), (b, -1.0_f32, interaction.out_hot, b_t)] {
|
||||
if hot || !shows(edge) || (direction > 0.0 && mark_in.is_none()) || (direction < 0.0 && mark_out.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let point = |offset: f32, y: f32| pos2(x + direction * offset, y);
|
||||
@@ -669,7 +787,7 @@ fn mini_timeline(
|
||||
Which::Source => app.session.state.source_item.and_then(|i| filmcraft_engine::clip_ops::source_view(&app.session, i)).map(|v| v.markers),
|
||||
};
|
||||
if let Some(markers) = markers {
|
||||
for m in &markers {
|
||||
for m in markers.iter().filter(|m| shows(m.start)) {
|
||||
let x = xof(m.start);
|
||||
let c = m.color.marker_rgb();
|
||||
let c = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
@@ -681,15 +799,17 @@ fn mini_timeline(
|
||||
));
|
||||
}
|
||||
}
|
||||
let x = xof(time);
|
||||
// playhead: blue triangle over a line
|
||||
let hy = bar.max.y - 11.0;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 5.5, hy), pos2(x + 5.5, hy), pos2(x + 5.5, hy + 5.0), pos2(x, hy + 9.0), pos2(x - 5.5, hy + 5.0)],
|
||||
t.playhead,
|
||||
Stroke::NONE,
|
||||
));
|
||||
p.line_segment([pos2(x, hy + 8.0), pos2(x, bar.max.y)], Stroke::new(1.0, t.playhead));
|
||||
if shows(time) {
|
||||
let x = xof(time);
|
||||
// playhead: blue triangle over a line
|
||||
let hy = bar.max.y - 11.0;
|
||||
p.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(x - 5.5, hy), pos2(x + 5.5, hy), pos2(x + 5.5, hy + 5.0), pos2(x, hy + 9.0), pos2(x - 5.5, hy + 5.0)],
|
||||
t.playhead,
|
||||
Stroke::NONE,
|
||||
));
|
||||
p.line_segment([pos2(x, hy + 8.0), pos2(x, bar.max.y)], Stroke::new(1.0, t.playhead));
|
||||
}
|
||||
if interaction.in_hot {
|
||||
crate::panels::source_range::trim_cue(p, a, bar, 4.0);
|
||||
}
|
||||
@@ -700,7 +820,7 @@ fn mini_timeline(
|
||||
if !interaction.handled && (resp.dragged() || resp.clicked()) {
|
||||
resp.interact_pointer_pos().map(|pos| {
|
||||
let f = ((pos.x - bar.min.x) / bar.width().max(1.0)).clamp(0.0, 1.0) as f64;
|
||||
rate.snap(origin + Tick((f * dur) as i64))
|
||||
rate.snap(shown.0 + Tick((f * dur) as i64))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -732,6 +852,9 @@ fn set_transport_visible(hidden: &mut Vec<String>, command: &str, visible: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Width and height of a transport button.
|
||||
const TRANSPORT_BUTTON: f32 = 28.0;
|
||||
|
||||
fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) {
|
||||
let t = app.tokens;
|
||||
let src = which == Which::Source;
|
||||
@@ -777,7 +900,7 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
if row.width() >= 30.0 {
|
||||
// Keep the existing button-editor affordance only when it has its own space.
|
||||
// Customize this monitor's transport separately from the other monitor.
|
||||
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0));
|
||||
let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 5.0), vec2(22.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor"));
|
||||
app.auto.add(&format!("{prefix}.transport.editor"), r, "Button Editor");
|
||||
icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim });
|
||||
@@ -810,13 +933,16 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
}
|
||||
}
|
||||
let available = (row.width() - 46.0).max(0.0);
|
||||
let widths = vec![28.0; buttons.len()];
|
||||
let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 2.0, 28.0) else {
|
||||
let widths = vec![TRANSPORT_BUTTON; buttons.len()];
|
||||
// roomy spacing when every button fits with it, tight spacing before any goes to the overflow menu
|
||||
let count = buttons.len() as f32;
|
||||
let gap = if count * TRANSPORT_BUTTON + (count - 1.0).max(0.0) * 6.0 <= available { 6.0 } else { 2.0 };
|
||||
let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, gap, TRANSPORT_BUTTON) else {
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(Rect::from_min_max(row.min, pos2((row.right() - 30.0).max(row.left()), row.bottom()))), |ui| {
|
||||
egui::ScrollArea::horizontal().id_salt((prefix, "transport-scroll")).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, command, label) in &buttons {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::hover());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON), Sense::hover());
|
||||
if transport_button(app, ui, rect, prefix, *icon, command, label) {
|
||||
invoke_transport(app, ui.ctx(), which, command);
|
||||
}
|
||||
@@ -826,25 +952,26 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which)
|
||||
});
|
||||
return;
|
||||
};
|
||||
let total = fit.shown.len() as f32 * 30.0 - 2.0 + if fit.overflow.is_empty() { 0.0 } else { 30.0 };
|
||||
let mut x = row.center().x - 15.0 - total.max(28.0) / 2.0;
|
||||
let pitch = TRANSPORT_BUTTON + gap;
|
||||
let total = fit.shown.len() as f32 * pitch - gap + if fit.overflow.is_empty() { 0.0 } else { pitch };
|
||||
let mut x = row.center().x - 15.0 - total.max(TRANSPORT_BUTTON) / 2.0;
|
||||
for (index, _) in &fit.shown {
|
||||
let Some((icon, command, label)) = buttons.get(*index) else { continue };
|
||||
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0));
|
||||
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON));
|
||||
if transport_button(app, ui, rect, prefix, *icon, command, label) {
|
||||
invoke_transport(app, ui.ctx(), which, command);
|
||||
}
|
||||
x += 30.0;
|
||||
x += pitch;
|
||||
}
|
||||
if !fit.overflow.is_empty() {
|
||||
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0));
|
||||
let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(TRANSPORT_BUTTON, TRANSPORT_BUTTON));
|
||||
let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls"));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
for offset in [-5.0, 0.0, 5.0] {
|
||||
ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 1.5, t.icon);
|
||||
for offset in [-6.0, 0.0, 6.0] {
|
||||
ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 2.0, t.icon);
|
||||
}
|
||||
if response.has_focus() {
|
||||
ui.painter().rect_stroke(rect.shrink(2.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
|
||||
@@ -877,7 +1004,7 @@ fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefi
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 16.0 } else { 14.0 };
|
||||
let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 20.0 } else { 18.0 };
|
||||
let color = if command == "media.toggleProxies" && app.session.media.use_proxies() {
|
||||
t.accent
|
||||
} else if response.hovered() {
|
||||
|
||||
@@ -50,7 +50,8 @@ pub struct Interaction {
|
||||
pub scrub: Option<Tick>,
|
||||
}
|
||||
|
||||
pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Which) -> Interaction {
|
||||
/// `shown`: the part of the clip or sequence the bar shows (its time zoom range).
|
||||
pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Which, shown: (Tick, Tick)) -> Interaction {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(item) = self_item(app, which) else {
|
||||
clear(&ctx, which);
|
||||
@@ -67,8 +68,10 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
clear(&ctx, which);
|
||||
}
|
||||
let selected = preview_for(app, &ctx, which).unwrap_or_else(|| view.selected_range());
|
||||
let span = view.end.0.saturating_sub(view.start.0).max(1) as f64;
|
||||
let xof = |t: Tick| bar.min.x + (t.0.saturating_sub(view.start.0) as f64 / span).clamp(0.0, 1.0) as f32 * bar.width();
|
||||
let span = shown.1.0.saturating_sub(shown.0.0).max(1) as f64;
|
||||
let xof = |t: Tick| bar.min.x + (t.0.saturating_sub(shown.0.0) as f64 / span).clamp(0.0, 1.0) as f32 * bar.width();
|
||||
// what is off a zoomed bar has no handle (past either end of it all, it sits at that end)
|
||||
let shows = |t: Tick| (t >= shown.0 || shown.0 <= view.start) && (t <= shown.1 || shown.1 >= view.end);
|
||||
let (a, b) = (xof(selected.start), xof(selected.end()));
|
||||
let mid = (a + b) * 0.5;
|
||||
let band = Rect::from_min_max(pos2(a, bar.min.y + 8.0), pos2(b, bar.max.y));
|
||||
@@ -95,6 +98,9 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (edge == RangeEdge::In && !shows(selected.start)) || (edge == RangeEdge::Out && !shows(selected.end())) {
|
||||
continue;
|
||||
}
|
||||
app.auto.add(id, rect, label);
|
||||
let response =
|
||||
ui.add_enabled_ui(enabled && rect.width() > 0.0, |ui| ui.interact(rect, egui::Id::new(id), Sense::click_and_drag())).inner.on_hover_text(label);
|
||||
@@ -136,8 +142,8 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
result.handled = true;
|
||||
ctx.set_cursor_icon(cursor);
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
let raw = (f64::from(pos.x - d.pointer_x) / f64::from(bar.width().max(1.0)) * d.bounds.duration.0 as f64)
|
||||
.clamp(-(d.bounds.duration.0 as f64), d.bounds.duration.0 as f64);
|
||||
let raw =
|
||||
(f64::from(pos.x - d.pointer_x) / f64::from(bar.width().max(1.0)) * span).clamp(-(d.bounds.duration.0 as f64), d.bounds.duration.0 as f64);
|
||||
if raw.is_finite() {
|
||||
match adjust_range(d.bounds, d.original, d.rate, d.edge, Tick(raw as i64)) {
|
||||
Ok(r) => {
|
||||
@@ -179,24 +185,26 @@ pub fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, which: Whi
|
||||
&& edge == RangeEdge::Move
|
||||
&& let Some(pos) = response.interact_pointer_pos()
|
||||
{
|
||||
let time = view.start.0.saturating_add(((f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0) * span) as i64);
|
||||
let time = shown.0.0.saturating_add(((f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0) * span) as i64);
|
||||
if let Err(e) = app.session.execute(if which == Which::Source { "source.setPlayhead" } else { "playhead.set" }, json!({"time":time})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
let time = if which == Which::Source { app.session.state.source_playhead } else { app.session.playhead() };
|
||||
let x = xof(time);
|
||||
let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y));
|
||||
let id = if which == Which::Source { "source.playhead" } else { "program.playhead" };
|
||||
app.auto.add(id, rect, "Scrub playhead");
|
||||
let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if (playhead.dragged() || playhead.clicked())
|
||||
&& let Some(pos) = playhead.interact_pointer_pos()
|
||||
{
|
||||
let f = (f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0);
|
||||
result.scrub = Some(view.rate.snap(Tick(view.start.0.saturating_add((f * span) as i64))));
|
||||
result.handled = true;
|
||||
if shows(time) {
|
||||
let x = xof(time);
|
||||
let rect = Rect::from_min_max(pos2(x - 7.0, bar.max.y - 12.0), pos2(x + 7.0, bar.max.y));
|
||||
let id = if which == Which::Source { "source.playhead" } else { "program.playhead" };
|
||||
app.auto.add(id, rect, "Scrub playhead");
|
||||
let playhead = ui.interact(rect, egui::Id::new(id), Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
if (playhead.dragged() || playhead.clicked())
|
||||
&& let Some(pos) = playhead.interact_pointer_pos()
|
||||
{
|
||||
let f = (f64::from(pos.x - bar.min.x) / f64::from(bar.width().max(1.0))).clamp(0.0, 1.0);
|
||||
result.scrub = Some(view.rate.snap(Tick(shown.0.0.saturating_add((f * span) as i64))));
|
||||
result.handled = true;
|
||||
}
|
||||
}
|
||||
result.range = preview_for(app, &ctx, which);
|
||||
result
|
||||
|
||||
@@ -308,6 +308,9 @@ pub struct MonitorView {
|
||||
pub zoom: Option<f32>,
|
||||
/// Pan offset of a magnified picture from the centre (points).
|
||||
pub pan: [f32; 2],
|
||||
/// The part of the clip or sequence the time ruler under the picture shows, as fractions of its
|
||||
/// length (start, end), set with the zoom bar under the ruler; None = all of it.
|
||||
pub time_zoom: Option<[f64; 2]>,
|
||||
pub safe_margins: bool,
|
||||
pub show_transport: bool,
|
||||
/// Transport buttons hidden through the monitor's Button Editor. Stored per monitor.
|
||||
@@ -333,6 +336,7 @@ impl Default for MonitorView {
|
||||
high_quality: false,
|
||||
zoom: None,
|
||||
pan: [0.0, 0.0],
|
||||
time_zoom: None,
|
||||
safe_margins: false,
|
||||
show_transport: true,
|
||||
// The optional Loop button is available from the editor without altering default layouts.
|
||||
|
||||
@@ -122,6 +122,28 @@ fn compact_transport_buttons_stay_inside_monitor() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_buttons_are_large_and_evenly_spaced() {
|
||||
for (which, prefix) in [(Which::Source, "source"), (Which::Program, "program")] {
|
||||
let h = harness(720.0, ThemeKind::Dark, which);
|
||||
let mut buttons: Vec<Rect> = h
|
||||
.state()
|
||||
.auto
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.id.starts_with(&format!("{prefix}.transport.")) && !e.id.contains(".editor"))
|
||||
.map(|e| element(&h, &e.id))
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
assert!(buttons.len() >= 10, "{prefix}: {buttons:?}");
|
||||
for b in &buttons {
|
||||
assert!(b.width() >= 28.0 && b.height() >= 28.0, "{prefix}: small button {b:?}");
|
||||
}
|
||||
let gaps: Vec<f32> = buttons.windows(2).map(|w| w[1].left() - w[0].right()).collect();
|
||||
assert!(gaps.iter().all(|g| (g - gaps[0]).abs() < 0.5 && *g > 2.0), "{prefix}: gaps {gaps:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_editor_keeps_its_own_space_and_customizes_compact_controls() {
|
||||
for width in [720.0, 500.0, 300.0, 250.0, 90.0] {
|
||||
|
||||
@@ -241,6 +241,50 @@ fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() {
|
||||
assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}");
|
||||
}
|
||||
|
||||
/// The zoom bar under a monitor's time ruler: a handle zooms the ruler in, the middle scrolls it,
|
||||
/// the ruler maps clicks and the playhead to the part it shows, and each monitor keeps its own.
|
||||
#[test]
|
||||
fn time_zoom_bar_zooms_and_scrolls_the_monitor_ruler() {
|
||||
let mut d = Driver::demo();
|
||||
let duration = num(&d.exec("sequence.inspect", json!({}))["duration"]);
|
||||
let track = d.rect("program.zoomBar.track");
|
||||
assert!((d.rect("program.zoomBar")[2] - track[2]).abs() < 0.5, "the whole sequence at first");
|
||||
// the right handle dragged to the middle: the ruler shows the first half
|
||||
let y = track[1] + track[3] / 2.0;
|
||||
d.ok("ui.drag", json!({"from": {"x": track[0] + track[2] - 4.0, "y": y}, "to": {"x": track[0] + track[2] / 2.0, "y": y}, "steps": 8}));
|
||||
d.frames(2);
|
||||
let z = d.program()["time_zoom"].clone();
|
||||
assert!(num(&z[0]) == 0.0 && (num(&z[1]) - 0.5).abs() < 0.02, "{z}");
|
||||
let thumb = d.rect("program.zoomBar");
|
||||
assert!((thumb[2] - track[2] * num(&z[1])).abs() < 1.0, "thumb {thumb:?} for {z}");
|
||||
|
||||
// a click halfway along the ruler is halfway through the half shown, and the playhead is drawn there
|
||||
let bar = d.rect("program.scrubBar");
|
||||
d.ok("ui.click", json!({"x": bar[0] + bar[2] / 2.0, "y": bar[1] + bar[3] / 2.0}));
|
||||
d.frames(2);
|
||||
let playhead = num(&d.exec("sequence.inspect", json!({}))["playhead"]);
|
||||
let want = duration * num(&z[1]) / 2.0;
|
||||
assert!((playhead - want).abs() < duration * 0.01, "playhead {playhead} for {want}");
|
||||
let head = d.rect("program.playhead");
|
||||
let at = bar[0] + bar[2] * (playhead / (duration * num(&z[1])));
|
||||
assert!((head[0] + head[2] / 2.0 - at).abs() < 1.0, "playhead drawn at {head:?}, want x {at}");
|
||||
|
||||
// the thumb's middle dragged right scrolls the ruler without zooming; the playhead goes out of view
|
||||
let (x, shift) = (thumb[0] + thumb[2] / 2.0, track[2] * 0.3);
|
||||
d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + shift, "y": y}, "steps": 8}));
|
||||
d.frames(2);
|
||||
let z2 = d.program()["time_zoom"].clone();
|
||||
assert!((num(&z2[0]) - 0.3).abs() < 0.02 && (num(&z2[1]) - num(&z2[0]) - num(&z[1])).abs() < 1e-9, "{z2}");
|
||||
assert!(d.try_rect("program.playhead").is_none(), "a playhead off the zoomed ruler is not drawn");
|
||||
let source = d.ok("ui.inspect", json!({}))["ui"]["source"]["time_zoom"].clone();
|
||||
assert!(source.is_null(), "the Source monitor keeps its own range: {source}");
|
||||
|
||||
// an unusable range from ui.set shows all of it
|
||||
d.ok("ui.set", json!({"program": {"time_zoom": [0.9, 0.1]}}));
|
||||
d.frames(2);
|
||||
assert!((d.rect("program.zoomBar")[2] - track[2]).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnification_pans_and_guides_from_rulers() {
|
||||
let mut d = Driver::demo();
|
||||
|
||||
@@ -124,6 +124,17 @@ The playhead has drag priority above the range band and trim handles, including
|
||||
selection. Drag it to scrub; the unoccupied upper ruler can also scrub. Drag the gray band away
|
||||
from the playhead to translate the marked range.
|
||||
|
||||
## Zoom the time ruler
|
||||
|
||||
Under each monitor's time ruler is a zoom scroll bar like the Timeline's. Its thumb is the part of
|
||||
the clip or sequence the ruler shows: drag a handle at either end to zoom in or out, drag the
|
||||
middle to scroll, or press beside the thumb to move it there. A thumb filling the bar shows the
|
||||
whole duration; the ruler zooms in as far as five frames. The playhead, markers, In/Out and ticks
|
||||
follow the part shown, and clicking or scrubbing the ruler sets a time within it; what lies
|
||||
outside it is not drawn. Each monitor keeps its own range in its view state (`time_zoom`: start
|
||||
and end as fractions of the duration, `null` for all of it). Automation ids: `<monitor>.zoomBar`
|
||||
(the thumb) and `<monitor>.zoomBar.track`.
|
||||
|
||||
## Export Frame
|
||||
|
||||
The camera button in either monitor opens Export Frame. Drag its white header to move the dialog.
|
||||
|
||||
Reference in new issue
Block a user