Merge pull request #277 from storytold/fix/269-271-272-ui

UI: long menus scroll, panels get scroll bars, Learn closes when leaving it (#269, #271, #272)
This commit is contained in:
BFlat 2026-10-08 19:15:07 +07:00 committed by GitHub
commit fbc4080f53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 518 additions and 261 deletions

View File

@ -383,16 +383,21 @@ impl EffectcraftApp {
}
pub fn set_workspace(&mut self, name: &str) {
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name));
self.ui.maximized = None;
// Learn: the Home screen's Learn tab (tutorials) in the Composition panel.
// Learn: the Home screen's Learn tab (tutorials) in the Composition panel. Leaving it for
// another workspace closes the Home screen again: it stayed over every workspace until a
// project was created (#272).
if name == "Learn" {
self.ui.start_screen = true;
self.ui.home_learn = true;
self.ui.home_templates = false;
} else if self.ui.workspace == "Learn" {
self.ui.start_screen = false;
self.ui.home_learn = false;
}
self.ui.workspace = name.to_string();
self.ui.dock = self.ui.saved_workspaces.get(name).cloned().unwrap_or_else(|| dock::workspace(name));
self.ui.floating = self.ui.saved_floating.get(name).cloned().unwrap_or_else(|| dock::workspace_floating(name));
self.ui.maximized = None;
}
/// Built-in workspaces followed by the saved ones (Window ▸ Workspace ▸ Save as New Workspace).
@ -1543,6 +1548,7 @@ impl eframe::App for EffectcraftApp {
self.apply_gpu_failure(ctx);
if !self.styled {
theme::install(ctx, &self.tokens);
fit_window(ctx);
self.styled = true;
ctx.request_repaint();
} else {
@ -1598,6 +1604,39 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale
app.advance_playback(ctx, scale);
}
/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the
/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's
/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the
/// screen (#269).
fn fit_window(ctx: &egui::Context) {
let (monitor, window) = ctx.input(|i| (i.viewport().monitor_size, i.viewport().outer_rect.or(i.viewport().inner_rect)));
if let (Some(monitor), Some(window)) = (monitor, window)
&& overflows_screen(window.size(), monitor)
{
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
}
}
/// Whether a window `size` leaves no room on a `monitor` for a taskbar or dock (points).
fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool {
const TASKBAR: f32 = 64.0;
size.x > monitor.x || size.y > monitor.y - TASKBAR
}
#[cfg(test)]
mod fit_window_tests {
use super::overflows_screen;
use egui::vec2;
#[test]
fn a_window_taller_than_the_screen_beside_its_taskbar_overflows() {
// The default window on a 1366 × 768 laptop (shrunk to the monitor) and on 1080p.
assert!(overflows_screen(vec2(1366.0, 799.0), vec2(1366.0, 768.0)));
assert!(overflows_screen(vec2(1680.0, 1051.0), vec2(1920.0, 1080.0)));
assert!(!overflows_screen(vec2(1680.0, 1051.0), vec2(2560.0, 1440.0)));
}
}
#[cfg(test)]
mod gpu_failure_tests {
use super::*;

View File

@ -1537,7 +1537,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
if let MenuNode::Submenu { label, children } = node {
let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
menu_nodes(app, ui, children, &mut clicked);
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked));
});
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
}
@ -1604,12 +1604,7 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[])));
ui.menu_button((gutter(false), shown.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
// Long submenus (Blending Mode, effect categories) scroll instead of running
// off the screen. Others show whole: egui sizes a new submenu from a default
// 400 pt area, so without a minimum a longer one (Window ▸ Workspace) got
// stuck at that height with its last entries scrolled out of view (#191).
let max_h = ui.ctx().content_rect().height() - 40.0;
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, |ui| menu_nodes(app, ui, children, clicked));
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked));
});
}
MenuNode::Item(e) => {

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
}

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)

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()
{

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);
}

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();
}

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 {
@ -268,28 +255,21 @@ fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str,
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
}
app.auto.add(&format!("preview.{key}"), r, text);
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
if !open {
if !widgets::popup_is_open(ui, pid) {
return None;
}
let mut chosen = None;
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(r.left_bottom() + vec2(0.0, 2.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(r.width().max(140.0));
for (i, label) in items.iter().enumerate() {
let resp = ui.selectable_label(current == Some(i), label.as_str());
app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label);
if resp.clicked() {
chosen = Some(i);
}
widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, items.len(), 0, |ui| {
ui.set_min_width(r.width().max(140.0));
let mut chosen = None;
for (i, label) in items.iter().enumerate() {
let resp = ui.selectable_label(current == Some(i), label.as_str());
app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label);
if resp.clicked() {
chosen = Some(i);
}
});
});
let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !r.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
}
chosen
}
chosen
})
}
/// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the
@ -389,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());
@ -417,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})) {
@ -429,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);
@ -443,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);
}

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

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),

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());

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);
}
@ -544,51 +553,44 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// The font menu: recent fonts first, names in English or the fonts' own language, and a
/// "Sample" preview in each font (Settings ▸ Type).
fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option<String> {
if !ui.data(|d| d.get_temp::<bool>(id.with("open")).unwrap_or(false)) {
if !widgets::popup_is_open(ui, id) {
return None;
}
let rows = effectcraft_engine::font_menu(&app.session.prefs);
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
let mut chosen = None;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
let seps = rows.iter().filter(|r| r.family.is_empty()).count();
widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
let mut chosen = None;
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
});
});
});
let outside = widgets::pressed_outside(ui.ctx(), &area.response);
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
}
chosen
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
}
chosen
})
}
/// Align panel: Align Layers to Selection / Composition, the six align buttons and the six

