Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions contributors/people.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ sync_github_name = false

[people.webdevtodayjason]
display_name = "Jason Brashear"

[people.ksaxena149]
real_name = "Kushagra Saxena"
Comment thread
ksaxena149 marked this conversation as resolved.
Resolved
44 changes: 33 additions & 11 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({}));
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<f32> {
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));
Expand All @@ -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),
Expand All @@ -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).
Expand Down Expand Up @@ -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<egui::Event>, 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<egui::Event>, modifiers: egui::Modifiers, label_selected: bool) {
use egui::{Event, Key};
let keys: Vec<Key> = 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),
Expand All @@ -2305,7 +2317,7 @@ mod clipboard_key_tests {

fn keys(events: Vec<Event>, 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()
}

Expand Down Expand Up @@ -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<Key> = 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)]
Expand Down
7 changes: 5 additions & 2 deletions crates/ui-egui/tests/chrome_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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}");
Expand All @@ -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}");
Expand Down
173 changes: 173 additions & 0 deletions crates/ui-egui/tests/status_bar_ui.rs
Original file line number Diff line number Diff line change
@@ -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<ControlRequest>,
/// Text the app put on the system clipboard.
copied: Vec<String>,
}

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<f32> = 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<Rect> {
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));
}
Loading