diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 8f565a13..c1c1e73c 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -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, 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() { diff --git a/crates/ui-egui/src/panels/source_range.rs b/crates/ui-egui/src/panels/source_range.rs index 7b2167ec..d7bc66e9 100644 --- a/crates/ui-egui/src/panels/source_range.rs +++ b/crates/ui-egui/src/panels/source_range.rs @@ -50,7 +50,8 @@ pub struct Interaction { pub scrub: Option, } -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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2f780e11..289061a6 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -308,6 +308,9 @@ pub struct MonitorView { pub zoom: Option, /// 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. diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/monitor_geometry.rs index 974f94a3..700657e1 100644 --- a/crates/ui-egui/tests/monitor_geometry.rs +++ b/crates/ui-egui/tests/monitor_geometry.rs @@ -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 = 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 = 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] { diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 92b34a1f..0ab42929 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -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(); diff --git a/docs/monitors.md b/docs/monitors.md index f8cdd36d..774193c3 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -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: `.zoomBar` +(the thumb) and `.zoomBar.track`. + ## Export Frame The camera button in either monitor opens Export Frame. Drag its white header to move the dialog.