M9.8: effects & presets panel (favourites, recently used, animation presets, search highlight, contents menu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
08a48cc1e3
commit
5d3beaf7df
|
|
@ -1,5 +1,9 @@
|
|||
//! Effects & Presets: search + category tree; double-click applies to the selected layers, drag
|
||||
//! onto a layer (timeline, viewer, Effect Controls) applies there.
|
||||
//! Effects & Presets: search (matches highlighted), the contents menu (colour-depth / GPU
|
||||
//! filters, animation presets on/off, categories or alphabetical, refresh), Favorites (star an
|
||||
//! effect), Recently Used, "* Animation Presets" (EffectCraft's own text animation presets and
|
||||
//! the user's `.ecpreset` files) and the effect categories. Double-click applies to the
|
||||
//! selected layers; dragging an effect onto a layer (timeline, viewer, Effect Controls) applies
|
||||
//! it there.
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
|
@ -9,15 +13,304 @@ use crate::panels::DragPayload;
|
|||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
const ROW_H: f32 = 21.0;
|
||||
const RECENT_MAX: usize = 8;
|
||||
|
||||
/// A user animation preset file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PresetFile {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Where user presets live: `$EC_PRESETS_DIR`, else `~/Documents/EffectCraft/Presets`.
|
||||
pub fn presets_dir() -> Option<std::path::PathBuf> {
|
||||
if let Ok(d) = std::env::var("EC_PRESETS_DIR") {
|
||||
return Some(d.into());
|
||||
}
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok()?;
|
||||
Some(std::path::Path::new(&home).join("Documents").join("EffectCraft").join("Presets"))
|
||||
}
|
||||
|
||||
/// The `.ecpreset` files in `dir`, sorted by name.
|
||||
pub fn scan_presets(dir: &std::path::Path) -> Vec<PresetFile> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
|
||||
let mut v: Vec<PresetFile> = rd
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ecpreset")))
|
||||
.filter_map(|p| Some(PresetFile { name: p.file_stem()?.to_string_lossy().to_string(), path: p.to_string_lossy().to_string() }))
|
||||
.collect();
|
||||
v.sort_by_key(|p| p.name.to_lowercase());
|
||||
v
|
||||
}
|
||||
|
||||
/// Byte range of the (case-insensitive) search match in `name`, if any.
|
||||
pub fn match_range(name: &str, query: &str) -> Option<std::ops::Range<usize>> {
|
||||
if query.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lower = name.to_lowercase();
|
||||
let i = lower.find(&query.to_lowercase())?;
|
||||
let end = i + query.len();
|
||||
// Lower-casing can change byte lengths outside ASCII; only highlight clean boundaries.
|
||||
(lower.len() == name.len() && name.is_char_boundary(i) && name.is_char_boundary(end)).then_some(i..end)
|
||||
}
|
||||
|
||||
/// Remember `id` as the most recently used effect.
|
||||
pub fn push_recent(recent: &mut Vec<String>, id: &str) {
|
||||
recent.retain(|r| r != id);
|
||||
recent.insert(0, id.to_string());
|
||||
recent.truncate(RECENT_MAX);
|
||||
}
|
||||
|
||||
/// Does the contents-menu depth filter (`all`, `32`, `gpu`) keep effect `e`?
|
||||
pub fn depth_keeps(depth: &str, e: &effectcraft_engine::effects::EffectSpec) -> bool {
|
||||
match depth {
|
||||
"32" => e.float,
|
||||
"gpu" => e.gpu,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn filled_star(p: &egui::Painter, c: egui::Pos2, r: f32, fill: Option<Color32>, stroke: Color32) {
|
||||
let pts: Vec<egui::Pos2> = (0..10)
|
||||
.map(|i| {
|
||||
let a = std::f32::consts::PI * i as f32 / 5.0 - std::f32::consts::FRAC_PI_2;
|
||||
let rr = if i % 2 == 0 { r } else { r * 0.45 };
|
||||
c + vec2(a.cos(), a.sin()) * rr
|
||||
})
|
||||
.collect();
|
||||
if let Some(f) = fill {
|
||||
// The star is concave: fill it as five triangles around the centre plus the pentagon.
|
||||
for i in 0..5 {
|
||||
let tri = vec![pts[2 * i], pts[(2 * i + 1) % 10], pts[(2 * i + 9) % 10]];
|
||||
p.add(egui::Shape::convex_polygon(tri, f, Stroke::NONE));
|
||||
}
|
||||
let inner: Vec<egui::Pos2> = (0..5).map(|i| pts[2 * i + 1]).collect();
|
||||
p.add(egui::Shape::convex_polygon(inner, f, Stroke::NONE));
|
||||
}
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.0, stroke)));
|
||||
}
|
||||
|
||||
/// Text with the search match highlighted.
|
||||
fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &Tokens) {
|
||||
if let Some(m) = match_range(name, query) {
|
||||
let pre = p.layout_no_wrap(name[..m.start].to_string(), Tokens::ui(12.0), t.text).size().x;
|
||||
let hit = p.layout_no_wrap(name[m.clone()].to_string(), Tokens::ui(12.0), t.text).size().x;
|
||||
let r = Rect::from_min_size(pos2(pos.x + pre - 1.0, pos.y - 8.0), vec2(hit + 2.0, 16.0));
|
||||
p.rect_filled(r, 2.0, t.accent.gamma_multiply(0.45));
|
||||
}
|
||||
p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
|
||||
}
|
||||
|
||||
enum Apply {
|
||||
Effect(String),
|
||||
TextPreset(String),
|
||||
PresetFile(String),
|
||||
}
|
||||
|
||||
/// A folder row; returns whether it is open.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn folder_row(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
key: &str,
|
||||
label: &str,
|
||||
indent: f32,
|
||||
forced_open: bool,
|
||||
icon: Option<Icon>,
|
||||
) -> bool {
|
||||
let t = app.tokens;
|
||||
let open = forced_open || app.ui.effects_open.contains(key);
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return open;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0));
|
||||
icons::paint(lp, tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
let ic = Rect::from_center_size(pos2(r.min.x + 28.0 + indent, r.center().y), vec2(13.0, 13.0));
|
||||
match icon {
|
||||
Some(Icon::Star) => filled_star(lp, ic.center(), 6.0, Some(Color32::from_rgb(0xe8, 0xc0, 0x40)), Color32::from_rgb(0xe8, 0xc0, 0x40)),
|
||||
Some(i) => icons::paint(lp, ic, i, t.text_dim),
|
||||
None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)),
|
||||
}
|
||||
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("effects.category.{key}"), r, label);
|
||||
if resp.clicked() && !forced_open {
|
||||
if open {
|
||||
app.ui.effects_open.remove(key);
|
||||
} else {
|
||||
app.ui.effects_open.insert(key.to_string());
|
||||
}
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// An effect row (fx icon, highlighted name, favourite star, 32 bpc badge).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn effect_row(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
e: &'static effectcraft_engine::effects::EffectSpec,
|
||||
indent: f32,
|
||||
query: &str,
|
||||
auto_prefix: &str,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
|
||||
highlighted(lp, pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t);
|
||||
if e.float {
|
||||
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
|
||||
// Favourite star (always shown when starred, else on hover).
|
||||
let fav = app.ui.effects_favorites.contains(e.id);
|
||||
let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click());
|
||||
if fav || resp.hovered() || sresp.hovered() {
|
||||
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
|
||||
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" });
|
||||
if sresp.clicked() {
|
||||
if fav {
|
||||
app.ui.effects_favorites.remove(e.id);
|
||||
} else {
|
||||
app.ui.effects_favorites.insert(e.id.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(Apply::Effect(e.id.to_string()));
|
||||
}
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(&ctx, DragPayload::Effect(e.id.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset row (no drag; double-click applies).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn preset_row(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
on_apply: Apply,
|
||||
apply: &mut Option<Apply>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim);
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(on_apply);
|
||||
}
|
||||
}
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool) -> Vec<PresetFile> {
|
||||
let id = egui::Id::new("fx-user-presets");
|
||||
let now = ctx.input(|i| i.time);
|
||||
if !force
|
||||
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, std::sync::Arc<Vec<PresetFile>>)>(id))
|
||||
&& now - at < 5.0
|
||||
{
|
||||
return (*v).clone();
|
||||
}
|
||||
let v = presets_dir().map(|d| scan_presets(&d)).unwrap_or_default();
|
||||
ctx.data_mut(|d| d.insert_temp(id, (now, std::sync::Arc::new(v.clone()))));
|
||||
v
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().clone();
|
||||
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 16.0, 24.0));
|
||||
// Track the last applied effect (menus, drag and drop, the control channel) as recently used.
|
||||
if let Some(last) = app.session.state.last_effect.clone()
|
||||
&& app.ui.effects_recent.first() != Some(&last)
|
||||
{
|
||||
push_recent(&mut app.ui.effects_recent, &last);
|
||||
}
|
||||
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
|
||||
let mut q = app.ui.effects_search.clone();
|
||||
widgets::search_field(ui, sr, &mut q, "Search effects", &t);
|
||||
widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
|
||||
app.ui.effects_search = q.clone();
|
||||
app.auto.add("effects.search", sr, "Search");
|
||||
// Contents menu.
|
||||
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
|
||||
let mpop = egui::Id::new("fx-contents-menu");
|
||||
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text("Effects & Presets options").clicked() {
|
||||
widgets::open_popup(ui, mpop);
|
||||
}
|
||||
app.auto.add("effects.menu", mr, "Effects & Presets options");
|
||||
let view = app.ui.effects_view.clone();
|
||||
let mark = |on: bool, l: &str| if on { format!("✓ {l}") } else { format!(" {l}") };
|
||||
let menu = vec![
|
||||
mark(view.depth == "all", "Show Effects for All Color Depths"),
|
||||
mark(view.depth == "32", "Show 32 bpc Effects Only"),
|
||||
mark(view.depth == "gpu", "Show GPU Accelerated Effects Only"),
|
||||
"-".into(),
|
||||
mark(view.presets, "Show Animation Presets"),
|
||||
"-".into(),
|
||||
mark(!view.alphabetical, "Categories"),
|
||||
mark(view.alphabetical, "Alphabetical"),
|
||||
"-".into(),
|
||||
" Refresh List".into(),
|
||||
" Clear Recently Used".into(),
|
||||
];
|
||||
let mut force_scan = false;
|
||||
if let Some(i) = widgets::popup_menu(ui, mpop, mr.left_bottom() - vec2(220.0, 0.0), &menu, None) {
|
||||
let v = &mut app.ui.effects_view;
|
||||
match i {
|
||||
0 => v.depth = "all".into(),
|
||||
1 => v.depth = "32".into(),
|
||||
2 => v.depth = "gpu".into(),
|
||||
4 => v.presets = !v.presets,
|
||||
6 => v.alphabetical = false,
|
||||
7 => v.alphabetical = true,
|
||||
9 => force_scan = true,
|
||||
10 => app.ui.effects_recent.clear(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let view = app.ui.effects_view.clone();
|
||||
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
|
||||
let lp = p.with_clip_rect(list);
|
||||
let scroll_id = egui::Id::new("fx-scroll");
|
||||
|
|
@ -25,67 +318,87 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if ui.rect_contains_pointer(list) {
|
||||
scroll = (scroll - ui.input(|i| i.smooth_scroll_delta.y)).max(0.0);
|
||||
}
|
||||
let query = q.to_lowercase();
|
||||
let query = q.trim().to_lowercase();
|
||||
let searching = !query.is_empty();
|
||||
let mut y = list.min.y - scroll;
|
||||
let mut apply: Option<String> = None;
|
||||
let row_h = 21.0;
|
||||
// "* Animation Presets" (our own, original presets come later) then categories.
|
||||
let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec();
|
||||
cats.retain(|c| effectcraft_engine::effects::registry().iter().any(|e| e.category == *c));
|
||||
for cat in cats {
|
||||
let items: Vec<_> = effectcraft_engine::effects::registry()
|
||||
.iter()
|
||||
.filter(|e| e.category == cat && (query.is_empty() || e.name.to_lowercase().contains(&query)))
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
let mut apply: Option<Apply> = None;
|
||||
let reg = effectcraft_engine::effects::registry();
|
||||
let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query));
|
||||
let lookup = |id: &str| reg.iter().find(|e| e.id == id);
|
||||
|
||||
// Favorites and Recently Used.
|
||||
let favs: Vec<_> = app.ui.effects_favorites.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
|
||||
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
|
||||
let mut favs = favs;
|
||||
favs.sort_by_key(|e| e.name);
|
||||
for e in favs {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply);
|
||||
}
|
||||
let open = !query.is_empty() || app.ui.effects_open.contains(cat);
|
||||
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), row_h));
|
||||
y += row_h;
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0));
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", cat)), Sense::click());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let recent: Vec<_> = app.ui.effects_recent.iter().filter_map(|id| lookup(id)).filter(|e| keep(e)).collect();
|
||||
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) {
|
||||
for e in recent {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply);
|
||||
}
|
||||
icons::paint(&lp, tw, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
|
||||
icons::paint(&lp, Rect::from_center_size(pos2(r.min.x + 28.0, r.center().y), vec2(13.0, 13.0)), Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58));
|
||||
lp.text(pos2(r.min.x + 40.0, r.center().y), Align2::LEFT_CENTER, cat, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("effects.category.{cat}"), r, cat);
|
||||
if resp.clicked() && query.is_empty() {
|
||||
if open {
|
||||
app.ui.effects_open.remove(cat);
|
||||
} else {
|
||||
app.ui.effects_open.insert(cat.to_string());
|
||||
}
|
||||
|
||||
// * Animation Presets: our text presets and the user's .ecpreset files.
|
||||
if view.presets {
|
||||
let text: Vec<(String, String)> =
|
||||
effectcraft_engine::text_presets().into_iter().filter(|(_, n)| query.is_empty() || n.to_lowercase().contains(&query)).collect();
|
||||
let files: Vec<PresetFile> =
|
||||
user_presets(&ctx, force_scan).into_iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
if (!text.is_empty() || !files.is_empty()) && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, Apply::TextPreset(id.clone()), &mut apply);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
for f in files {
|
||||
preset_row(
|
||||
app,
|
||||
ui,
|
||||
&lp,
|
||||
list,
|
||||
&mut y,
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
Apply::PresetFile(f.path.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Effects: by category, or one alphabetical list.
|
||||
if view.alphabetical {
|
||||
let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect();
|
||||
all.sort_by_key(|e| e.name.to_lowercase());
|
||||
for e in all {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply);
|
||||
}
|
||||
for e in items {
|
||||
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), row_h));
|
||||
y += row_h;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
} else {
|
||||
let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec();
|
||||
cats.retain(|c| reg.iter().any(|e| e.category == *c));
|
||||
for cat in cats {
|
||||
let items: Vec<_> = reg.iter().filter(|e| e.category == cat && keep(e)).collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::new(("fx", e.id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
if !folder_row(app, ui, &lp, list, &mut y, cat, cat, 0.0, searching, None) {
|
||||
continue;
|
||||
}
|
||||
icons::paint(&lp, Rect::from_center_size(pos2(r.min.x + 44.0, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
|
||||
lp.text(pos2(r.min.x + 56.0, r.center().y), Align2::LEFT_CENTER, e.name, Tokens::ui(12.0), t.text);
|
||||
if e.float {
|
||||
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
|
||||
}
|
||||
app.auto.add(&format!("effects.item.{}", e.id), r, e.name);
|
||||
if resp.double_clicked() {
|
||||
apply = Some(e.id.to_string());
|
||||
}
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(&ctx, DragPayload::Effect(e.id.to_string()));
|
||||
for e in items {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply);
|
||||
}
|
||||
}
|
||||
}
|
||||
if y == list.min.y - scroll && searching {
|
||||
lp.text(pos2(list.center().x, list.min.y + 24.0), Align2::CENTER_CENTER, "No matching effects or presets", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
let content = y + scroll - list.min.y;
|
||||
scroll = scroll.min((content - list.height()).max(0.0));
|
||||
ctx.data_mut(|d| d.insert_temp(scroll_id, scroll));
|
||||
|
|
@ -100,14 +413,74 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
painter.rect_stroke(r, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
|
||||
}
|
||||
if let Some(id) = apply {
|
||||
if app.session.state.selected_layers.is_empty() {
|
||||
app.ui.status = "Select a layer to apply the effect to".into();
|
||||
} else if let Err(e) = app.session.execute("effect.apply", json!({"effect": id})) {
|
||||
app.ui.status = e.to_string();
|
||||
} else {
|
||||
app.show_panel(crate::dock::PanelKind::EffectControls);
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
let Some(apply) = apply else { return };
|
||||
if app.session.state.selected_layers.is_empty() {
|
||||
app.ui.status = "Select a layer to apply to".into();
|
||||
return;
|
||||
}
|
||||
let (cmd, params) = match &apply {
|
||||
Apply::Effect(id) => ("effect.apply", json!({"effect": id})),
|
||||
Apply::TextPreset(id) => ("layer.applyTextPreset", json!({"preset": id})),
|
||||
Apply::PresetFile(path) => ("anim.applyPreset", json!({"path": path})),
|
||||
};
|
||||
match app.session.execute(cmd, params) {
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
Ok(_) => {
|
||||
if let Apply::Effect(id) = &apply {
|
||||
push_recent(&mut app.ui.effects_recent, id);
|
||||
app.show_panel(crate::dock::PanelKind::EffectControls);
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_match_ranges() {
|
||||
assert_eq!(match_range("Gaussian Blur", "blur"), Some(9..13));
|
||||
assert_eq!(match_range("Gaussian Blur", "GAUSS"), Some(0..5));
|
||||
assert_eq!(match_range("Gaussian Blur", "x"), None);
|
||||
assert_eq!(match_range("Gaussian Blur", ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_list_is_most_recent_first_and_bounded() {
|
||||
let mut r = vec![];
|
||||
for id in ["a", "b", "c", "a"] {
|
||||
push_recent(&mut r, id);
|
||||
}
|
||||
assert_eq!(r, vec!["a", "c", "b"]);
|
||||
for i in 0..20 {
|
||||
push_recent(&mut r, &format!("x{i}"));
|
||||
}
|
||||
assert_eq!(r.len(), RECENT_MAX);
|
||||
assert_eq!(r[0], "x19");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_filter() {
|
||||
let reg = effectcraft_engine::effects::registry();
|
||||
let all = reg.iter().filter(|e| depth_keeps("all", e)).count();
|
||||
let f32s = reg.iter().filter(|e| depth_keeps("32", e)).count();
|
||||
assert_eq!(all, reg.len());
|
||||
assert!(f32s <= all && f32s > 0);
|
||||
assert!(reg.iter().filter(|e| depth_keeps("32", e)).all(|e| e.float));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_user_preset_files() {
|
||||
let d = std::env::temp_dir().join(format!("ec-presets-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("Zoom In.ecpreset"), "{}").unwrap();
|
||||
std::fs::write(d.join("bounce.ECPRESET"), "{}").unwrap();
|
||||
std::fs::write(d.join("notes.txt"), "").unwrap();
|
||||
let v = scan_presets(&d);
|
||||
assert_eq!(v.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), vec!["bounce", "Zoom In"]);
|
||||
assert!(scan_presets(&d.join("missing")).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue