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:
ReReddo committed 2026-10-11 11:29:47 +03:00
1 parent 4e2d0bc2d3
commit a17b4292fe
6 files changed
+269 -53

No files matched your search

+163 -36
View File
@@ -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() {
+25 -17
View File
@@ -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
+4
View File
@@ -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.
+22
View File
@@ -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] {
+44
View File
@@ -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();
+11
View File
@@ -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.