diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6903e7c3..8fb22ba3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1680,12 +1680,20 @@ fn gutter(checked: bool) -> egui::Atom<'static> { } fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool { - let label = entry_label(app, e); - let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label)); + let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e)); + // Edit ▸ Label's entries show the label's colour before its name. + if e.command == "edit.label" + && let Some(l) = e.params.get("label").and_then(Value::as_str).and_then(effectcraft_engine::color::Label::from_name) + { + let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner; + crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens); + return out.response.clicked(); + } + let mut b = egui::Button::new((check, label)); if let Some(s) = entry_shortcut(app, e) { b = b.shortcut_text(shortcut_text(&s)); } - ui.add_enabled(entry_enabled(app, e), b).clicked() + ui.add_enabled(enabled, b).clicked() } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index e6bcfa64..16fff58e 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -190,12 +190,10 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let labels = effectcraft_engine::color::Label::ALL; let cur = labels.get(d.label).copied().unwrap_or_default(); let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| { - for (i, l) in labels.iter().enumerate() { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); - ui.painter().rect_filled(r, 2.0, app.tokens.label(*l)); - ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l)); - }); + for (i, l) in labels.into_iter().enumerate() { + if crate::widgets::label_entry(ui, l, &app.session.prefs.label_name(l), d.label == i, &app.tokens).clicked() { + d.label = i; + } } }); app.auto.add("dialog.marker.label", r.response.rect, "Label"); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 9ea7a0ff..0c7b971b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -8,6 +8,7 @@ use std::sync::{Arc, Mutex}; +use effectcraft_engine::color::Label; use effectcraft_engine::project::{Item, ItemId, ItemKind}; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; @@ -485,14 +486,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click()); app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name()); let pop = egui::Id::new(("plabel-pop", id.0)); - if sresp.clicked() { + if sresp.clicked() || sresp.secondary_clicked() { widgets::open_popup(ui, pop); } - let names: Vec = effectcraft_engine::color::Label::ALL.iter().map(|l| app.session.prefs.label_name(*l)).collect(); - let cur = effectcraft_engine::color::Label::ALL.iter().position(|l| *l == it.label); - if let Some(li) = widgets::popup_menu(ui, pop, sw.left_bottom(), &names, cur) { - let items: Vec = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }; - actions.push(("project.setLabel".into(), json!({"items": items, "label": li}))); + if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) { + actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)}))); } // Optional columns. for (key, _, cx, w) in cols.iter().skip(2) { @@ -576,8 +574,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } resp.context_menu(|ui| { if ui.button("Delete").clicked() { - let items: Vec = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }; - actions.push(("project.delete".into(), json!({"items": items}))); + actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); ui.close(); } if matches!(it.kind, ItemKind::Comp(_)) && ui.button("Open Composition").clicked() { @@ -604,6 +601,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } ui.close(); } + ui.separator(); + ui.menu_button("Label", |ui| { + for l in Label::ALL { + if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() { + actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)}))); + ui.close(); + } + } + }); }); } // The scroll bars, over the rows (which would take their presses otherwise). @@ -775,6 +781,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } +/// Items a row's menu acts on: the selection when the row is selected, else the row's item. +fn targets(app: &EffectcraftApp, id: ItemId, selected: bool) -> Vec { + if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] } +} + +/// `project.setLabel`'s index of a label. +fn label_index(l: Label) -> usize { + Label::ALL.iter().position(|x| *x == l).unwrap_or(0) +} + /// The column header's context menu: show or hide the optional columns. fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) { resp.context_menu(|ui| { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index c9456b4e..e58bf9e5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1599,15 +1599,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { lp.rect_filled(lr, 2.0, t.label(layer.label)); let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click()); app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name()); - lresp.context_menu(|ui| { - for lab in effectcraft_engine::color::Label::ALL { - let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) }; - if ui.button(name).clicked() { - actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); - ui.close(); - } - } - }); + // Click (or right-click) the swatch for the label menu (After Effects). + let pop = egui::Id::new(("label-pop", layer.id.0)); + if lresp.clicked() || lresp.secondary_clicked() { + widgets::open_popup(ui, pop); + } + if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) { + actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()}))); + } } if vis.num { lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim); @@ -2384,8 +2383,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lbl == "Select All Keyframes" { // Keyframe colour labels. ui.menu_button("Label", |ui| { - for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() { - if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() { + for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() { + if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() { if !ks { actions.push(( "keys.select".into(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 54a3bfe3..5a7971a2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -365,6 +365,61 @@ pub fn check_label(on: bool, label: &str) -> String { if on { format!("✓ {label}") } else { format!(" {label}") } } +fn label_swatch_id() -> egui::Id { + egui::Id::new("label-swatch") +} + +/// The colour swatch of a Label menu row, as an atom for its button: Label menus show each +/// label's colour before its name (After Effects), so a label is picked by its colour. Paint it +/// with [`paint_label_swatch`] once the button is laid out. +pub fn label_swatch() -> egui::Atom<'static> { + egui::Atom::custom(label_swatch_id(), vec2(12.0, 12.0)) +} + +/// Paint the [`label_swatch`] of a laid-out button: label `l`'s colour, an empty box for None. +pub fn paint_label_swatch(ui: &Ui, out: &egui::AtomLayoutResponse, l: effectcraft_engine::color::Label, t: &Tokens) { + let Some(r) = out.rect(label_swatch_id()) else { return }; + let r = Rect::from_center_size(r.center(), vec2(11.0, 11.0)); + if l == effectcraft_engine::color::Label::None { + ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + } else { + ui.painter().rect_filled(r, 2.0, t.label(l)); + } +} + +/// A Label menu row: label `l`'s swatch then `name`, highlighted when it is the `current` label. +pub fn label_entry(ui: &mut Ui, l: effectcraft_engine::color::Label, name: &str, current: bool, t: &Tokens) -> Response { + let out = egui::Button::selectable(current, (label_swatch(), name)).atom_ui(ui); + paint_label_swatch(ui, &out, l, t); + out.response +} + +/// The open label menu `id` at `pos` (open it with [`open_popup`]): every label's swatch and its +/// name from Settings ▸ Labels (`name`). Returns the chosen label. +pub fn label_popup( + ui: &mut Ui, + id: egui::Id, + pos: egui::Pos2, + current: effectcraft_engine::color::Label, + name: impl Fn(effectcraft_engine::color::Label) -> String, + t: &Tokens, +) -> Option { + if !popup_is_open(ui, id) { + return None; + } + let labels = effectcraft_engine::color::Label::ALL; + popup_list(ui, id, pos, Rect::NOTHING, labels.len(), 0, |ui| { + ui.set_min_width(160.0); + let mut chosen = None; + for l in labels { + if label_entry(ui, l, &name(l), l == current, t).clicked() { + chosen = Some(l); + } + } + chosen + }) +} + /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { if !popup_is_open(ui, id) { diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index 6b8bd4a5..9fd7d94b 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -153,6 +153,29 @@ fn saved_workspace_is_listed_in_the_workspace_menu() { assert_eq!(h.state().ui.dock, saved, "its layout came back"); } +/// Edit ▸ Label shows each label's colour before its name (#290), and choosing one labels the +/// selected layer. +#[test] +fn edit_label_menu_shows_the_label_colours() { + let mut h = harness(); + click(&mut h, "menu.Edit"); + let label = h.query_by_label(" Label ⏵").expect("Edit ▸ Label").rect(); + hover(&mut h, label.center()); + // (Not Dark Green; the entry's label starts with its check-mark column.) + let green = h.query_by_label(" Green").expect("the Label submenu is open").rect(); + let col = h.state().tokens.label(effectcraft_engine::color::Label::Green); + let swatch = |s: &egui::Shape| matches!(s, egui::Shape::Rect(r) if r.fill == col && r.rect.width() < 16.0 && green.contains(r.rect.center())); + let shapes: Vec = h.output().shapes.iter().map(|c| c.shape.clone()).collect(); + assert!(shapes.iter().any(|s| swatch(s) || matches!(s, egui::Shape::Vec(v) if v.iter().any(swatch))), "no Green swatch"); + // Into the submenu, then down to Green. + hover(&mut h, pos2(green.center().x, label.center().y)); + for k in 1..=10 { + hover(&mut h, pos2(green.center().x, label.center().y + (green.center().y - label.center().y) * k as f32 / 10.0)); + } + click_at(&mut h, green.center()); + assert_eq!(h.state().session.active_comp().unwrap().layers[0].label, effectcraft_engine::color::Label::Green); +} + /// Learn opens the Home screen's tutorials; clicking another workspace tab closes them again, /// however quickly the tabs are clicked (they stayed over every workspace, #272). #[test] diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 1fc07668..f2a44fc5 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -5,7 +5,7 @@ use effectcraft_engine::Session; use effectcraft_engine::color::Label; use effectcraft_engine::project::{ItemId, ItemKind}; use effectcraft_ui_egui::EffectcraftApp; -use egui::{Event, Modifiers, Pos2, pos2}; +use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; @@ -80,6 +80,18 @@ fn folder_twirl_opens_and_closes_the_folder_on_one_click() { assert!(h.state().ui.project_open_folders.contains(&folder.0), "a double-click on the row opens the folder"); } +/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`. +fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool { + fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool { + match s { + egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x, + egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)), + _ => false, + } + } + h.output().shapes.iter().any(|c| walk(&c.shape, row, col)) +} + #[test] fn label_swatch_opens_the_label_menu() { let (mut h, _, _, solid) = harness(); @@ -89,6 +101,8 @@ fn label_swatch_opens_the_label_menu() { click(&mut h, &format!("project.item.{}.label", solid.0)); assert!(h.state().session.state.project_selection.is_empty(), "the swatch does not select the row"); let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + // Each entry shows its colour before its name (#290). + assert!(swatch_in(&h, entry, h.state().tokens.label(Label::Green)), "no {green} swatch"); let undo = h.state().session.history.undo.len(); click_at(&mut h, entry.center()); assert_eq!(h.state().session.project.item(solid).unwrap().label, Label::Green); diff --git a/crates/ui-egui/tests/ui_timeline_switches.rs b/crates/ui-egui/tests/ui_timeline_switches.rs index 9c410af9..20f861d4 100644 --- a/crates/ui-egui/tests/ui_timeline_switches.rs +++ b/crates/ui-egui/tests/ui_timeline_switches.rs @@ -1,13 +1,15 @@ //! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and //! dragging over other layers gives them all the state the first one got, in one undo step, as -//! in After Effects (#227); stopwatches the same way (#284). Also the layer bar past its source -//! (#290). +//! in After Effects (#227); stopwatches the same way (#284). Also the label swatch's menu and the +//! layer bar past its source (#290). use effectcraft_engine::Session; +use effectcraft_engine::color::Label; use effectcraft_engine::project::LayerId; use effectcraft_ui_egui::EffectcraftApp; -use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2}; +use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; /// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom. @@ -146,6 +148,38 @@ fn dragging_over_stopwatches_sets_them_all() { assert_eq!(animated(&h), [true; 5], "each drag was one step"); } +/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`. +fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool { + fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool { + match s { + egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x, + egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)), + _ => false, + } + } + h.output().shapes.iter().any(|c| walk(&c.shape, row, col)) +} + +/// Clicking a layer's label swatch opens the label menu, which shows each label's colour before +/// its name, so a label is picked by its colour (#290). +#[test] +fn the_label_swatch_menu_shows_the_label_colours() { + let (mut h, ids) = harness(); + let green = h.state().session.prefs.label_name(Label::Green); + let at = rect(&h, &format!("timeline.layer.{}.label", ids[1])).center(); + drag(&mut h, at, at, 1); + let entry = h.query_by_label(&green).expect("the label menu is open").rect(); + for l in [Label::Green, Label::SeaFoam, Label::Purple] { + let e = h.query_by_label(&h.state().session.prefs.label_name(l)).unwrap().rect(); + assert!(swatch_in(&h, e, h.state().tokens.label(l)), "no {l:?} swatch"); + } + let undo = h.state().session.history.undo.len(); + drag(&mut h, entry.center(), entry.center(), 1); + assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(ids[1])).unwrap().label, Label::Green); + assert_eq!(h.state().session.history.undo.len(), undo + 1); + assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); +} + /// A layer extended past its source's end (its last frame held) has that part of its bar /// striped, so the source's own span shows (#290); with time remapping it isn't. #[test]