From bcf5f32d9b79561d4be992f70a0683407e92c0de Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 14:20:25 +0700 Subject: [PATCH 1/2] UI: clicking into a value or inline text field selects all of it (#170) Cause: the click-to-edit fields (hot numbers and revolutions, the Timeline time display and layer comments, Project rename/comment, the Render Queue output path, Essential Graphics control names) opened a text edit with egui's default caret at the end of the text, so typing appended to the old value. The Timeline time display, layer comment and Essential Graphics name editors also re-requested focus before checking `lost_focus()`, which hid the loss, so Enter, Escape and clicking away never closed them. Fix: a shared `widgets::keep_focus` keeps an open inline editor focused and selects its whole text on the frame it opens (`widgets::select_all`, which the layer rename now reuses). Those editors check for a lost focus first, and Escape now cancels in the time display and the Essential Graphics name editor. Test: ui_angles `typing_replaces_the_clicked_value` (degrees and revolutions in Effect Controls, Timeline and Properties, typed without Ctrl+A); ui_inline_edit `project_rename_replaces_the_name` and `timecode_field_replaces_commits_and_cancels`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/essential.rs | 8 +-- crates/ui-egui/src/panels/project.rs | 2 +- crates/ui-egui/src/panels/render_queue.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 16 +++-- crates/ui-egui/src/widgets.rs | 19 +++++- crates/ui-egui/tests/ui_angles.rs | 16 +++++ crates/ui-egui/tests/ui_inline_edit.rs | 75 +++++++++++++++++++++++ 7 files changed, 122 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/ui_inline_edit.rs 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"); +} From 7387049ed4996521f326e559b7eb9f1526bd31e0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 14:20:26 +0700 Subject: [PATCH 2/2] Dock: drag the gaps between stacked panels to resize them (#171) Cause: a `DockNode::Stack` laid its panels out from fixed heights (Preview 46 pt) with the rest shared, but only split gutters were interactive, so the right column's panels could be expanded or collapsed and never resized. Fix: the gap below a stacked panel drags the nearest open panels above and below it against each other, neither going below 40 pt. The new sizes are stored as the entries' heights, so they persist in the layout like split sizes. The last panel without a height keeps sharing the rest, and when no panel takes the rest, or space runs short, the heights scale so the stack fills its column. Split and stack gaps share one `gutter` helper. Automation ids: `dock.gutter.s` for the gap below stacked entry i. Test: ui_tabs `stacked_panel_gaps_resize_the_panels` (drag, persistence, collapse/expand, minimum); dock unit tests `stacked_panels_fill_their_column` and `dragging_a_stack_gap_trades_height_between_its_neighbours`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 180 ++++++++++++++++++++++++++++---- crates/ui-egui/tests/ui_tabs.rs | 35 +++++++ 2 files changed, 193 insertions(+), 22 deletions(-) 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/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]