diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5e719ac60..0283034a6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -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, } @@ -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, }, @@ -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 { + 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 { + 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 = 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) -> 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"); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index e48740603..dd014d31e 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -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::(edit_id)); } else { + widgets::keep_focus(ui.ctx(), &r, &buf); ui.ctx().data_mut(|d| d.insert_temp(edit_id, buf)); } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1a9c21631..2f9963d9a 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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::(edit_id(), (eid, ef, buf))); } true diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index f2a842316..99f907ae4 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9fb77eabb..8af70d553 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(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::(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::(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 { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 526a9adf1..703375e8b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -38,8 +38,8 @@ pub fn hot_number_at( } ui.data_mut(|d| d.remove::(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()); diff --git a/crates/ui-egui/tests/ui_angles.rs b/crates/ui-egui/tests/ui_angles.rs index 7d5a888f7..007e11286 100644 --- a/crates/ui-egui/tests/ui_angles.rs +++ b/crates/ui-egui/tests/ui_angles.rs @@ -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)] { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs new file mode 100644 index 000000000..a8232de3e --- /dev/null +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -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"); +} diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index c1b3e73e0..d3a71ec32 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -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> { + 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]