View File

@ -1934,12 +1934,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::Popup::menu(&lang).show(|ui| {
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
ui.menu_button(cat, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
ui.close();
widgets::menu_scroll(ui, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
ui.close();
}
}
}
})
});
}
});
@ -2438,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());
@ -2958,12 +2954,14 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S
item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers}));
});
ui.menu_button("Blending Mode", |ui| {
for m in BlendMode::ALL {
item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()}));
if m.ends_group() {
ui.separator();
widgets::menu_scroll(ui, |ui| {
for m in BlendMode::ALL {
item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()}));
if m.ends_group() {
ui.separator();
}
}
}
})
});
ui.menu_button("Arrange", |ui| {
item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"}));

View File

@ -962,12 +962,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
if !hits.is_empty() {
ui.menu_button("Select", |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
ui.close();
crate::widgets::menu_scroll(ui, |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
ui.close();
}
}
}
})
});
ui.separator();
}

View File

@ -455,34 +455,27 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
/// `viewer.<auto>.<n>` automation ids.
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
let pid = egui::Id::new(id);
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
if !open {
if !widgets::popup_is_open(ui, pid) {
return None;
}
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
let pos = anchor.left_top() - vec2(0.0, h + 4.0);
let mut chosen = None;
let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(170.0);
for (i, (label, on)) in items.iter().enumerate() {
if label == "-" {
ui.separator();
continue;
}
let r = ui.selectable_label(*on, label.as_str());
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
if r.clicked() {
chosen = Some(i);
}
let seps = items.iter().filter(|(l, _)| l == "-").count();
widgets::popup_list(ui, pid, anchor.left_top() - vec2(0.0, h + 4.0), anchor, items.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(170.0);
let mut chosen = None;
for (i, (label, on)) in items.iter().enumerate() {
if label == "-" {
ui.separator();
continue;
}
});
});
let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default());
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(pid.with("open"), false));
}
chosen
let r = ui.selectable_label(*on, label.as_str());
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
if r.clicked() {
chosen = Some(i);
}
}
chosen
})
}
fn toggle_popup(ui: &egui::Ui, id: &str) {

View File

@ -345,6 +345,9 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
s.spacing.button_padding = egui::vec2(8.0, 3.0);
s.spacing.interact_size.y = 22.0;
s.spacing.menu_margin = egui::Margin::same(5);
// Scroll bars always show (egui's float in only over the list), so long menus and lists
// can be dragged without a mouse wheel or touchpad (#269).
s.spacing.scroll = egui::style::ScrollStyle::solid();
s.text_styles.insert(TextStyle::Body, FontId::new(12.0, FontFamily::Proportional));
s.text_styles.insert(TextStyle::Button, FontId::new(12.0, FontFamily::Proportional));
s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional));

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
@ -293,46 +367,60 @@ pub fn check_label(on: bool, label: &str) -> String {
/// Show a popup menu anchored at `pos` with string options; returns the chosen index.
pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option<usize>) -> Option<usize> {
let mut chosen = None;
let open_id = id.with("open");
let open: bool = ui.data(|d| d.get_temp(open_id).unwrap_or(false));
if !open {
if !popup_is_open(ui, id) {
return None;
}
// As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond,
// and moved up when it would run past the bottom of the window (the Timeline's menus).
let seps = options.iter().filter(|o| *o == "-").count();
popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(160.0);
let mut chosen = None;
for (i, o) in options.iter().enumerate() {
if o == "-" {
ui.separator();
} else if ui.selectable_label(current == Some(i), o).clicked() {
chosen = Some(i);
}
}
chosen
})
}
/// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators,
/// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape
/// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when
/// it would run past the bottom of the window (the Timeline's menus, #269). Closes when `body`
/// returns a choice, on Escape, or on a press outside it and outside `anchor` (a control that
/// toggles the list itself). Returns the choice.
pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, rows: usize, seps: usize, body: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
let screen = ui.ctx().content_rect();
let max_h = (screen.height() - 24.0).max(120.0);
let sp = ui.spacing();
let seps = options.iter().filter(|o| *o == "-").count() as f32;
let rows = options.len() as f32 - seps;
let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0);
let est = (rows as f32 * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps as f32 * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0);
let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top()));
let mut chosen = None;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(160.0);
// (An area's content is laid out in last frame's size: ask for the estimate.)
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if o == "-" {
ui.separator();
continue;
}
let sel = current == Some(i);
if ui.selectable_label(sel, o).clicked() {
chosen = Some(i);
}
}
});
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui));
});
});
let clicked_outside = pressed_outside(ui.ctx(), &area.response);
if chosen.is_some() || clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(open_id, false));
let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p));
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
}
chosen
}
/// A menu's (or submenu's) entries, scrolling with a scroll bar when they are taller than the
/// window instead of running off it (Edit on a short screen, #269; Blending Mode). Others show
/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one
/// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view
/// (#191).
pub fn menu_scroll<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let max_h = ui.ctx().content_rect().height() - 40.0;
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner
}
/// Whether a [`popup_menu`] is open (build its options only then: they can be long).
pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool {
ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false))

