From 04c5e01996e31fdf7e10767469523bf58c2df5df Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Tue, 20 Oct 2026 13:07:35 +0530 Subject: [PATCH 1/2] #2295: the status bar's text can be selected and copied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar painted its hint and messages, so a path, a file that would not open or the reason an export stopped could be read but not selected, copied or found by agents. It is now a selectable label in the same 20 px bar: a long message ends in "…" before the running job's progress (and shows in full on hover), the 8 s auto-clear stays, and a click beside the text still dismisses the message. Agents read the whole text as the `status.text` element. Ctrl+C on selected label text also reached the app's Copy shortcut, which copied the selected clips instead or, with none, replaced the message with "no clips selected". Selected label text now keeps Copy and Cut, the way a focused text field does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 44 +++++-- crates/ui-egui/tests/chrome_ui.rs | 7 +- crates/ui-egui/tests/status_bar_ui.rs | 173 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 4 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/status_bar_ui.rs 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 | From 97c6b0922c1a1f470a55f8eac603b5b9908da107 Mon Sep 17 00:00:00 2001 From: Kushagra Saxena Date: Tue, 20 Oct 2026 13:07:35 +0530 Subject: [PATCH 2/2] Contributors: add ksaxena149 (Kushagra Saxena) Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) 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"