Labels: label menus show each label's colour (#290)
The label menus listed the labels' names only, so a label couldn't be picked by its colour. Every place a label is chosen now shows its colour swatch before its name, as After Effects does: the Timeline's label swatch (which now opens its menu on a click too, not only a right-click), the Project panel's label swatch and a new Label submenu in its item menu, Edit > Label, keyframe Label menus and the marker dialog. One shared widget (widgets::label_entry / label_popup) draws them; keyframe labels now show the names set in Settings > Labels. Tests: label_swatch_opens_the_label_menu (ui_project_rows), the_label_swatch_menu_shows_the_label_colours (ui_timeline_switches), edit_label_menu_shows_the_label_colours (ui_panels). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3174652ce4
commit
2f3a507f8e
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<u64> = 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<u64> = 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<u64> {
|
||||
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| {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<effectcraft_engine::color::Label> {
|
||||
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<usize>) -> Option<usize> {
|
||||
if !popup_is_open(ui, id) {
|
||||
|
|
|
|||
|
|
@ -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<egui::Shape> = 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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue