mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
Merge pull request #187 from storytold/fix/170-171-inline-edit-stack-gaps
UI: inline fields select their value on click (#170); stacked panels resize (#171)
This commit is contained in:
+158
-22
@@ -173,7 +173,7 @@ pub enum SplitSize {
|
||||
pub struct StackEntry {
|
||||
pub panel: PanelKind,
|
||||
pub open: bool,
|
||||
/// Content height when open; `None` = share the remaining height.
|
||||
/// Content height when open (set by dragging the gaps); `None` = share the remaining height.
|
||||
pub height: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -191,7 +191,8 @@ pub enum DockNode {
|
||||
active: usize,
|
||||
},
|
||||
/// After Effects' stacked panels (e.g. the Default workspace's right column): each panel has
|
||||
/// a header row; clicking it expands or collapses the panel in place.
|
||||
/// a header row; clicking it expands or collapses the panel in place, and dragging the gap
|
||||
/// between two open panels resizes them.
|
||||
Stack {
|
||||
entries: Vec<StackEntry>,
|
||||
},
|
||||
@@ -842,21 +843,34 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active, stacked: None });
|
||||
}
|
||||
DockNode::Stack { entries } => {
|
||||
// Headers for every entry; fixed-height panels next; flexible ones share the rest.
|
||||
// A header for every entry; the open panels' bodies share the rest.
|
||||
let head = t.tab_h;
|
||||
let g = t.gap;
|
||||
let n = entries.len() as f32;
|
||||
let fixed: f32 = entries.iter().filter(|e| e.open).filter_map(|e| e.height).sum();
|
||||
let flex = entries.iter().filter(|e| e.open && e.height.is_none()).count().max(1) as f32;
|
||||
let spare = (rect.height() - n * head - (n - 1.0).max(0.0) * g - fixed).max(0.0);
|
||||
let avail = rect.height() - n * head - (n - 1.0).max(0.0) * g;
|
||||
let bodies = stack_bodies(entries, avail);
|
||||
let mut y = rect.min.y;
|
||||
let mut drag = None;
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
let body = if !e.open { 0.0 } else { e.height.unwrap_or(spare / flex) };
|
||||
let body = bodies.get(i).copied().unwrap_or(0.0);
|
||||
let r = Rect::from_min_max(pos2(rect.min.x, y), pos2(rect.max.x, (y + head + body).min(rect.max.y)));
|
||||
let content = Rect::from_min_max(pos2(r.min.x, r.min.y + head), r.max);
|
||||
out.push(Group { path: format!("{path}s{i}"), rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) });
|
||||
let group = format!("{path}s{i}");
|
||||
// The gap below a panel resizes the open panels on either side of it.
|
||||
let above = entries.get(..=i).and_then(|s| s.iter().rposition(|x| x.open));
|
||||
let below = entries.get(i + 1..).and_then(|s| s.iter().position(|x| x.open)).map(|k| i + 1 + k);
|
||||
if let (Some(above), Some(below)) = (above, below) {
|
||||
let gap = Rect::from_min_max(pos2(rect.min.x, r.max.y), pos2(rect.max.x, r.max.y + g));
|
||||
if let Some(d) = gutter(ui, gap, true, &group, t, reg) {
|
||||
drag = Some((above, below, d));
|
||||
}
|
||||
}
|
||||
out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) });
|
||||
y = r.max.y + g;
|
||||
}
|
||||
if let Some((above, below, d)) = drag {
|
||||
resize_stacked(entries, &bodies, above, below, d);
|
||||
}
|
||||
}
|
||||
DockNode::Split { vertical, size, a, b } => {
|
||||
let g = t.gap;
|
||||
@@ -868,7 +882,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
SplitSize::FixedB(px) => avail - px.min(avail - 20.0),
|
||||
}
|
||||
.clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0));
|
||||
let (ra, gutter, rb) = if *vertical {
|
||||
let (ra, gutter_rect, rb) = if *vertical {
|
||||
(
|
||||
Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)),
|
||||
Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)),
|
||||
@@ -881,16 +895,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max),
|
||||
)
|
||||
};
|
||||
// gutter drag (a slightly larger hit area than the visible gap)
|
||||
let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) });
|
||||
let id = egui::Id::new(("dock-gutter", path.to_string()));
|
||||
let resp = ui.interact(hit, id, Sense::drag());
|
||||
reg.add(&format!("dock.gutter.{path}"), hit, "gutter");
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(if *vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
|
||||
}
|
||||
if resp.dragged() {
|
||||
let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x };
|
||||
if let Some(d) = gutter(ui, gutter_rect, *vertical, path, t, reg) {
|
||||
let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0));
|
||||
*size = match *size {
|
||||
SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)),
|
||||
@@ -898,15 +903,85 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf),
|
||||
};
|
||||
}
|
||||
if resp.dragged() || resp.hovered() {
|
||||
ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 }));
|
||||
}
|
||||
layout(ui, a, ra, t, &format!("{path}a"), out, reg);
|
||||
layout(ui, b, rb, t, &format!("{path}b"), out, reg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gap between dock areas that drags to resize them (`vertical`: up and down), with a slightly
|
||||
/// larger hit area than the gap. Returns the drag this frame.
|
||||
fn gutter(ui: &mut egui::Ui, gap: Rect, vertical: bool, path: &str, t: &Tokens, reg: &mut crate::automation::Registry) -> Option<f32> {
|
||||
let hit = gap.expand2(if vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) });
|
||||
let resp = ui.interact(hit, egui::Id::new(("dock-gutter", path.to_string())), Sense::drag());
|
||||
reg.add(&format!("dock.gutter.{path}"), hit, "gutter");
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
|
||||
ui.painter().rect_filled(gap, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 }));
|
||||
}
|
||||
resp.dragged().then(|| if vertical { resp.drag_delta().y } else { resp.drag_delta().x })
|
||||
}
|
||||
|
||||
/// The least body height of an open stacked panel (when the stack has room for it).
|
||||
const STACK_MIN: f32 = 40.0;
|
||||
|
||||
/// An open stacked panel without a height of its own: it shares the room the others leave.
|
||||
fn flexible(e: &StackEntry) -> bool {
|
||||
e.open && !e.height.is_some_and(f32::is_finite)
|
||||
}
|
||||
|
||||
/// The body heights of a stack's panels in `avail` points (headers and gaps taken out). Open
|
||||
/// panels with a height keep it and the others share the rest; when no panel takes the rest, or
|
||||
/// there is too little room, the heights grow or shrink in proportion (down to [`STACK_MIN`]) so
|
||||
/// the open panels fill the stack.
|
||||
fn stack_bodies(entries: &[StackEntry], avail: f32) -> Vec<f32> {
|
||||
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
|
||||
let open = entries.iter().filter(|e| e.open).count();
|
||||
let min = STACK_MIN.min(avail / open.max(1) as f32);
|
||||
let fixed: Vec<f32> = entries.iter().filter(|e| e.open && !flexible(e)).filter_map(|e| e.height).map(|h| h.max(min)).collect();
|
||||
let flex = open.saturating_sub(fixed.len());
|
||||
let k = fixed.len() as f32;
|
||||
// What the panels with a height may take, and how much of it lies above their minimum.
|
||||
let room = avail - flex as f32 * min;
|
||||
let sum: f32 = fixed.iter().sum();
|
||||
let (excess, target) = (sum - k * min, (room - k * min).max(0.0));
|
||||
let fit = flex == 0 || sum > room;
|
||||
let size = |h: f32| match (fit, excess > 0.0) {
|
||||
(false, _) => h,
|
||||
(true, true) => min + (h - min) / excess * target,
|
||||
(true, false) => min + target / k.max(1.0),
|
||||
};
|
||||
let used: f32 = fixed.iter().map(|&h| size(h)).sum();
|
||||
let share = (avail - used).max(0.0) / flex.max(1) as f32;
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| match (e.open, flexible(e)) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => share,
|
||||
(true, false) => size(e.height.unwrap_or(min).max(min)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drag the gap between the open stacked panels `above` and `below` (laid out at `bodies`) by
|
||||
/// `d` points: one grows as much as the other shrinks, neither below [`STACK_MIN`]. The new sizes
|
||||
/// become the panels' heights, except that the last panel without one keeps sharing the rest
|
||||
/// (so the stack still fills its column when the window is resized).
|
||||
fn resize_stacked(entries: &mut [StackEntry], bodies: &[f32], above: usize, below: usize, d: f32) {
|
||||
let (Some(&a), Some(&b)) = (bodies.get(above), bodies.get(below)) else { return };
|
||||
let pair = a + b;
|
||||
let lo = STACK_MIN.min(pair / 2.0);
|
||||
let na = (a + d).max(lo).min(pair - lo);
|
||||
for (i, h) in [(above, na), (below, pair - na)] {
|
||||
let last_flexible = entries.iter().filter(|e| flexible(e)).count() == 1;
|
||||
if let Some(e) = entries.get_mut(i)
|
||||
&& !(last_flexible && flexible(e))
|
||||
{
|
||||
e.height = Some(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show
|
||||
/// several (the Timeline's comps). Tab labels may name the comp or layer they show (After
|
||||
/// Effects style).
|
||||
@@ -1127,6 +1202,67 @@ mod tests {
|
||||
assert!(!d.toggle_stacked(Timeline));
|
||||
}
|
||||
|
||||
fn entry(panel: PanelKind, open: bool, height: Option<f32>) -> StackEntry {
|
||||
StackEntry { panel, open, height }
|
||||
}
|
||||
|
||||
fn close_to(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_panels_fill_their_column() {
|
||||
use PanelKind::*;
|
||||
// A panel with a height keeps it; the open ones without share the rest.
|
||||
let s = [entry(Preview, true, Some(100.0)), entry(Properties, true, None), entry(Align, false, None), entry(Audio, true, None)];
|
||||
assert!(close_to(&stack_bodies(&s, 500.0), &[100.0, 200.0, 0.0, 200.0]));
|
||||
// Too little room: the heights shrink so the others keep their minimum.
|
||||
let s = [entry(Preview, true, Some(500.0)), entry(Properties, true, None)];
|
||||
assert!(close_to(&stack_bodies(&s, 300.0), &[260.0, STACK_MIN]));
|
||||
// Nothing to take the rest: the heights grow in proportion (above the minimum) to fill.
|
||||
let s = [entry(Preview, true, Some(100.0)), entry(Properties, true, Some(300.0)), entry(Align, false, Some(80.0))];
|
||||
assert!(close_to(&stack_bodies(&s, 600.0), &[137.5, 462.5, 0.0]));
|
||||
let s = [entry(Preview, true, Some(46.0)), entry(Properties, false, None)];
|
||||
assert!(close_to(&stack_bodies(&s, 600.0), &[600.0, 0.0]));
|
||||
// Hostile sizes never make a body negative or non-finite.
|
||||
for avail in [-50.0, 0.0, 10.0, f32::NAN, f32::INFINITY] {
|
||||
for h in [None, Some(-5.0), Some(f32::NAN), Some(f32::INFINITY), Some(1e30), Some(f32::MAX)] {
|
||||
let s = [entry(Preview, true, h), entry(Properties, true, Some(60.0)), entry(Align, false, h)];
|
||||
let b = stack_bodies(&s, avail);
|
||||
assert!(b.iter().all(|x| x.is_finite() && *x >= 0.0), "{avail} {h:?}: {b:?}");
|
||||
}
|
||||
}
|
||||
assert!(stack_bodies(&[], 100.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_stack_gap_trades_height_between_its_neighbours() {
|
||||
use PanelKind::*;
|
||||
let mut s = vec![entry(Preview, true, Some(46.0)), entry(Properties, true, None), entry(Align, false, None)];
|
||||
let bodies = stack_bodies(&s, 800.0);
|
||||
resize_stacked(&mut s, &bodies, 0, 1, 100.0);
|
||||
// Preview keeps its new height; Properties, the only panel without one, takes the rest.
|
||||
assert_eq!(s[0].height, Some(146.0));
|
||||
assert_eq!(s[1].height, None);
|
||||
assert!(close_to(&stack_bodies(&s, 800.0), &[146.0, 654.0, 0.0]));
|
||||
// Neither side goes below the minimum.
|
||||
for (d, want) in [(-1000.0, STACK_MIN), (1000.0, 800.0 - STACK_MIN)] {
|
||||
let bodies = stack_bodies(&s, 800.0);
|
||||
resize_stacked(&mut s, &bodies, 0, 1, d);
|
||||
assert!(close_to(&stack_bodies(&s, 800.0)[..1], &[want]), "{d}");
|
||||
}
|
||||
// Two sharing panels: the upper one gets a height, the lower one keeps sharing.
|
||||
let mut s = vec![entry(Preview, true, None), entry(Align, false, None), entry(Properties, true, None)];
|
||||
let bodies = stack_bodies(&s, 400.0);
|
||||
resize_stacked(&mut s, &bodies, 0, 2, 50.0);
|
||||
assert_eq!((s[0].height, s[2].height), (Some(250.0), None));
|
||||
assert!(close_to(&stack_bodies(&s, 400.0), &[250.0, 0.0, 150.0]));
|
||||
// Out-of-range indices change nothing.
|
||||
let before = s.clone();
|
||||
resize_stacked(&mut s, &bodies, 0, 9, 50.0);
|
||||
assert_eq!(s, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_workspace_has_ae_right_column_stack() {
|
||||
let d = workspace("Default");
|
||||
|
||||
@@ -26,8 +26,8 @@ use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, ParamUi, Proper
|
||||
use egui::{Rect, RichText};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::panels::DragPayload;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
type Actions = Vec<(String, Value)>;
|
||||
|
||||
@@ -296,13 +296,13 @@ fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCo
|
||||
Some(mut buf) => {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(120.0));
|
||||
app.auto.add(&format!("essential.control.{}.rename", c.id), r.rect, &c.name);
|
||||
r.request_focus();
|
||||
if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
if !buf.trim().is_empty() && buf.trim() != c.name {
|
||||
if r.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf.trim() != c.name {
|
||||
actions.push(("essential.rename".into(), json!({"comp": cid.0, "control": c.id, "name": buf.trim()})));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.remove::<String>(edit_id));
|
||||
} else {
|
||||
widgets::keep_focus(ui.ctx(), &r, &buf);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(edit_id, buf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +480,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push((cmd.into(), params));
|
||||
}
|
||||
} else {
|
||||
resp.request_focus();
|
||||
widgets::keep_focus(&ctx, &resp, &buf);
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (eid, ef, buf)));
|
||||
}
|
||||
true
|
||||
|
||||
@@ -715,7 +715,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
editing = None;
|
||||
} else {
|
||||
resp.request_focus();
|
||||
widgets::keep_focus(ui.ctx(), &resp, &buf);
|
||||
editing = Some((it.id, buf));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -915,11 +915,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::new("tl-tc-edit"))) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tc_rect));
|
||||
let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(16.0)).desired_width(150.0));
|
||||
r.request_focus();
|
||||
if r.lost_focus() {
|
||||
let _ = app.session.execute("time.set", json!({"timecode": buf}));
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
let _ = app.session.execute("time.set", json!({"timecode": buf}));
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<String>(egui::Id::new("tl-tc-edit")));
|
||||
} else {
|
||||
widgets::keep_focus(&ctx, &r, &buf);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("tl-tc-edit"), buf));
|
||||
}
|
||||
}
|
||||
@@ -1357,15 +1359,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
|
||||
if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
|
||||
let out = egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()).show(&mut child);
|
||||
let er = out.response;
|
||||
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_focus_id())).unwrap_or(false) {
|
||||
// Just started: focus the field with the whole name selected.
|
||||
er.request_focus();
|
||||
let mut state = out.state;
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(buf.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(&ctx, er.id);
|
||||
widgets::select_all(&ctx, er.id, &buf);
|
||||
}
|
||||
if er.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
@@ -2488,13 +2486,13 @@ fn layer_cells(
|
||||
if let Some((el, mut buf)) = editing.filter(|(el, _)| *el == lid) {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cr));
|
||||
let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(11.5)).desired_width(cr.width()));
|
||||
er.request_focus();
|
||||
if er.lost_focus() {
|
||||
if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && buf != layer.comment {
|
||||
actions.push(("layer.setComment".into(), json!({"layers": [el], "comment": buf})));
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<(u64, String)>(edit_id));
|
||||
} else {
|
||||
widgets::keep_focus(&ctx, &er, &buf);
|
||||
ctx.data_mut(|d| d.insert_temp(edit_id, (el, buf)));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -38,8 +38,8 @@ pub fn hot_number_at(
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(editing_id));
|
||||
} else {
|
||||
keep_focus(ui.ctx(), &r, &buf);
|
||||
ui.data_mut(|d| d.insert_temp(editing_id, buf));
|
||||
r.request_focus();
|
||||
}
|
||||
return (er, out, out.is_some());
|
||||
}
|
||||
@@ -94,6 +94,23 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s
|
||||
(rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done)
|
||||
}
|
||||
|
||||
/// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it
|
||||
/// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value.
|
||||
pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) {
|
||||
if !ctx.memory(|m| m.has_focus(field.id)) {
|
||||
select_all(ctx, field.id, text);
|
||||
}
|
||||
field.request_focus();
|
||||
}
|
||||
|
||||
/// Select all of the text field `id`'s `text`, so typing replaces it.
|
||||
pub fn select_all(ctx: &egui::Context, id: egui::Id, text: &str) {
|
||||
let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ctx, id);
|
||||
}
|
||||
|
||||
/// A colour swatch; returns the response (click opens the picker in the caller).
|
||||
pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
|
||||
@@ -156,6 +156,22 @@ fn valid_angles_commit_on_click_away_with_undo() {
|
||||
commit_edits(true);
|
||||
}
|
||||
|
||||
/// Clicking a value selects all of it: typing replaces it without selecting it first (#170).
|
||||
#[test]
|
||||
fn typing_replaces_the_clicked_value() {
|
||||
for panel in [Panel::EffectControls, Panel::Timeline, Panel::Properties] {
|
||||
for (revolutions, text, expected) in [(true, "3", 1110.5), (false, "45.5", 405.5)] {
|
||||
let (mut h, layer, uid) = harness(panel, 390.5);
|
||||
let p = component(&h, panel, uid, revolutions);
|
||||
click(&mut h, p);
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.step();
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(angle(&h, layer, uid), expected, "{panel:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_edits(click_away: bool) {
|
||||
for panel in [Panel::EffectControls, Panel::Timeline, Panel::Properties] {
|
||||
for (revolutions, text, expected) in [(true, "3", 1110.5), (false, "45.5", 405.5)] {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Inline text fields (a name, the current time) open with their whole text selected so typing
|
||||
//! replaces it, commit on Enter and cancel on Escape (#170).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4, "fps": 24})).unwrap();
|
||||
let comp = s.active_comp_id().unwrap().0;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(4);
|
||||
(h, comp)
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, times: usize) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
// All in one frame (the harness' frames are longer apart than a double-click).
|
||||
for _ in 0..times {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
|
||||
assert!(h.ctx.egui_wants_keyboard_input(), "the field has the keyboard");
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_rename_replaces_the_name() {
|
||||
let (mut h, comp) = harness();
|
||||
let name = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().find(|i| i.id.0 == comp).unwrap().name.clone();
|
||||
let p = center(&h, &format!("project.item.{comp}.name"));
|
||||
click(&mut h, p, 1);
|
||||
key(&mut h, Key::Enter);
|
||||
type_text(&mut h, "Hero");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(name(&h), "Hero");
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "Enter releases the keyboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_field_replaces_commits_and_cancels() {
|
||||
let (mut h, _) = harness();
|
||||
let frame = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().frame_rate.frame_at(h.state().session.time());
|
||||
let p = center(&h, "timeline.timecode");
|
||||
click(&mut h, p, 2);
|
||||
type_text(&mut h, "0:00:01:00");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(frame(&h), 24, "Enter commits the typed time");
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "Enter releases the keyboard");
|
||||
click(&mut h, p, 2);
|
||||
type_text(&mut h, "0:00:02:00");
|
||||
key(&mut h, Key::Escape);
|
||||
assert_eq!(frame(&h), 24, "Escape cancels");
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "Escape releases the keyboard");
|
||||
}
|
||||
@@ -200,6 +200,41 @@ fn panels_dock_beside_others_and_gutters_resize_them() {
|
||||
assert!((wider.width() - project.width() - 60.0).abs() < 2.0, "{} → {}", project.width(), wider.width());
|
||||
}
|
||||
|
||||
/// Issue #171: the gaps between the right column's stacked panels drag to resize them (Preview
|
||||
/// was stuck at its default height), not below a minimum; the heights stay in the layout.
|
||||
#[test]
|
||||
fn stacked_panel_gaps_resize_the_panels() {
|
||||
let (mut h, _, _) = harness();
|
||||
fn height(n: &DockNode, p: PanelKind) -> Option<Option<f32>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)),
|
||||
DockNode::Tabs { .. } => None,
|
||||
DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height),
|
||||
}
|
||||
}
|
||||
let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
|
||||
let gap = rect(&h, "dock.gutter.bs0");
|
||||
assert!(gap.center().y > preview.max.y && gap.center().y < props.min.y, "the gap below Preview: {gap:?}");
|
||||
drag(&mut h, gap.center(), gap.center() + egui::vec2(0.0, 150.0));
|
||||
let (p2, q2) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties"));
|
||||
assert!((p2.height() - preview.height() - 150.0).abs() < 2.0, "Preview {} → {}", preview.height(), p2.height());
|
||||
assert!((props.height() - q2.height() - 150.0).abs() < 2.0, "Properties {} → {}", props.height(), q2.height());
|
||||
assert!((q2.max.y - props.max.y).abs() < 1.0, "the column stays filled");
|
||||
let saved = height(&h.state().ui.dock, PanelKind::Preview).flatten().expect("Preview's height is in the layout");
|
||||
assert!((saved - p2.height()).abs() < 2.0, "{saved} vs {}", p2.height());
|
||||
assert_eq!(height(&h.state().ui.dock, PanelKind::Properties), Some(None), "Properties still takes the rest");
|
||||
// Collapsing and expanding Preview keeps its height.
|
||||
let tab = rect(&h, "panel.tab.Preview");
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!(!h.state().ui.dock.is_visible(PanelKind::Preview));
|
||||
click_n(&mut h, tab.center(), 1);
|
||||
assert!((rect(&h, "panel.Preview").height() - p2.height()).abs() < 1.0);
|
||||
// Not below the minimum.
|
||||
let gap = rect(&h, "dock.gutter.bs0");
|
||||
drag(&mut h, gap.center(), gap.center() - egui::vec2(0.0, 400.0));
|
||||
assert!((rect(&h, "panel.Preview").height() - 40.0).abs() < 1.0, "{}", rect(&h, "panel.Preview").height());
|
||||
}
|
||||
|
||||
/// Issue #45: the Project panel's details (name, size, duration) stay inside the panel with a
|
||||
/// margin, cut short with "…" when they don't fit; so does the hint shown with nothing selected.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user