View File

@ -153,6 +153,20 @@ fn saved_workspace_is_listed_in_the_workspace_menu() {
assert_eq!(h.state().ui.dock, saved, "its layout came back");
}
/// Learn opens the Home screen's tutorials; clicking another workspace tab closes them again,
/// however quickly the tabs are clicked (they stayed over every workspace, #272).
#[test]
fn leaving_the_learn_workspace_closes_its_tutorials() {
let mut h = harness();
for name in ["Learn", "Default", "Learn", "Review", "Learn", "Learn", "Small Screen", "Standard", "Learn", "Default"] {
click(&mut h, &format!("header.workspace.{name}"));
assert_eq!(h.state().ui.workspace, name);
let learn = name == "Learn";
assert_eq!(h.state().ui.start_screen, learn, "{name}: the Home screen shows only for Learn");
assert_eq!(!h.state().auto.query("home.learn.").is_empty(), learn, "{name}: the tutorials show only for Learn");
}
}
/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with
/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`.
#[test]

View File

@ -0,0 +1,145 @@
//! Long menus and lists scroll instead of running off the window, with a scroll bar to drag
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(w, h)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
h
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, dy),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
});
h.run_steps(20);
}
/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always
/// shows) so its last entries can be reached and clicked, instead of running off the bottom of
/// the window (#269).
#[test]
fn a_menu_taller_than_the_window_scrolls() {
let mut h = harness(1200.0, 520.0);
assert!(!h.ctx.global_style().spacing.scroll.floating, "scroll bars show without hovering the list");
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
assert!(last.max.y > 520.0, "Edit is taller than the window ({last:?})");
let first = h.query_by_label_contains("Quick Apply").expect("Quick Apply").rect();
wheel(&mut h, first.center(), -2000.0);
let last = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
assert!(last.min.y >= 0.0 && last.max.y <= 520.0, "scrolled into the window ({last:?})");
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");
}