diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 434f2b79..c0b61996 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -294,7 +294,6 @@ const JAPANESE: &[(&str, &str)] = &[ ("Latest auto-save: {}", "最新の自動保存:{}"), ("Layer", "レイヤー"), ("Layer controls", "レイヤーコントロール"), - ("Layer {}", "レイヤー{}"), ("Layer:", "レイヤー:"), ("Layer: {}", "レイヤー:{}"), ("Layer Styles", "レイヤースタイル"), @@ -1029,7 +1028,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Latest auto-save: {}", "最新的自动保存:{}"), ("Layer", "图层"), ("Layer controls", "图层控制"), - ("Layer {}", "图层 {}"), ("Layer:", "图层:"), ("Layer: {}", "图层:{}"), ("Layer Styles", "图层样式"), @@ -1769,7 +1767,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Latest auto-save: {}", "最新的自動儲存:{}"), ("Layer", "圖層"), ("Layer controls", "圖層控制"), - ("Layer {}", "圖層 {}"), ("Layer:", "圖層:"), ("Layer: {}", "圖層:{}"), ("Layer Styles", "圖層樣式"), @@ -2512,7 +2509,6 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Latest auto-save: {}", "Останнє автозбереження: {}"), ("Layer", "Шар"), ("Layer controls", "Керування шарами"), - ("Layer {}", "Шар {}"), ("Layer:", "Шар:"), ("Layer: {}", "Шар: {}"), ("Layer Styles", "Стилі шару"), @@ -3070,7 +3066,6 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Latest auto-save: {}", "Último salvamento automático: {}"), ("Layer", "Camada"), ("Layer controls", "Controles da camada"), - ("Layer {}", "Camada {}"), ("Layer:", "Camada:"), ("Layer: {}", "Camada: {}"), ("Layer Styles", "Estilos de camada"), @@ -3529,7 +3524,6 @@ const CZECH: &[(&str, &str)] = &[ ("Latest auto-save: {}", "Nejnovější automatická záloha: {}"), ("Layer", "Vrstva"), ("Layer controls", "Ovládací prvky vrstvy"), - ("Layer {}", "Vrstva {}"), ("Layer:", "Vrstva:"), ("Layer: {}", "Vrstva: {}"), ("Layer Styles", "Styly vrstvy"), @@ -4092,7 +4086,6 @@ const SPANISH: &[(&str, &str)] = &[ ("Latest auto-save: {}", "Último guardado automático: {}"), ("Layer", "Capa"), ("Layer controls", "Controles de capa"), - ("Layer {}", "Capa {}"), ("Layer:", "Capa:"), ("Layer: {}", "Capa: {}"), ("Layer Styles", "Estilos de capa"), diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 03b3a1ac..f930aff2 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -441,19 +441,10 @@ fn prop_row( let comp = app.session.active_comp_arc(); if let Some(comp) = comp { let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); - let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id); - let name = l.and_then(|id| idx_of(id).map(|i| format!("{}. {}", i + 1, comp.layers[i].name))).unwrap_or_else(|| "None".into()); - let pop = egui::Id::new(("ec-lpop", uid)); - if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() { - widgets::open_popup(ui, pop); + if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::new(("ec-l", uid))) { + set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); } app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - let mut opts = vec!["None".to_string()]; - opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name))); - let cur = l.and_then(idx_of).map(|i| i + 1).unwrap_or(0); - if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) { - set(actions, if i == 0 { serde_json::Value::Null } else { json!(comp.layers[i - 1].id.0) }); - } // Source / Masks / Effects & Masks (the hidden companion parameter). if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { let si = src.value.as_enum() as usize; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..a48cc3e4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4233,13 +4233,16 @@ fn value_editor( } Value::Text(_) => {} Value::Layer(l) => { - p.text( - pos2(x, at.y), - Align2::LEFT_CENTER, - l.map(|l| crate::i18n::tr_args("Layer {}", &[&l])).unwrap_or(crate::i18n::tr("None").into()), - Tokens::ui(12.0), - t.hot_text, - ); + // Layer references (a light's Source, a Displacement Map's layer…) pick from the + // comp's layers, as in Effect Controls; the choice is written through the same + // undoable prop.set as the other value arms. + let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0)); + if let Some(comp) = app.session.active_comp_arc() + && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::new(("tl-lay", uid))) + { + set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); + } + app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); } Value::Gradient(_) => { // Opens the Gradient Editor at the current time (#612). diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d..f74cc048 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -557,6 +557,44 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] }) } +/// The drop-down for a layer-valued property (`ParamUi::Layer`): the current layer shown as +/// "N. name" (or "None"), and a popup of "None" plus every layer of the comp. Effect Controls +/// and the Timeline share it so a layer reference (an Environment light's Source, a Displacement +/// Map's layer) is picked the same way in every panel, and the reference is shown as its layer's +/// index and name rather than its internal id. Returns `Some(chosen)` when the user picked — +/// the layer's id, or `None` for "None" — and `None` while nothing is chosen; the caller writes +/// it as an undoable `prop.set` (id, or null for "None"). +pub fn layer_picker( + ui: &mut Ui, + rect: Rect, + layers: &[effectcraft_engine::project::Layer], + current: Option, + t: &Tokens, + id: egui::Id, +) -> Option> { + let index_of = |wanted: u64| layers.iter().position(|l| l.id.0 == wanted); + let entry = |i: usize| layers.get(i).map(|l| format!("{}. {}", i + 1, l.name)); + let current_index = current.and_then(index_of); + let label = current_index.and_then(entry).unwrap_or_else(|| crate::i18n::tr("None").to_string()); + let pop = id.with("pop"); + if dropdown(ui, rect, &label, t, id).clicked() { + open_popup(ui, pop); + } + if !popup_is_open(ui, pop) { + return None; + } + // Built only while open: a comp can hold hundreds of layers. + let mut options: Vec = Vec::with_capacity(layers.len() + 1); + options.push(crate::i18n::tr("None").to_string()); + options.extend((0..layers.len()).filter_map(entry)); + // The current entry is highlighted (the first option is "None"). + match popup_menu(ui, pop, rect.left_bottom(), &options, Some(current_index.map_or(0, |i| i + 1))) { + Some(0) => Some(None), + Some(picked) => layers.get(picked - 1).map(|l| Some(l.id.0)), + None => None, + } +} + /// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators, /// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape /// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when diff --git a/crates/ui-egui/tests/ui_layer_source_picker.rs b/crates/ui-egui/tests/ui_layer_source_picker.rs new file mode 100644 index 00000000..9b952de7 --- /dev/null +++ b/crates/ui-egui/tests/ui_layer_source_picker.rs @@ -0,0 +1,211 @@ +//! Picking a layer-valued property with the shared drop-down: the Timeline draws a layer-valued +//! Light Options property (an Environment light's Source) as a drop-down of the comp's layers, +//! Effect Controls' layer parameters (a Displacement Map's layer) pick through the same widget, +//! and both write an undoable `prop.set`. + +use effectcraft_engine::Session; +use effectcraft_engine::keyframe::Value; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::NodeT; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2) { + h.event(egui::Event::PointerMoved(at)); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +fn rect_of(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation element {id}")).clone(); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +/// Whether `want` is painted inside `row` (the drop-down's current-value text). +fn painted_in(h: &Harness<'_, EffectcraftApp>, row: Rect, want: &str) -> bool { + h.output().shapes.iter().any(|c| match &c.shape { + egui::Shape::Text(t) => t.galley.text() == want && row.expand(3.0).contains(t.pos), + _ => false, + }) +} + +/// The current value of a layer-valued property, by uid. +fn layer_value(h: &Harness<'_, EffectcraftApp>, layer: u64, prop: u64) -> Option { + let comp = h.state().session.active_comp().unwrap(); + match &comp.layer(LayerId(layer)).unwrap().props.find(prop).unwrap().value { + Value::Layer(v) => *v, + other => panic!("property {prop} is not layer-valued: {other:?}"), + } +} + +/// The "N. name" a drop-down shows for a layer. +fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String { + let comp = h.state().session.active_comp().unwrap(); + let i = comp.layers.iter().position(|l| l.id.0 == layer).unwrap(); + format!("{}. {}", i + 1, comp.layers[i].name) +} + +/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key, +/// so the pick below records its own step instead of folding into a picker write), picks `none` +/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`. +#[track_caller] +fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) { + h.state_mut().session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": value})).unwrap(); + h.run_steps(2); + assert_eq!(layer_value(h, layer, prop), Some(value)); + let steps = h.state().session.history.undo.len(); + let at = rect_of(h, value_id).center(); + click(h, at); + h.get_by_label(none).click(); + h.run_steps(3); + assert_eq!(layer_value(h, layer, prop), None, "selecting None clears the value"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one clear undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer"); +} + +/// A light's Source (a layer-valued Light Options property) picks a comp layer from the +/// Timeline and undo restores None. +#[test] +fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap(); + let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap(); + let (group, source) = { + let comp = s.active_comp().unwrap(); + let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap(); + (opts.uid, opts.get("source").unwrap().uid) + }; + let mut app = EffectcraftApp::new(s); + app.ui.timeline.open_layers.insert(light); + app.ui.timeline.open_groups.insert(group); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + h.run_steps(4); + + let value_id = format!("timeline.prop.{source}.value"); + // Unset: the row is a drop-down reading "None" — an element to click, not bare text. + assert_eq!(layer_value(&h, light, source), None); + assert!(painted_in(&h, rect_of(&h, &value_id), "None"), "the Source row shows a None drop-down"); + + // Opening it lists "None" and every layer of the comp, by index and name. + let at = rect_of(&h, &value_id).center(); + click(&mut h, at); + let entry = entry_for(&h, sky); + assert!(h.query_by_label("None").is_some(), "the list starts with None"); + assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})"); + + let steps = h.state().session.history.undo.len(); + h.get_by_label(&entry).click(); + h.run_steps(3); + assert_eq!(layer_value(&h, light, source), Some(sky), "the Source holds the layer id"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + // The row names the layer (index and name), not its internal id. + assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the Source row shows the layer's name"); + + // Dismissing the list without choosing writes nothing; reopening marks the chosen layer as + // the current entry (and "None" as not current). + click(&mut h, pos2(1500.0, 300.0)); + h.run_steps(2); + assert_eq!(layer_value(&h, light, source), Some(sky), "dismissing the list writes nothing"); + let reopen = rect_of(&h, &value_id).center(); + click(&mut h, reopen); + assert_eq!(h.get_by_label(&entry).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the current entry is highlighted"); + assert_eq!(h.get_by_label("None").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False), "None is not the current entry"); + click(&mut h, pos2(1500.0, 300.0)); + h.run_steps(2); + + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(layer_value(&h, light, source), None, "undo restores None"); + + none_pick_clears(&mut h, &value_id, "None", light, source, sky); +} + +/// The shared widget is the one Effect Controls already used: a Displacement Map's layer +/// parameter still picks from the drop-down and still undoes. The interface language is +/// Japanese: "None" is the catalog's word there, like every other label drawn in the panel. +#[test] +fn effect_controls_layer_param_picks_through_the_shared_widget() { + let mut s = Session::default(); + s.prefs.general.language = "ja".to_string(); + s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 4})).unwrap(); + let map = s.execute("layer.newSolid", json!({"name": "Disp", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let base = s.execute("layer.newSolid", json!({"name": "Base", "color": "#20c040"})).unwrap()["layer"].as_u64().unwrap(); + let fx = s.execute("effect.apply", json!({"layers": [base], "effect": "Displacement Map"})).unwrap()["effects"][0].as_u64().unwrap(); + let prop = { + let comp = s.active_comp().unwrap(); + comp.layer(LayerId(base)).unwrap().effects().unwrap().find_group(fx).unwrap().get("displacementMapLayer").unwrap().uid + }; + s.state.selected_layers = vec![LayerId(base)]; + let mut app = EffectcraftApp::new(s); + app.show_panel(PanelKind::EffectControls); + let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app); + h.run_steps(4); + + let value_id = format!("effectControls.prop.{prop}.value"); + // The closed drop-down is the translated "None" (なし), not a hardcoded English word. + let row = rect_of(&h, &value_id); + assert!(painted_in(&h, row, "なし"), "the parameter row shows the translated None"); + click(&mut h, row.center()); + let entry = entry_for(&h, map); + assert!(h.query_by_label(&entry).is_some(), "the comp's layers are listed ({entry})"); + let steps = h.state().session.history.undo.len(); + h.get_by_label(&entry).click(); + h.run_steps(3); + assert_eq!(layer_value(&h, base, prop), Some(map), "the parameter holds the layer id"); + assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step"); + // The row names the picked layer, as the Timeline's Source row does. + assert!(painted_in(&h, rect_of(&h, &value_id), &entry), "the parameter row shows the layer's name"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(layer_value(&h, base, prop), None, "undo restores None"); + + none_pick_clears(&mut h, &value_id, "なし", base, prop, map); +} + +/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over +/// the layer list, and the row after a layer is chosen. Renders with wgpu when `EC_SNAPSHOT_DIR` +/// is set: `EC_SNAPSHOT_DIR=shots cargo test -p effectcraft-ui-egui --test ui_layer_source_picker -- --ignored`. +#[test] +#[ignore = "renders with wgpu; run with --ignored (set EC_SNAPSHOT_DIR to keep PNGs)"] +fn snapshot_source_dropdown_open_and_chosen() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 1280, "height": 720, "frameRate": 30, "duration": 5})).unwrap(); + s.execute("comp.settings", json!({"renderer": "advanced3D"})).unwrap(); + let sky = s.execute("layer.newSolid", json!({"name": "Sky HDRI", "color": "#66aaff"})).unwrap()["layer"].as_u64().unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env Light"})).unwrap()["layer"].as_u64().unwrap(); + let (group, source) = { + let comp = s.active_comp().unwrap(); + let opts = comp.layer(LayerId(light)).unwrap().props.sub("lightOptions").unwrap(); + (opts.uid, opts.get("source").unwrap().uid) + }; + let mut app = EffectcraftApp::new(s); + app.ui.timeline.open_layers.insert(light); + app.ui.timeline.open_groups.insert(group); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).wgpu().build_eframe(|_| app); + h.run_steps(4); + let save = |h: &mut Harness<'_, EffectcraftApp>, name: &str| { + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().expect("render").save(format!("{dir}/{name}")).expect("save"); + } + }; + save(&mut h, "source_unset.png"); + let at = rect_of(&h, &format!("timeline.prop.{source}.value")).center(); + click(&mut h, at); + save(&mut h, "source_dropdown_open.png"); + let entry = entry_for(&h, sky); + h.get_by_label(&entry).click(); + h.run_steps(3); + assert_eq!(layer_value(&h, light, source), Some(sky)); + save(&mut h, "source_chosen.png"); +}