Panels: every scrolling panel has a scroll bar to drag (#271)

Most panels lay out their own rows and scrolled only with the mouse wheel: Effect Controls,
Effects & Presets, the Media Browser, Ease Presets and the Timeline's layer rows had no scroll
bar, Properties and Preview a thin indicator that couldn't be dragged, and Render Queue, History,
Markers, Character, Paragraph and Info didn't scroll at all, so a panel shorter than its content
cut it off. Without a wheel or a working touchpad there was no way down. The Project panel had
the only real scroll bars, hand-rolled, and its vertical one was hit-tested under the rows, so it
only dragged where no row was.

- widgets::scroll_bar: the Project panel's bars generalised (horizontal or vertical, drag the
  thumb or press the track to move it there, automation id with "scroll/max"); the Project
  panel's two bars and the Timeline's column bar use it, drawn over the rows.
- widgets::PanelScroll: the wheel offset, its clamping and the bar for a panel's own rows, used by
  Effect Controls, Properties, Effects & Presets, Media Browser, Ease Presets, Preview, Render
  Queue, History (which still follows the current state), Markers, Character, Paragraph and Info.
  The Timeline's layer rows get a bar on the right edge.
- Effect Controls: an animated vector's values (a puppet pin's Position) ran under the keyframe
  navigator in a narrow panel. They now end before it, moving left over the name, which is cut
  short.

