diff --git a/contributors/people.toml b/contributors/people.toml index 7f6a1c2adf..4805918dc9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -88,3 +88,6 @@ sync_github_name = false [people.webdevtodayjason] display_name = "Jason Brashear" + +[people.ksaxena149] +real_name = "Kushagra Saxena" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c838e6fe90..e8ae4221a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1538,6 +1538,8 @@ impl FilmcraftApp { // out, so it still reports the previous pass, and it is true for any focused widget, which // would leave the editor without its single-key tool shortcuts while a slider has focus. let text_focused = ctx.text_edit_focused(); + // read before `input_mut`: the plugin lock must not be taken inside the input lock + let label_selected = ctx.with_plugin(|s: &mut egui::text_selection::LabelSelectionState| s.has_selection()).unwrap_or(false); // Esc cancels a dynamic trim in progress if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.session.execute("trim.cancelDynamic", json!({})); @@ -1575,7 +1577,7 @@ impl FilmcraftApp { modifiers: m | egui::Modifiers::COMMAND, }); } - clipboard_events_as_keys(&mut i.events, modifiers); + clipboard_events_as_keys(&mut i.events, modifiers, label_selected); let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); let mut candidates: Vec<&menus::KeyBinding> = panel.chain(app_wide).collect(); @@ -1878,12 +1880,18 @@ impl FilmcraftApp { } } let hint = if !self.ui.status.is_empty() { self.ui.status.clone() } else { self.hint_text() }; - ui.painter().text(egui::pos2(sb.min.x + 10.0, sb.center().y), egui::Align2::LEFT_CENTER, hint, Tokens::ui(11.0), t.text_dim); + // the bar goes under the text and the job's controls, so they get their own clicks let resp = ui.interact(sb, egui::Id::new("status-bar"), egui::Sense::click()); if resp.clicked() { self.ui.status.clear(); } - self.job_status(ui, sb, &t); + let right = self.job_status(ui, sb, &t).unwrap_or(sb.max.x); + // A label, not painted text, so a path or an error can be selected and copied (#2295); + // too long a message ends in "…" before the job's progress and shows in full on hover. + let text = egui::Rect::from_min_max(egui::pos2(sb.min.x + 10.0, sb.min.y), egui::pos2((right - 10.0).max(sb.min.x + 10.0), sb.max.y)); + let mut row = ui.new_child(egui::UiBuilder::new().max_rect(text).id_salt("status-text").layout(egui::Layout::left_to_right(egui::Align::Center))); + let shown = row.add(egui::Label::new(egui::RichText::new(&hint).font(Tokens::ui(11.0)).color(t.text_dim)).selectable(true).truncate()); + self.auto.add("status.text", shown.rect, &hint); } /// The running job (export / render previews), if any; plays the rendered range when a preview @@ -1913,10 +1921,10 @@ impl FilmcraftApp { } /// Right side of the status bar: the running job (export / render previews) with a progress - /// bar and a cancel button. - fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) { + /// bar and a cancel button. Returns where it starts, if a job is running. + fn job_status(&mut self, ui: &mut egui::Ui, sb: egui::Rect, t: &Tokens) -> Option { use std::sync::atomic::Ordering; - let Some(job) = self.watch_jobs(ui.ctx()) else { return }; + let job = self.watch_jobs(ui.ctx())?; let f = job.progress.fraction().clamp(0.0, 1.0); let left = job.progress.eta().map(panels::left_text).unwrap_or_default(); let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0)); @@ -1925,7 +1933,7 @@ impl FilmcraftApp { p.rect_filled(bar, 3.0, t.separator); p.rect_filled(egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * f, bar.height())), 3.0, t.accent); let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() }; - p.text( + let progress = p.text( egui::pos2(bar.min.x - 8.0, sb.center().y), egui::Align2::RIGHT_CENTER, format!("{verb}… {:.0}%{left}", f * 100.0), @@ -1943,6 +1951,7 @@ impl FilmcraftApp { job.progress.cancel.store(true, Ordering::Relaxed); self.watched_render = None; } + Some(progress.min.x) } /// Contextual hint for the status bar (Premiere shows tool/gesture hints here). @@ -2279,12 +2288,15 @@ pub fn playback_mix(session: &Session, seq_id: filmcraft_project::ItemId, sr: u3 /// /// Add the key press each event stands for, with the modifiers held, so the bindings see it. The /// original event stays for anything else that reads it. Only called when no text field has -/// keyboard focus; text fields keep handling the clipboard themselves. -fn clipboard_events_as_keys(events: &mut Vec, modifiers: egui::Modifiers) { +/// keyboard focus; text fields keep handling the clipboard themselves. Selected label text (a +/// status bar message, #2295) keeps Copy and Cut the same way: Ctrl+C copies the text, not the +/// selected clips. +fn clipboard_events_as_keys(events: &mut Vec, modifiers: egui::Modifiers, label_selected: bool) { use egui::{Event, Key}; let keys: Vec = events .iter() .filter_map(|e| match e { + Event::Copy | Event::Cut if label_selected => None, Event::Copy if modifiers.command => Some(Key::C), Event::Copy => Some(Key::Copy), Event::Cut if modifiers.command => Some(Key::X), @@ -2305,7 +2317,7 @@ mod clipboard_key_tests { fn keys(events: Vec, m: Modifiers) -> Vec<(Key, Modifiers)> { let mut events = events; - super::clipboard_events_as_keys(&mut events, m); + super::clipboard_events_as_keys(&mut events, m, false); events.into_iter().filter_map(|e| if let Event::Key { key, modifiers, pressed: true, .. } = e { Some((key, modifiers)) } else { None }).collect() } @@ -2336,11 +2348,21 @@ mod clipboard_key_tests { #[test] fn other_events_are_left_alone() { let mut events = vec![Event::Text("a".into()), Event::Copy]; - super::clipboard_events_as_keys(&mut events, Modifiers::COMMAND); + super::clipboard_events_as_keys(&mut events, Modifiers::COMMAND, false); assert_eq!(events.len(), 3); assert!(matches!(events[0], Event::Text(_))); assert!(matches!(events[1], Event::Copy)); } + + #[test] + fn selected_label_text_keeps_copy_and_cut() { + let c = Modifiers::COMMAND; + let mut events = vec![Event::Copy, Event::Cut, Event::Paste("x".into())]; + super::clipboard_events_as_keys(&mut events, c, true); + let keys: Vec = events.iter().filter_map(|e| if let Event::Key { key, .. } = e { Some(*key) } else { None }).collect(); + assert_eq!(keys, vec![Key::V], "only Paste becomes a key press"); + assert!(matches!(events[0], Event::Copy), "the selection still gets the Copy event"); + } } #[cfg(test)] diff --git a/crates/ui-egui/tests/chrome_ui.rs b/crates/ui-egui/tests/chrome_ui.rs index 0e65f36c0a..e342107919 100644 --- a/crates/ui-egui/tests/chrome_ui.rs +++ b/crates/ui-egui/tests/chrome_ui.rs @@ -24,10 +24,11 @@ fn harness(show_header: bool, show_status_bar: bool) -> Harness<'static, Filmcra harness } -/// Top and bottom edge of every registered element outside the header (the menu bar, `menu.*`, is part of it). +/// Top and bottom edge of every registered element outside the header (the menu bar, `menu.*`, is part of it) +/// and the status bar (`status.*`). fn body_extent(h: &Harness<'static, FilmcraftApp>) -> (f32, f32) { let els = h.state().auto.query(""); - let body: Vec<_> = els.iter().filter(|e| !e.id.starts_with("header.") && !e.id.starts_with("menu.")).collect(); + let body: Vec<_> = els.iter().filter(|e| !e.id.starts_with("header.") && !e.id.starts_with("menu.") && !e.id.starts_with("status.")).collect(); assert!(!body.is_empty(), "no panel elements registered"); let top = body.iter().map(|e| e.rect[1]).fold(f32::INFINITY, f32::min); let bottom = body.iter().map(|e| e.rect[1] + e.rect[3]).fold(f32::NEG_INFINITY, f32::max); @@ -40,6 +41,7 @@ fn header_and_status_bar_are_shown_by_default() { assert!(UiState::default().show_status_bar); let h = harness(true, true); assert!(!h.state().auto.query("header.").is_empty()); + assert!(!h.state().auto.query("status.").is_empty()); let (top, bottom) = body_extent(&h); assert!(top >= 38.0, "panels start under the header: {top}"); assert!(bottom <= H - 20.0, "panels end above the status bar: {bottom}"); @@ -50,6 +52,7 @@ fn header_and_status_bar_are_shown_by_default() { fn hidden_header_and_status_bar_give_the_panels_the_window() { let h = harness(false, false); assert!(h.state().auto.query("header.").is_empty(), "header drawn while hidden"); + assert!(h.state().auto.query("status.").is_empty(), "status bar drawn while hidden"); let (top, bottom) = body_extent(&h); assert!(top < 38.0, "panels start at the top of the window: {top}"); assert!(bottom > H - 20.0, "panels reach the bottom of the window: {bottom}"); diff --git a/crates/ui-egui/tests/status_bar_ui.rs b/crates/ui-egui/tests/status_bar_ui.rs new file mode 100644 index 0000000000..ed9cb007c5 --- /dev/null +++ b/crates/ui-egui/tests/status_bar_ui.rs @@ -0,0 +1,173 @@ +//! The status bar (#2295): its hint or message is a label, so it can be selected and copied (painted +//! text could not), a click beside the text still dismisses a message, and a long message ends +//! before the running job's progress instead of running under it. + +use std::sync::atomic::Ordering; +use std::sync::mpsc::{Sender, channel}; + +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use filmcraft_engine::{Job, Session}; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +const MESSAGE: &str = "Could not open /media/day 2/A012C003.mov: unsupported codec"; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + /// Text the app put on the system clipboard. + copied: Vec, +} + +impl Driver { + fn new() -> Self { + let (tx, rx) = channel(); + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, copied: Vec::new() }; + d.frames(4); + d + } + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + let out = &self.harness.output().platform_output.commands; + self.copied.extend(out.iter().filter_map(|c| if let egui::OutputCommand::CopyText(t) = c { Some(t.clone()) } else { None })); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + /// The rect and label of the automation element `id`. + fn element(&mut self, id: &str) -> (Rect, String) { + let v = self.call("ui.elements", json!({"prefix": id})); + let e = v["result"].as_array().unwrap().iter().find(|e| e["id"] == id).cloned().unwrap_or_else(|| panic!("no {id} in {v}")); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap() as f32).collect(); + (Rect::from_min_size(pos2(r[0], r[1]), egui::vec2(r[2], r[3])), e["label"].as_str().unwrap().to_string()) + } + fn press(&mut self, at: Pos2, pressed: bool) { + self.harness.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + self.frames(1); + } + fn click(&mut self, at: Pos2) { + self.harness.hover_at(at); + self.frames(1); + self.press(at, true); + self.press(at, false); + self.frames(2); + } + fn drag(&mut self, from: Pos2, to: Pos2) { + self.harness.hover_at(from); + self.frames(1); + self.press(from, true); + for k in 1..=8 { + self.harness.hover_at(from + (to - from) * (k as f32 / 8.0)); + self.frames(1); + } + self.press(to, false); + self.frames(2); + } + /// Ctrl+C, as egui-winit reports it: a Copy event with Ctrl held, in one frame. (The harness + /// runs a pass per queued event and keeps only the last one's output, so this goes straight + /// into the frame's input.) + fn ctrl_c(&mut self) { + self.harness.input_mut().events.extend([Event::ModifiersChanged(Modifiers::COMMAND), Event::Copy]); + self.frames(1); + self.harness.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + self.frames(1); + } + /// The rect of painted text starting with `prefix`. + fn painted(&self, prefix: &str) -> Option { + self.harness.output().shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text().starts_with(prefix) => Some(t.visual_bounding_rect()), + _ => None, + }) + } +} + +#[test] +fn a_status_message_can_be_selected_and_copied() { + let mut d = Driver::new(); + // with clips selected too, Ctrl+C belongs to the selected text + d.app().session.execute("edit.selectAll", json!({})).unwrap(); + assert!(!d.app().session.state.selection.is_empty()); + d.app().ui.status = MESSAGE.into(); + d.frames(2); + // a widget with the message as its text: painted text was neither in the accessibility tree + // nor an automation element + assert!(d.harness.query_by_label(MESSAGE).is_some(), "the message is not a label"); + let (text, label) = d.element("status.text"); + assert_eq!(label, MESSAGE); + // drag across it and copy + d.copied.clear(); + d.drag(pos2(text.min.x + 1.0, text.center().y), pos2(text.max.x - 0.5, text.center().y)); + assert_eq!(d.app().ui.status, MESSAGE, "selecting the text dismissed it"); + d.ctrl_c(); + assert_eq!(d.copied, vec![MESSAGE.to_string()], "Ctrl+C did not copy the message"); + assert!(d.app().session.state.clipboard.is_empty(), "Ctrl+C copied the clips as well"); + assert_eq!(d.app().ui.status, MESSAGE, "copying the message replaced it"); +} + +#[test] +fn the_hint_is_a_label_too() { + let mut d = Driver::new(); + assert!(d.app().ui.status.is_empty()); + let (text, hint) = d.element("status.text"); + assert!(hint.starts_with("Click to select"), "{hint}"); + assert!(text.width() > 0.0 && d.harness.query_by_label(&hint).is_some()); +} + +#[test] +fn a_click_beside_the_text_dismisses_the_message() { + let mut d = Driver::new(); + d.app().ui.status = MESSAGE.into(); + d.frames(2); + let (text, _) = d.element("status.text"); + // on the text, a click places the selection and leaves the message + d.click(text.center()); + assert_eq!(d.app().ui.status, MESSAGE); + // beside it, it dismisses the message as before + d.click(pos2(text.max.x + 200.0, text.center().y)); + assert!(d.app().ui.status.is_empty(), "{}", d.app().ui.status); +} + +#[test] +fn a_long_message_ends_before_the_job_progress() { + let mut d = Driver::new(); + let job = Job { id: 901, label: "Exporting".into(), progress: Default::default(), result: Default::default() }; + job.progress.total.store(1000, Ordering::Relaxed); + job.progress.done.store(250, Ordering::Relaxed); + d.app().session.jobs.push(job); + let long = format!("Could not write {}", "/very/long/folder/name".repeat(40)); + d.app().ui.status = long.clone(); + d.frames(3); + let (text, label) = d.element("status.text"); + assert_eq!(label, long, "agents read the whole message"); + let progress = d.painted("Exporting…").expect("the job's progress text"); + assert!(text.max.x <= progress.min.x, "the message {text:?} runs under the progress {progress:?}"); + // and the cancel button still takes its own click + let (cancel, _) = d.element("status.job.cancel"); + d.click(cancel.center()); + let cancelled = d.app().session.jobs.iter().find(|j| j.id == 901).map(|j| j.progress.cancel.load(Ordering::Relaxed)); + assert_eq!(cancelled, Some(true)); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 80802cf204..dc5efda68e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -28,7 +28,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `engine.execute` / `ui.menu.invoke` | `{command, params}` | any engine or UI command id | | `engine.commands` / `ui.menu.list` | – | command registry / menu tree | | `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) | -| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect | +| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect (`status.text` carries the status bar's whole message or hint, even when the bar shows it shortened) | | `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}, keyframeSelection:[…]}` | `keyframeSelection` replaces the Effect Controls keyframe selection (`[{clip, effect, param, mask?, time}]`, `time` in media ticks; read back as `ui.inspect` → `ui.keyframe_selection`; a click on a lane keyframe selects just that one); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) | | `ui.panel.show` / `ui.panel.close` | `{panel}` | | | `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |