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:
BFlat
2026-10-07 14:30:38 +07:00
committed by GitHub
9 changed files with 315 additions and 38 deletions
+158 -22
View File
@@ -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");
+4 -4
View File
@@ -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));
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+7 -9
View File
@@ -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 {
+18 -1
View File
@@ -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());
+16
View File
@@ -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)] {
+75
View File
@@ -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");
}
+35
View File
@@ -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]