Test: ui_scrolling panels_taller_than_their_space_have_a_scroll_bar_to_drag (eleven panels in a
short window: a press on the track and a thumb drag scroll them),
effect_values_stay_clear_of_the_keyframe_navigator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 18:40:05 +07:00
co-authored by Claude Opus 5.5
parent 6d2ef5a830
commit 11f3736025
13 changed files with 275 additions and 128 deletions
+3 -9
View File
@@ -198,16 +198,12 @@ fn graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Rect, curve: &mut EaseC
fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Option<&str>) -> Option<(String, EaseCurve)> {
let t = app.tokens;
let p = ui.painter().with_clip_rect(list);
let scroll_id = egui::Id::new("easePresets.scroll");
let mut scroll: f32 = ui.ctx().data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ease-presets-scroll"), list);
let cols = (((list.width() + GAP) / (TILE.x + GAP)).floor() as usize).max(1);
let (mut clicked, mut count) = (None, 0);
for (n, (name, curve, built_in)) in ease_presets::presets(&app.session).enumerate() {
count = n + 1;
let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll), TILE);
let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll.offset), TILE);
if !r.intersects(list) {
continue;
}
@@ -242,8 +238,6 @@ fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Optio
clicked = Some((name.to_string(), curve));
}
}
let content = count.div_ceil(cols) as f32 * (TILE.y + GAP);
scroll = scroll.min((content - list.height()).max(0.0));
ui.ctx().data_mut(|d| d.insert_temp(scroll_id, scroll));
scroll.end(ui, &mut app.auto, "easePresets.scroll", count.div_ceil(cols) as f32 * (TILE.y + GAP), &t);
clicked
}
+31 -13
View File
@@ -182,10 +182,27 @@ fn prop_row(
if prop.is_animated() {
key_navigator(app, ui, p, layer, prop, ectx, r, actions);
}
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0, r.max.y));
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0);
let value = ectx.value(layer, prop);
let vx = (r.min.x + r.width() * 0.48).max(swr.max.x + 130.0);
// A vector's values end before the keyframe navigator: in a narrow panel they move left over
// the name (cut short) instead of running under the navigator (#271).
let vx = match &value {
Value::Vec2(_) | Value::Vec3(_) => {
let point = if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { 22.0 } else { 0.0 };
let fields: f32 = value
.components()
.iter()
.take(shown_dims(layer, prop, &value))
.map(|v| p.layout_no_wrap(format!("{v:.1}"), Tokens::ui(12.0), t.hot_text).size().x + 12.0)
.sum();
let right = r.max.x - if prop.is_animated() { 52.0 } else { 8.0 };
vx.min(right - point - fields + 8.0).max(swr.max.x + 60.0)
}
_ => vx,
};
let name_clip =
Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0).min(vx - 6.0), r.max.y));
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
let merge = format!("ec-{uid}");
let set =
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
@@ -264,7 +281,7 @@ fn prop_row(
}
Value::Vec2(_) | Value::Vec3(_) => {
let c = value.components();
let n = if prop.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
let n = shown_dims(layer, prop, &value);
let mut x = vx;
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) {
let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
@@ -380,6 +397,12 @@ fn prop_row(
}
}
/// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer).
fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize {
let n = value.components().len();
if prop.shown_dims > 0 && !(layer.is_3d() && n == 3) { prop.shown_dims as usize } else { n }
}
/// Colour of a gradient at `f` (colour stops only).
fn gradient_at(g: &effectcraft_engine::keyframe::Gradient, f: f64) -> Color32 {
let stops = &g.colors;
@@ -906,12 +929,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max);
let scroll_id = egui::Id::new("ec-scroll");
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(body) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let mut y = body.min.y + 4.0 - scroll;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ec-scroll"), body);
let mut y = body.min.y + 4.0 - scroll.offset;
let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body);
// Rows scroll under the header: clip their widgets (and hit tests) to the body.
@@ -1074,9 +1093,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.extend(crate::menus::menu_contents(app, ui, "Effect"));
});
ui.set_clip_rect(panel_clip);
let content_h = y + scroll - body.min.y;
scroll = scroll.min((content_h - body.height()).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
let content_h = y + scroll.offset - body.min.y;
scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t);
// Drop effects here.
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
&& ui.rect_contains_pointer(rect)
+5 -10
View File
@@ -313,14 +313,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let view = app.ui.effects_view.clone();
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
let lp = p.with_clip_rect(list);
let scroll_id = egui::Id::new("fx-scroll");
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("fx-scroll"), list);
let query = q.trim().to_lowercase();
let searching = !query.is_empty();
let mut y = list.min.y - scroll;
let mut y = list.min.y - scroll.offset;
let mut apply: Option<Apply> = None;
let reg = effectcraft_engine::effects::all();
let keep =
@@ -397,12 +393,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
if y == list.min.y - scroll && searching {
if y == list.min.y - scroll.offset && searching {
lp.text(pos2(list.center().x, list.min.y + 24.0), Align2::CENTER_CENTER, "No matching effects or presets", Tokens::ui(12.0), t.text_faint);
}
let content = y + scroll - list.min.y;
scroll = scroll.min((content - list.height()).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
let content = y + scroll.offset - list.min.y;
scroll.end(ui, &mut app.auto, "effects.scroll", content, &t);
if let Some(DragPayload::Effect(id)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
&& let Some(pos) = ctx.pointer_hover_pos()
{
+7 -2
View File
@@ -2,14 +2,15 @@
use egui::{Align2, Color32, Rect, pos2, vec2};
use crate::EffectcraftApp;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 12.0;
let mut y = rect.min.y + 14.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("info-scroll"), rect);
let mut y = rect.min.y + 14.0 - scroll.offset;
if app.pointer_comp.is_some() {
// GPU frames: read the shown frame back for sampling.
app.viewer_pixels();
@@ -60,6 +61,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += 82.0;
p.line_segment([pos2(rect.min.x + 8.0, y), pos2(rect.max.x - 8.0, y)], egui::Stroke::new(1.0, t.separator));
y += 14.0;
let mut bottom = y;
let comp = app.session.active_comp_arc();
if let Some(c) = comp {
let sel: Vec<String> = app.session.state.selected_layers.iter().filter_map(|id| c.layer(*id)).map(|l| l.name.clone()).collect();
@@ -121,5 +123,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len()));
p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim);
}
bottom = ly + 30.0;
}
let content = bottom - (rect.min.y - scroll.offset);
scroll.end(ui, &mut app.auto, "info.scroll", content, &t);
}
+4 -10
View File
@@ -17,8 +17,8 @@ use serde_json::{Value, json};
use super::DragPayload;
use super::panel_kit as kit;
use crate::EffectcraftApp;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
// ---------------------------------------------------------------- Media Browser
@@ -159,19 +159,12 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tile = vec2(140.0, 112.0);
let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize;
let mut budget = 2u32;
let scroll_id = egui::Id::new("mb-scroll");
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(list) {
scroll -= ctx.input(|i| i.smooth_scroll_delta.y);
}
let rows = entries.len().div_ceil(cols);
scroll = scroll.clamp(0.0, (rows as f32 * tile.y - list.height() + 8.0).max(0.0));
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("mb-scroll"), list);
let lp = p.with_clip_rect(list);
let mut actions: Vec<(&str, Value)> = vec![];
for (i, e) in entries.iter().enumerate() {
let r = Rect::from_min_size(
pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll),
pos2(list.min.x + 8.0 + (i % cols) as f32 * tile.x, list.min.y + 8.0 + (i / cols) as f32 * tile.y - scroll.offset),
tile - vec2(8.0, 8.0),
);
if !r.intersects(list) {
@@ -232,6 +225,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if entries.is_empty() {
lp.text(list.center(), Align2::CENTER_CENTER, "Empty folder", Tokens::ui(12.0), t.text_faint);
}
scroll.end(ui, &mut app.auto, "mediaBrowser.scroll", entries.len().div_ceil(cols) as f32 * tile.y + 8.0, &t);
if budget == 0 {
ctx.request_repaint();
}
+29 -23
View File
@@ -23,14 +23,10 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let p = ui.painter().with_clip_rect(rect);
// The panel scrolls when it is shorter than its controls (the default workspace shows just
// the transport row, as in After Effects).
let scroll_id = egui::Id::new("preview-scroll");
let mut scroll: f32 = ctx.data(|d| d.get_temp(scroll_id).unwrap_or(0.0));
if ui.rect_contains_pointer(rect) {
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
}
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("preview-scroll"), rect);
let panel_clip = ui.clip_rect();
ui.set_clip_rect(rect.intersect(panel_clip));
let y = rect.min.y + 12.0 - scroll;
let y = rect.min.y + 12.0 - scroll.offset;
let bw = 30.0;
let total = bw * 5.0 + 16.0;
let mut x = rect.center().x - total / 2.0;
@@ -231,17 +227,8 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(bar.min.x, bar.max.y + 12.0), Align2::LEFT_CENTER, format!("{cached} / {total} frames cached{status}"), Tokens::ui(11.0), t.text_faint);
}
ui.set_clip_rect(panel_clip);
let content = yy + 30.0 - (rect.min.y - scroll);
let max = (content - rect.height()).max(0.0);
scroll = scroll.min(max);
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
if max > 0.0 {
// A thin scroll indicator on the right edge.
let h = (rect.height() * rect.height() / content).max(12.0);
let top = rect.min.y + (rect.height() - h) * (scroll / max);
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, top), vec2(3.0, h)), 1.5, t.text_faint.gamma_multiply(0.6));
app.auto.add("preview.scroll", Rect::from_min_size(pos2(rect.max.x - 6.0, rect.min.y), vec2(6.0, rect.height())), "Preview scroll");
}
let content = yy + 30.0 - (rect.min.y - scroll.offset);
scroll.end(ui, &mut app.auto, "preview.scroll", content, &t);
}
fn fmt_rate(r: f64) -> String {
@@ -382,12 +369,24 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let nodes = app.session.history_tree();
let row_h = 22.0;
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
// Keep the current state in view.
let visible = ((rect.height() - 12.0) / row_h).max(1.0) as usize;
let first = (cur + 2).saturating_sub(visible).min(nodes.len().saturating_sub(visible));
let mut y = rect.min.y + 6.0;
let scroll_id = egui::Id::new("history-scroll");
let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, rect);
// The current state scrolls into view when it changes (undo, redo, a new step).
if ui.data(|d| d.get_temp::<usize>(scroll_id.with("current"))) != Some(cur) {
ui.data_mut(|d| d.insert_temp(scroll_id.with("current"), cur));
let top = cur as f32 * row_h;
if top < scroll.offset {
scroll.offset = top;
} else if top + row_h + 12.0 > scroll.offset + rect.height() {
scroll.offset = top + row_h + 12.0 - rect.height();
}
}
// Only the rows in view are drawn.
let first = ((scroll.offset / row_h) as usize).min(nodes.len());
let mut y = rect.min.y + 6.0 - (scroll.offset - first as f32 * row_h);
let visible = (rect.height() / row_h) as usize + 2;
let mut jump: Option<usize> = None;
for n in nodes.iter().skip(first).take(visible + 1) {
for n in nodes.iter().skip(first).take(visible) {
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h));
y += row_h;
let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click());
@@ -410,6 +409,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
jump = Some(n.index);
}
}
scroll.end(ui, &mut app.auto, "history.scroll", nodes.len() as f32 * row_h + 12.0, &t);
if let Some(i) = jump {
let ctx = ui.ctx().clone();
if let Err(e) = crate::menus::invoke(app, &ctx, "edit.history.goto", serde_json::json!({"index": i})) {
@@ -422,13 +422,17 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let Some(c) = app.session.active_comp_arc() else { return };
let mut y = rect.min.y + 8.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("markers-scroll"), rect);
let mut y = rect.min.y + 8.0 - scroll.offset;
if c.markers.is_empty() {
p.text(rect.center(), Align2::CENTER_CENTER, "No composition markers", Tokens::ui(12.0), t.text_faint);
}
for m in &c.markers {
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 22.0));
y += 22.0;
if !r.intersects(rect) {
continue;
}
let tc = crate::panels::timecode(&app.session, &c, m.time);
p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(11.5), t.timecode);
p.text(pos2(r.min.x + 120.0, r.center().y), Align2::LEFT_CENTER, &m.comment, Tokens::ui(12.0), t.text);
@@ -436,4 +440,6 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.set_time(m.time);
}
}
let content = y + 8.0 - (rect.min.y - scroll.offset);
scroll.end(ui, &mut app.auto, "markers.scroll", content, &t);
}
+9 -29
View File
@@ -175,12 +175,6 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect {
Rect::from_center_size(r.center(), vec2(w * s, h * s))
}
/// A scroll bar thumb's length on a `track` long: the share of `content` in view, at least 16
/// points but never longer than the track (a panel only a few points tall still draws, #231).
fn thumb_len(track: f32, visible: f32, content: f32) -> f32 {
(track * visible / content).max(16.0).min(track).max(0.0)
}
/// An inline text edit in progress: (item, field `name`|`comment`, text).
type Editing = (u64, String, String);
fn edit_id() -> egui::Id {
@@ -345,18 +339,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
}
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
let tw = thumb_len(track.width(), track.width(), track.width() + overflow);
let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height()));
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag());
p.rect_filled(track, 2.0, t.field_bg);
p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint });
app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}"));
if sresp.dragged() {
app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow);
}
}
let rows = visible_rows(app);
// Enter renames the selected item (Project panel focused, not typing).
if app.ui.focused == crate::dock::PanelKind::Project
@@ -401,17 +383,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
let scroll = app.ui.project_scroll;
if max_scroll > 0.0 {
let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
let th = thumb_len(track.height(), list.height(), content_h);
let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th));
let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag());
p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint });
app.auto.add("project.vscroll", track, &format!("{scroll}/{max_scroll}"));
if vresp.dragged() {
app.ui.project_scroll = (scroll + vresp.drag_delta().y * max_scroll / (track.height() - th).max(1.0)).clamp(0.0, max_scroll);
}
}
let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
let mut y = list.min.y - (scroll - first as f32 * ROW_H);
let project = app.session.project.clone();
@@ -635,6 +606,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
});
}
// The scroll bars, over the rows (which would take their presses otherwise).
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
}
if max_scroll > 0.0 {
let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
}
// Dropping a dragged item on the list: into the folder under the pointer (or the folder of
// the item under it), or the project root below the rows.
if let Some(iid) = dragging
+3 -17
View File
@@ -66,14 +66,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.
let scroll_id = egui::Id::new("props-scroll");
let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0));
if ui.rect_contains_pointer(rect) {
scroll -= ui.input(|i| i.smooth_scroll_delta.y);
}
scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0));
let top = rect.min.y + 8.0 - scroll;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("props-scroll"), rect);
let top = rect.min.y + 8.0 - scroll.offset;
let mut y = top;
let mut actions: Actions = vec![];
@@ -153,15 +147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 40.0;
let content_h = y - top;
ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h)));
if content_h > rect.height() {
// Thin scroll indicator.
let frac = rect.height() / content_h;
let bar_h = (rect.height() * frac).max(24.0);
let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0));
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6));
}
scroll.end(ui, &mut app.auto, "properties.scroll", y - top, &t);
for (id, params) in actions {
let params = match &text_target {
Some(tt) if id == "layer.setText" => tt.params(params),
+8 -1
View File
@@ -527,7 +527,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---------------------------------------------------------- items
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
let lp = p.with_clip_rect(list);
let mut y = list.min.y;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("rq-scroll"), list);
// Items scroll under the header and footer: clip their widgets (and hit tests) to the list.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(list.intersect(panel_clip));
let mut y = list.min.y - scroll.offset;
if queue.is_empty() {
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, "The render queue is empty.", Tokens::ui(12.0), t.text_faint);
}
@@ -746,6 +750,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
ui.set_clip_rect(panel_clip);
let content = y + scroll.offset - list.min.y;
scroll.end(ui, &mut app.auto, "renderQueue.scroll", content, &t);
ui.data_mut(|d| match &editing {
Some(e) => {
d.insert_temp(editing_id, e.clone());
+11 -2
View File
@@ -86,7 +86,9 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let target = text_target(app);
let enabled = target.is_some();
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let mut y = rect.min.y + 10.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("character-scroll"), rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let mut actions: Vec<serde_json::Value> = vec![];
@@ -279,7 +281,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(json!({"variations": {tag.clone(): nv}, "merge": format!("char-axis-{tag}")}));
}
}
y += 20.0 + axes.len().div_ceil(2) as f32 * 24.0;
}
// (Room for the status line at the bottom.)
scroll.end(ui, &mut app.auto, "character.scroll", y + 44.0 - top, &t);
match &target {
None => {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
@@ -441,7 +446,9 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let mut y = rect.min.y + 10.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("paragraph-scroll"), rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let mut actions: Vec<serde_json::Value> = vec![];
let bw = ((w - 6.0 * 3.0) / 7.0).clamp(20.0, 28.0);
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
@@ -530,6 +537,8 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, "Roman Hanging Punctuation", Tokens::ui(11.5), t.text_dim);
// (Room for the status line at the bottom.)
scroll.end(ui, &mut app.auto, "paragraph.scroll", y + 44.0 - top, &t);
if !enabled {
p.text(pos2(rect.center().x, rect.max.y - 20.0), Align2::CENTER_CENTER, "Select a text layer", Tokens::ui(11.0), t.text_faint);
}
+6 -12
View File
@@ -2440,18 +2440,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if overflow > 0.0 {
// Outline scroll bar along the top of the footer, under the columns.
let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0));
let frac = left_w / natural_w;
let tw = (track.width() * frac).max(20.0);
let tx = track.min.x + (track.width() - tw) * (oscroll / overflow);
let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height()));
p.rect_filled(track, 2.0, t.field_bg);
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag());
p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint });
app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}"));
if sresp.dragged() {
let k = overflow / (track.width() - tw).max(1.0);
app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow);
}
app.ui.timeline.outline_scroll = widgets::scroll_bar(ui, &mut app.auto, "timeline.outlineScroll", track, oscroll, overflow, &t);
}
if max_scroll > 0.0 {
// The layer rows' scroll bar, on the right edge beside the time graph.
let track = Rect::from_min_max(pos2(rect.max.x - 6.0, rows_rect.min.y + 1.0), pos2(rect.max.x - 2.0, rows_rect.max.y - 1.0));
app.ui.timeline.scroll_y = widgets::scroll_bar(ui, &mut app.auto, "timeline.scroll", track, app.ui.timeline.scroll_y, max_scroll, &t);
}
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click());
+74
View File
@@ -2,6 +2,7 @@
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use crate::automation::Registry;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
@@ -277,6 +278,79 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) ->
resp
}
/// A scroll bar along `track` (vertical when it is taller than wide) for content scrolled
/// `scroll` of `max`, registered for automation as `key` ("scroll/max"): drag the thumb, or
/// press the track beside it to centre the thumb there. The thumb's length is the share in view
/// (the view is about as long as its track), at least 16 points but never longer than the track
/// (a panel only a few points tall still draws, #231). Returns the new scroll.
pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scroll: f32, max: f32, t: &Tokens) -> f32 {
if !track.is_positive() {
// (A panel too short for its bar.)
return scroll.clamp(0.0, max.max(0.0));
}
let vertical = track.height() >= track.width();
let a = usize::from(vertical);
let len = track.size()[a];
let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
let travel = (len - thumb_len).max(1.0);
let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag());
let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
let mut s = scroll;
if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
s = (p[a] - thumb_len / 2.0 - track.min[a]) * max / travel;
} else if resp.dragged() {
s += resp.drag_delta()[a] * max / travel;
}
let s = s.clamp(0.0, max.max(0.0));
let thumb = if vertical {
Rect::from_min_size(pos2(track.min.x, thumb_at(s)), vec2(track.width(), thumb_len))
} else {
Rect::from_min_size(pos2(thumb_at(s), track.min.y), vec2(thumb_len, track.height()))
};
ui.painter().rect_filled(track, 2.0, t.field_bg);
ui.painter().rect_filled(thumb, 2.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_faint });
auto.add(key, track, &format!("{s}/{max}"));
if s != scroll {
ui.ctx().request_repaint();
}
s
}
/// Vertical scrolling for a panel that lays out its own rows: [`PanelScroll::begin`] gives the
/// offset to draw them at (moved by the mouse wheel over `area`), [`PanelScroll::end`] takes the
/// rows' height and draws a [`scroll_bar`] on the right edge while they overflow, so every
/// panel can be scrolled without a wheel or touchpad (#271).
pub struct PanelScroll {
id: egui::Id,
area: Rect,
/// How far the rows are scrolled up.
pub offset: f32,
}
impl PanelScroll {
pub fn begin(ui: &Ui, id: egui::Id, area: Rect) -> Self {
// (Clamped to last frame's overflow: the rows' height is known once they are drawn.)
let (mut offset, max): (f32, f32) = ui.data(|d| d.get_temp(id)).unwrap_or_default();
if ui.rect_contains_pointer(area) {
offset -= ui.input(|i| i.smooth_scroll_delta.y);
}
Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }
}
/// The rows were `content` points tall: keep the offset, with the scroll bar registered for
/// automation as `key`.
pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) {
let max = (content - self.area.height()).max(0.0);
let mut offset = self.offset.min(max);
if max > 0.0 {
let track = Rect::from_min_max(pos2(self.area.max.x - 6.0, self.area.min.y + 1.0), pos2(self.area.max.x - 2.0, self.area.max.y - 1.0));
offset = scroll_bar(ui, auto, key, track, offset, max, t);
}
ui.data_mut(|d| d.insert_temp(self.id, (offset, max)));
}
}
/// A pointer press this frame landed outside a popup `Area` (its `show` response). egui
/// hit-tests a fixed-position area where it was asked to go, not where it was moved to fit the
/// window, so `contains_pointer` / `hovered` are false over a menu shifted up from the bottom of
+85
View File
@@ -58,3 +58,88 @@ fn a_menu_taller_than_the_window_scrolls() {
click_at(&mut h, last.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click");
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=8 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// A scroll bar's "scroll/max" automation label.
fn scrolled(h: &Harness<'_, EffectcraftApp>, id: &str) -> (f32, f32) {
let label = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).label.clone();
let (s, max) = label.split_once('/').unwrap_or_else(|| panic!("{id}: {label}"));
(s.parse().unwrap(), max.parse().unwrap())
}
/// Every panel that scrolls its own rows shows a scroll bar while they overflow, and dragging it
/// scrolls them: without a mouse wheel or touchpad there was no way down most panels (#271).
#[test]
fn panels_taller_than_their_space_have_a_scroll_bar_to_drag() {
use effectcraft_ui_egui::dock::PanelKind;
// (The Project panel's list starts below its item preview and search: a taller window.)
for (panel, id, height) in [
(PanelKind::EffectControls, "effectControls.scroll", 240.0),
(PanelKind::Properties, "properties.scroll", 240.0),
(PanelKind::EffectsPresets, "effects.scroll", 240.0),
(PanelKind::Project, "project.vscroll", 330.0),
(PanelKind::Timeline, "timeline.scroll", 240.0),
(PanelKind::RenderQueue, "renderQueue.scroll", 240.0),
(PanelKind::History, "history.scroll", 240.0),
(PanelKind::Preview, "preview.scroll", 240.0),
(PanelKind::Character, "character.scroll", 240.0),
(PanelKind::Paragraph, "paragraph.scroll", 240.0),
(PanelKind::Info, "info.scroll", 240.0),
] {
let mut h = harness(1200.0, height);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
for e in ["slider", "angle", "checkbox", "color", "point"] {
app.session.execute("effect.apply", json!({"effect": format!("ec.control.{e}"), "layers": [lid]})).unwrap();
}
for _ in 0..6 {
app.session.execute("renderQueue.add", json!({})).unwrap();
}
app.show_panel(panel);
app.ui.maximized = Some(panel);
h.run_steps(3);
let (_, max) = scrolled(&h, id);
assert!(max > 0.0, "{id}: the rows overflow");
let bar = rect(&h, id);
// A press on the track moves the thumb there (History starts at its current state, the
// last); then the thumb drags to the end.
click_at(&mut h, pos2(bar.center().x, bar.min.y + 2.0));
assert_eq!(scrolled(&h, id).0, 0.0, "{id}: at the top");
drag(&mut h, pos2(bar.center().x, bar.min.y + 6.0), pos2(bar.center().x, bar.max.y + 40.0));
assert_eq!(scrolled(&h, id).0, max, "{id}: dragged to the end");
}
}
/// In a narrow Effect Controls panel an animated point's values stay clear of its keyframe
/// navigator (â—€ â—† â–¶), which was drawn over them (#271).
#[test]
fn effect_values_stay_clear_of_the_keyframe_navigator() {
use effectcraft_ui_egui::dock::PanelKind;
let mut h = harness(1600.0, 1000.0);
let app = h.state_mut();
let lid = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [lid]})).unwrap();
app.session.execute("effect.apply", json!({"effect": "ec.control.point", "layers": [lid]})).unwrap();
let path = "effects/#1/point";
app.session.execute("prop.toggleAnimation", json!({"layer": lid, "path": path})).unwrap();
app.session.execute("prop.set", json!({"layer": lid, "path": path, "value": [123456.7, 123456.7]})).unwrap();
let uid = app.session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(lid)).unwrap().props.prop(path).unwrap().uid;
assert!(app.edit_layout(|l| l.float(PanelKind::EffectControls, [300.0, 150.0, 330.0, 500.0])));
h.run_steps(3);
let value = rect(&h, &format!("effectControls.prop.{uid}.value.1"));
let nav = rect(&h, &format!("effectControls.prop.{uid}.prevKey"));
assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})");
assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch");
}