From 5f914db98c8e0dd2ad83c31078cd1a3a5e659157 Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 9 Oct 2026 21:13:11 +0100 Subject: [PATCH] =?UTF-8?q?ui:=20Effect=20Console=20(Ctrl+Space)=20?= =?UTF-8?q?=E2=80=94=20search=20and=20apply=20effects=20and=20presets=20at?= =?UTF-8?q?=20the=20pointer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small search bar opens at the pointer (Edit > Effect Console..., app.fxConsole {query?}). It ranks effects, text presets and user presets by name, word starts, initials (gb = Gaussian Blur) and letters in order, boosting recent and favourite effects and sinking Obsolete ones. Enter applies to the selected layers and closes, Shift+Enter applies and stays open, arrows choose, Escape / Ctrl+Space / a click outside close. With no text it lists recent effects. Off macOS a Ctrl shortcut now also expects the command flag egui reports with Ctrl. (cherry picked from commit c88580b11b1cb6940165d1eaaa8635a3a934cd0f) (cherry picked from commit c508d92a93299f0f5ec7986fc1c6f84aa243ff33) (cherry picked from commit 36f3d6533de51d70b0255c33b3f94ed2ebf2297b) --- crates/engine/src/commands/frontend.rs | 8 + crates/engine/src/menus.rs | 1 + crates/ui-egui/src/i18n.rs | 4 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/panels/dialogs.rs | 2 + crates/ui-egui/src/panels/effects_presets.rs | 2 +- crates/ui-egui/src/panels/fx_console.rs | 360 +++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/tests/ui_fx_console.rs | 110 ++++++ 10 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/fx_console.rs create mode 100644 crates/ui-egui/tests/ui_fx_console.rs diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index 57fdb60a3..ddddcbe4c 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -32,6 +32,14 @@ pub fn specs() -> Vec { fe!("app.showAll", "Show All", [], None, "{}", always), fe!("app.quit", "Quit EffectCraft", [], Some("Cmd+Q"), "{}", always), fe!("app.commandPalette", "Quick Apply...", ["Edit"], Some("Cmd+Shift+Space"), "{query?}", always), + fe!( + "app.fxConsole", + "Effect Console...", + ["Edit"], + Some("Ctrl+Space"), + "{query?} — a search bar at the pointer that applies effects and animation presets to the selected layers", + always + ), fe!("app.keyboardShortcuts", "Keyboard Shortcuts", ["Edit"], Some("Cmd+Alt+'"), "{}", always), fe!("app.templates", "Templates", [], None, "{kind: renderSettings|outputModule}", always), fe!("app.find", "Find", ["File"], Some("Cmd+F"), "{query?}", always), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index e98e29f6b..c6d622beb 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -592,6 +592,7 @@ Edit @dynamic:history --- Quick Apply... | app.commandPalette + Effect Console... | app.fxConsole --- Cut | edit.cut Copy | edit.copy diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4bbac2f75..71359f03a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -328,6 +328,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("edit.redo", "Redo", "重做"), ("", "History", "历史记录"), ("app.commandPalette", "Quick Apply...", "快速应用..."), + ("app.fxConsole", "Effect Console...", "效果控制台..."), ("edit.cut", "Cut", "剪切"), ("edit.copy", "Copy", "复制"), ("edit.copyWithPropertyLinks", "Copy with Property Links", "带属性链接复制"), @@ -952,6 +953,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("edit.redo", "Redo", "重做"), ("", "History", "步驟記錄"), ("app.commandPalette", "Quick Apply...", "快速套用..."), + ("app.fxConsole", "Effect Console...", "效果主控台..."), ("edit.cut", "Cut", "剪下"), ("edit.copy", "Copy", "拷貝"), ("edit.copyWithPropertyLinks", "Copy with Property Links", "拷貝屬性連結"), @@ -1573,6 +1575,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("edit.redo", "Redo", "やり直し"), ("", "History", "履歴"), ("app.commandPalette", "Quick Apply...", "クイック適用..."), + ("app.fxConsole", "Effect Console...", "エフェクトコンソール..."), ("edit.cut", "Cut", "カット"), ("edit.copy", "Copy", "コピー"), ("edit.copyWithPropertyLinks", "Copy with Property Links", "プロパティリンク付きでコピー"), @@ -2197,6 +2200,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("edit.redo", "Redo", "Повторити"), ("", "History", "Історія"), ("app.commandPalette", "Quick Apply...", "Швидко застосувати..."), + ("app.fxConsole", "Effect Console...", "Консоль ефектів..."), ("edit.cut", "Cut", "Вирізати"), ("edit.copy", "Copy", "Копіювати"), ("edit.copyWithPropertyLinks", "Copy with Property Links", "Копіювати зі зв’язками властивостей"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0e05e007..72d9788c5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1635,6 +1635,7 @@ impl EffectcraftApp { panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx); + panels::fx_console::show(self, &ctx); panels::scriptui_view::sync_panels(self); panels::scriptui_view::show_windows(self, &ctx); panels::learn::coach(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b4977..678adb940 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -807,6 +807,10 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.dialog = Some(crate::Dialog::CommandPalette); Value::Null } + "app.fxConsole" => { + crate::panels::fx_console::open(app, ctx, p.get("query").and_then(Value::as_str).unwrap_or_default()); + Value::Null + } "app.keyboardShortcuts" => { crate::panels::shortcut_editor::open(app); Value::Null @@ -1492,7 +1496,14 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { } "Shift" => m.shift = true, "Alt" => m.alt = true, - "Ctrl" => m.ctrl = true, + // Off macOS Ctrl is the command key: egui reports both, so a Ctrl shortcut (the + // Effect Console's Ctrl+Space) must expect both to match. + "Ctrl" => { + m.ctrl = true; + if !cfg!(target_os = "macos") { + m.command = true; + } + } k => { key = match k { ";" => Some(egui::Key::Semicolon), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 81fa6bab9..518fcbdc8 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -32,6 +32,8 @@ pub struct DialogState { pub solid_affect_all: bool, pub palette_query: String, pub palette_sel: usize, + /// The Effect Console (Ctrl+Space), when open. It isn't a modal dialog: the app stays live. + pub fx_console: Option, /// Settings dialog page id (`general`, `appearance`…). pub settings_page: String, /// Settings when the dialog opened (Cancel restores them). diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index e60354b15..f8c9f5539 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -245,7 +245,7 @@ fn preset_row( } /// User preset files, rescanned every few seconds (and on Refresh List). -fn user_presets(ctx: &egui::Context, force: bool) -> Vec { +pub(crate) fn user_presets(ctx: &egui::Context, force: bool) -> Vec { let id = egui::Id::new("fx-user-presets"); let now = ctx.input(|i| i.time); if !force diff --git a/crates/ui-egui/src/panels/fx_console.rs b/crates/ui-egui/src/panels/fx_console.rs new file mode 100644 index 000000000..0ae5a3693 --- /dev/null +++ b/crates/ui-egui/src/panels/fx_console.rs @@ -0,0 +1,360 @@ +//! Effect Console (Ctrl+Space, `app.fxConsole`): a small search bar that opens at the pointer, +//! finds effects and animation presets as you type (name, word starts, initials such as `gb` +//! for Gaussian Blur, or the letters in order) and applies the chosen one to the selected +//! layers. Enter applies and closes; Shift+Enter applies and keeps the console open; ↑/↓ move +//! the selection; Escape, Ctrl+Space or a click outside closes it. With no text it lists the +//! recently used effects. + +use std::collections::BTreeSet; + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::json; + +use super::effects_presets::{PresetFile, match_range, push_recent}; +use crate::EffectcraftApp; +use crate::i18n::tr; +use crate::icons::{self, Icon}; +use crate::theme::Tokens; + +const WIDTH: f32 = 400.0; +const FIELD_H: f32 = 30.0; +const ROW_H: f32 = 24.0; +/// Results shown at once. +pub const MAX_RESULTS: usize = 10; + +/// The open console. +#[derive(Clone, Debug, Default)] +pub struct Console { + pub query: String, + pub sel: usize, + /// Where it opened (top-left of the bar is placed around it). + pub at: Option, + /// Opened this frame: a click that opened it doesn't close it. + pub fresh: bool, +} + +/// What a result applies. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Target { + Effect(String), + TextPreset(String), + PresetFile(String), +} + +/// One search result. +#[derive(Clone, Debug, PartialEq)] +pub struct Hit { + pub label: String, + /// Category shown on the right. + pub detail: String, + pub target: Target, + pub score: i32, +} + +/// How well `name` matches `query` (lowercase, trimmed); higher is better, `None` when it +/// doesn't. Exact name, then name start, word start, initials (`gb` → Gaussian Blur), every +/// word starting a word, substring, every word a substring, then the letters in order. +pub fn score(name: &str, query: &str) -> Option { + let q = query.trim(); + if q.is_empty() { + return Some(0); + } + let n = name.to_lowercase(); + // Shorter names win ties ("Blur" before "Bilateral Blur"). + let short = i32::try_from(n.chars().count().min(60)).unwrap_or(60); + if n == q { + return Some(1000); + } + if n.starts_with(q) { + return Some(900 - short); + } + let words: Vec<&str> = n.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + if let Some(i) = words.iter().position(|w| w.starts_with(q)) { + return Some(800 - 4 * i32::try_from(i.min(20)).unwrap_or(20) - short); + } + let tokens: Vec<&str> = q.split_whitespace().collect(); + let initials: String = words.iter().filter_map(|w| w.chars().next()).collect(); + if tokens.len() == 1 && q.chars().count() >= 2 && initials.starts_with(q) { + return Some(750 - short); + } + if tokens.len() > 1 && tokens.iter().all(|t| words.iter().any(|w| w.starts_with(t))) { + return Some(700 - short); + } + if n.contains(q) { + return Some(600 - short); + } + if tokens.len() > 1 && tokens.iter().all(|t| n.contains(t)) { + return Some(500 - short); + } + // The letters in order, fewest skipped first. + let letters: Vec = q.chars().filter(|c| !c.is_whitespace()).collect(); + if letters.len() < 2 { + return None; + } + let mut chars = n.chars(); + let mut skipped = 0i32; + for c in letters { + loop { + match chars.next() { + Some(x) if x == c => break, + Some(_) => skipped = skipped.saturating_add(1), + None => return None, + } + } + } + Some(300 - skipped.min(200) - short) +} + +/// Search effects, text presets and user preset files for `query`, best first. With an empty +/// query: the recently used effects. `recent` is most-recent-first; recently used and +/// favourite effects rank higher, Obsolete ones lower. +pub fn search(query: &str, recent: &[String], favorites: &BTreeSet, presets: &[PresetFile]) -> Vec { + let q = query.trim().to_lowercase(); + let effects = effectcraft_engine::effects::all(); + if q.is_empty() { + return recent + .iter() + .filter_map(|id| effects.iter().find(|e| e.id == id)) + .take(MAX_RESULTS) + .map(|e| Hit { label: e.name.to_string(), detail: e.category.to_string(), target: Target::Effect(e.id.to_string()), score: 0 }) + .collect(); + } + let mut hits: Vec = vec![]; + for e in &effects { + let best = std::iter::once(e.name).chain(effectcraft_engine::effects::aliases(e.id).iter().copied()).filter_map(|n| score(n, &q)).max(); + let Some(mut s) = best else { continue }; + if let Some(i) = recent.iter().position(|r| r == e.id) { + s += 40 - 4 * i32::try_from(i.min(10)).unwrap_or(10); + } + if favorites.contains(e.id) { + s += 30; + } + if e.category.eq_ignore_ascii_case("Obsolete") { + s -= 150; + } + hits.push(Hit { label: e.name.to_string(), detail: e.category.to_string(), target: Target::Effect(e.id.to_string()), score: s }); + } + for (id, name) in effectcraft_engine::text_presets() { + if let Some(s) = score(&name, &q) { + hits.push(Hit { label: name, detail: "Text Preset".into(), target: Target::TextPreset(id), score: s - 5 }); + } + } + for f in presets { + if let Some(s) = score(&f.name, &q) { + hits.push(Hit { label: f.name.clone(), detail: "User Preset".into(), target: Target::PresetFile(f.path.clone()), score: s - 5 }); + } + } + hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))); + hits.truncate(MAX_RESULTS); + hits +} + +/// Open the console at the pointer (`query` prefilled). +pub fn open(app: &mut EffectcraftApp, ctx: &egui::Context, query: &str) { + let at = ctx.input(|i| i.pointer.latest_pos()); + app.dialog_state.fx_console = Some(Console { query: query.to_string(), sel: 0, at, fresh: true }); +} + +/// Apply `target` to the selected layers. Errors are for the status bar. +pub fn apply(app: &mut EffectcraftApp, ctx: &egui::Context, target: &Target) -> Result<(), String> { + if app.session.state.selected_layers.is_empty() { + return Err(tr("Select a layer to apply to").to_string()); + } + let (cmd, params) = match target { + Target::Effect(id) => ("effect.apply", json!({"effect": id})), + Target::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})), + Target::PresetFile(path) => ("anim.applyPreset", json!({"path": path})), + }; + crate::menus::invoke(app, ctx, cmd, params)?; + if let Target::Effect(id) = target { + push_recent(&mut app.ui.effects_recent, id); + } + Ok(()) +} + +/// Text with the match highlighted (as in Effects & Presets). +fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &Tokens) { + if let Some(m) = match_range(name, query.trim()) + && let (Some(pre), Some(hit)) = (name.get(..m.start), name.get(m.clone())) + { + let x0 = p.layout_no_wrap(pre.to_string(), Tokens::ui(12.5), t.text).size().x; + let w = p.layout_no_wrap(hit.to_string(), Tokens::ui(12.5), t.text).size().x; + p.rect_filled(Rect::from_min_size(pos2(pos.x + x0 - 1.0, pos.y - 8.0), vec2(w + 2.0, 16.0)), 2.0, t.accent.gamma_multiply(0.45)); + } + p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.5), t.text); +} + +pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { + let Some(mut c) = app.dialog_state.fx_console.take() else { return }; + let t = app.tokens; + // Keys the console owns, taken before the text field sees them. + let (esc, toggle, down, up, keep, enter) = ctx.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::Escape), + i.consume_key(egui::Modifiers::CTRL, egui::Key::Space), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) | i.consume_key(egui::Modifiers::NONE, egui::Key::Tab), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) | i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab), + i.consume_key(egui::Modifiers::SHIFT, egui::Key::Enter), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + ) + }); + if esc || (toggle && !c.fresh) { + return; + } + let presets = super::effects_presets::user_presets(ctx, false); + let hits = search(&c.query, &app.ui.effects_recent, &app.ui.effects_favorites, &presets); + if down { + c.sel = (c.sel + 1).min(hits.len().saturating_sub(1)); + } + if up { + c.sel = c.sel.saturating_sub(1); + } + c.sel = c.sel.min(hits.len().saturating_sub(1)); + + // Placement: the bar centred on the pointer, kept on screen. + let screen = ctx.content_rect(); + let rows = hits.len().max(1) as f32 + if app.session.state.selected_layers.is_empty() { 1.0 } else { 0.0 }; + let height = FIELD_H + 16.0 + 8.0 + rows * ROW_H + 8.0; + let at = c.at.unwrap_or(screen.center()); + let x = (at.x - WIDTH / 2.0).clamp(screen.min.x + 8.0, (screen.max.x - WIDTH - 8.0).max(screen.min.x + 8.0)); + let y = (at.y - FIELD_H / 2.0 - 8.0).clamp(screen.min.y + 8.0, (screen.max.y - height - 8.0).max(screen.min.y + 8.0)); + + let mut run: Option<(Target, bool)> = None; + let mut q = c.query.clone(); + let area = egui::Area::new(egui::Id::new("fx-console")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { + egui::Frame::new() + .fill(t.panel_bg) + .stroke(Stroke::new(1.0, t.accent.gamma_multiply(0.8))) + .corner_radius(8.0) + .inner_margin(egui::Margin::same(8)) + .shadow(egui::epaint::Shadow { offset: [0, 6], blur: 18, spread: 0, color: Color32::from_black_alpha(110) }) + .show(ui, |ui| { + ui.set_width(WIDTH - 16.0); + let (fr, _) = ui.allocate_exact_size(vec2(WIDTH - 16.0, FIELD_H), Sense::hover()); + let resp = crate::widgets::search_field(ui, fr, &mut q, tr("Search effects and presets"), &t); + resp.request_focus(); + app.auto.add("fxConsole.search", fr, "Effect Console"); + ui.add_space(6.0); + if app.session.state.selected_layers.is_empty() { + let (r, _) = ui.allocate_exact_size(vec2(WIDTH - 16.0, ROW_H), Sense::hover()); + ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, tr("No layer selected"), Tokens::ui(11.5), t.text_faint); + } + if hits.is_empty() { + let (r, _) = ui.allocate_exact_size(vec2(WIDTH - 16.0, ROW_H), Sense::hover()); + let msg = if c.query.trim().is_empty() { tr("Type an effect or preset name") } else { tr("No matching effects or presets") }; + ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(12.0), t.text_faint); + } + for (i, h) in hits.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(WIDTH - 16.0, ROW_H), Sense::click()); + if resp.hovered() && ui.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO) { + c.sel = i; + } + let p = ui.painter(); + if i == c.sel { + p.rect_filled(r, 4.0, t.row_selected); + } + let icon = match h.target { + Target::Effect(ref id) if app.ui.effects_favorites.contains(id) => Icon::Star, + Target::Effect(_) => Icon::Fx, + _ => Icon::Keyframe, + }; + icons::paint(p, Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0)), icon, t.text_dim); + let dw = p.layout_no_wrap(h.detail.clone(), Tokens::ui(11.0), t.text_dim).size().x; + let name_clip = Rect::from_min_max(r.min, pos2(r.max.x - dw - 18.0, r.max.y)); + highlighted(&p.with_clip_rect(name_clip), pos2(r.min.x + 28.0, r.center().y), &h.label, &c.query, &t); + p.text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, &h.detail, Tokens::ui(11.0), t.text_dim); + app.auto.add(&format!("fxConsole.result.{i}"), r, &h.label); + if resp.clicked() { + run = Some((h.target.clone(), ui.input(|inp| inp.modifiers.shift))); + } + } + }); + }); + if q != c.query { + c.query = q; + c.sel = 0; + } + if run.is_none() + && (enter || keep) + && let Some(h) = hits.get(c.sel) + { + run = Some((h.target.clone(), keep)); + } + // A click anywhere else closes it (and goes on to what was clicked). + let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !area.response.rect.contains(p))); + let fresh = std::mem::take(&mut c.fresh); + if outside && !fresh { + return; + } + if let Some((target, stay)) = run { + if let Err(e) = apply(app, ctx, &target) { + app.ui.status = e; + } + if !stay { + return; + } + c.query.clear(); + c.sel = 0; + } + app.dialog_state.fx_console = Some(c); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn top(query: &str) -> Vec { + search(query, &[], &BTreeSet::new(), &[]).into_iter().map(|h| h.label).collect() + } + + #[test] + fn scoring_prefers_exact_then_prefix_then_words_then_initials_then_letters() { + let s = |n| score(n, "gaussian blur").unwrap_or(-1); + assert_eq!(s("Gaussian Blur"), 1000); + assert!(score("Gaussian Blur", "gau").unwrap() > score("Fast Box Blur", "blur").unwrap(), "name start beats a later word"); + assert!(score("Gaussian Blur", "blur").unwrap() > score("Gaussian Blur", "gb").unwrap(), "word start beats initials"); + assert!(score("Gaussian Blur", "gb").unwrap() > score("Gaussian Blur", "ssian").unwrap(), "initials beat a substring"); + assert!(score("Gaussian Blur", "ssian").unwrap() > score("Gaussian Blur", "gsnbr").unwrap(), "substring beats scattered letters"); + assert_eq!(score("Gaussian Blur", "xyz"), None); + assert_eq!(score("Gaussian Blur", "z"), None, "one stray letter doesn't match"); + assert!(score("Fast Box Blur", "box bl").is_some(), "several words, each a word start"); + assert_eq!(score("Anything", " "), Some(0)); + // Non-ASCII names and queries don't panic. + assert!(score("Ünïcödé Blür", "blü").is_some()); + assert_eq!(score("日本", "語"), None); + } + + #[test] + fn search_finds_effects_by_name_initials_and_letters() { + assert_eq!(top("gaussian blur").first().map(String::as_str), Some("Gaussian Blur")); + assert!(top("gb").iter().take(3).any(|l| l == "Gaussian Blur"), "{:?}", top("gb")); + assert!(top("curves").first().is_some_and(|l| l == "Curves"), "{:?}", top("curves")); + assert!(top("glow").iter().any(|l| l == "Glow")); + assert!(top("qqqqzzzz").is_empty()); + assert!(top("blur").len() <= MAX_RESULTS); + } + + #[test] + fn recent_and_favourites_rank_higher_and_empty_lists_recent() { + let reg = effectcraft_engine::effects::all(); + let fast = reg.iter().find(|e| e.name == "Fast Box Blur").map(|e| e.id.to_string()).unwrap(); + let plain = search("blur", &[], &BTreeSet::new(), &[]); + let boosted = search("blur", std::slice::from_ref(&fast), &BTreeSet::new(), &[]); + let pos = |v: &[Hit]| v.iter().position(|h| h.target == Target::Effect(fast.clone())); + assert!(pos(&boosted).unwrap_or(99) <= pos(&plain).unwrap_or(99)); + let favs: BTreeSet = [fast.clone()].into(); + let fav = search("blur", &[], &favs, &[]); + assert!(pos(&fav).unwrap_or(99) <= pos(&plain).unwrap_or(99)); + // No query: the recently used effects, most recent first; unknown ids are skipped. + let recent = vec![fast.clone(), "nope.unknown".into()]; + let empty = search("", &recent, &BTreeSet::new(), &[]); + assert_eq!(empty.iter().map(|h| &h.target).collect::>(), vec![&Target::Effect(fast)]); + } + + #[test] + fn user_presets_are_searched() { + let p = vec![PresetFile { name: "Bouncy Pop".into(), path: "/x/Bouncy Pop.ecpreset".into() }]; + let hits = search("bouncy", &[], &BTreeSet::new(), &p); + assert_eq!(hits.first().map(|h| &h.target), Some(&Target::PresetFile("/x/Bouncy Pop.ecpreset".into()))); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f0ca804c..0cde52e6c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -15,6 +15,7 @@ pub mod expr_editor; pub mod flowchart; pub mod footage_panel; pub mod forms; +pub mod fx_console; pub mod fx_editors; pub mod fx_widgets; pub mod graph; diff --git a/crates/ui-egui/tests/ui_fx_console.rs b/crates/ui-egui/tests/ui_fx_console.rs new file mode 100644 index 000000000..ec8c73654 --- /dev/null +++ b/crates/ui-egui/tests/ui_fx_console.rs @@ -0,0 +1,110 @@ +//! The Effect Console (egui_kittest): Ctrl+Space opens a search bar at the pointer; typing finds +//! effects, Enter applies the selected one to the selected layers and closes, Shift+Enter keeps +//! it open for another, Escape closes without applying. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemId, LayerId}; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// The platform's Ctrl as egui reports it (off macOS Ctrl is also the command key). +fn ctrl() -> Modifiers { + Modifiers { ctrl: true, command: !cfg!(target_os = "macos"), ..Default::default() } +} + +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Red", "color": [1.0, 0.0, 0.0, 1.0]})).unwrap()["layer"].as_u64().unwrap(); + s.state.open_comps = vec![ItemId(main)]; + s.state.active_comp = Some(ItemId(main)); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + (h, l) +} + +fn effects(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec { + let s = &h.state().session; + let layer = s.active_comp().unwrap().layer(LayerId(l)).unwrap(); + layer.props.group("effects").map(|g| g.groups().map(|e| e.match_id.clone()).collect()).unwrap_or_default() +} + +fn open(h: &Harness<'_, EffectcraftApp>) -> bool { + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).any(|e| e.id == "fxConsole.search") +} + +fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + h.event(Event::Text(text.into())); + h.run_steps(2); +} + +#[test] +fn ctrl_space_opens_at_the_pointer_and_enter_applies_the_best_match() { + let (mut h, l) = harness(); + h.event(Event::PointerMoved(pos2(700.0, 400.0))); + h.step(); + h.key_press_modifiers(ctrl(), Key::Space); + h.run_steps(2); + assert!(open(&h), "Ctrl+Space opens the console"); + let field = h.state().auto.elements.iter().find(|e| e.id == "fxConsole.search").cloned().unwrap(); + assert!((field.rect[0] + field.rect[2] / 2.0 - 700.0).abs() < 2.0, "centred on the pointer: {:?}", field.rect); + + type_text(&mut h, "gaussian blur"); + let first = h.state().auto.elements.iter().find(|e| e.id == "fxConsole.result.0").map(|e| e.label.clone()); + assert_eq!(first.as_deref(), Some("Gaussian Blur")); + h.key_press(Key::Enter); + h.run_steps(2); + assert_eq!(effects(&h, l), vec!["ec.blur.gaussian"]); + assert!(!open(&h), "Enter closes it"); + assert_eq!(h.state().ui.effects_recent.first().map(String::as_str), Some("ec.blur.gaussian"), "remembered as recently used"); +} + +#[test] +fn shift_enter_keeps_it_open_arrows_choose_and_escape_closes_without_applying() { + let (mut h, l) = harness(); + h.key_press_modifiers(ctrl(), Key::Space); + h.run_steps(2); + type_text(&mut h, "gb"); + h.key_press_modifiers(Modifiers::SHIFT, Key::Enter); + h.run_steps(2); + assert_eq!(effects(&h, l).len(), 1); + assert!(open(&h), "Shift+Enter keeps the console open"); + + // Pick the second result with ↓. + type_text(&mut h, "blur"); + let second = h.state().auto.elements.iter().find(|e| e.id == "fxConsole.result.1").map(|e| e.label.clone()).unwrap(); + h.key_press(Key::ArrowDown); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(2); + let fx = effects(&h, l); + assert_eq!(fx.len(), 2); + let name = effectcraft_engine::effects::find(&fx[1]).map(|e| e.name).unwrap(); + assert_eq!(name, second); + + // Escape closes and applies nothing. + h.key_press_modifiers(ctrl(), Key::Space); + h.run_steps(2); + type_text(&mut h, "glow"); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(!open(&h)); + assert_eq!(effects(&h, l).len(), 2); +} + +#[test] +fn without_a_selected_layer_nothing_is_applied_and_the_status_says_why() { + let (mut h, l) = harness(); + h.state_mut().session.state.selected_layers.clear(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.fxConsole", json!({"query": "glow"})).unwrap(); + h.run_steps(2); + assert!(open(&h), "app.fxConsole opens it (control channel / MCP)"); + h.key_press(Key::Enter); + h.run_steps(2); + assert!(effects(&h, l).is_empty()); + assert!(h.state().ui.status.contains("Select a layer"), "{}", h.state().ui.status); +}