diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 38c93ebd4..33e9703b0 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -173,21 +173,43 @@ fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, } } -/// Stroke Over Fill / Fill Over Stroke (grid row 2, right). -fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec) { +/// Stroke order (grid row 2, right): per character (Fill Over Stroke / Stroke Over Fill), or for +/// the whole layer, which is the layer's More Options > Fill & Stroke (`fill_stroke`, see +/// [`layer_fill_stroke`]). Returns the layer order picked, if any. +fn stroke_order_field( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + g: Grid, + doc: &TextDoc, + fill_stroke: u32, + actions: &mut Vec, +) -> Option { let t = app.tokens; let Grid { x0, y, w, enabled } = g; let fx = x0 + w / 2.0; let fy = y + 2.0 * 28.0; let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0)); - let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()]; + let labels: Vec = + ["Fill Over Stroke", "Stroke Over Fill", "-", "All Fills Over All Strokes", "All Strokes Over All Fills"].iter().map(|s| s.to_string()).collect(); + let current = match fill_stroke { + 1 => 3, + 2 => 4, + _ => doc.stroke_over_fill as usize, + }; let pop = egui::Id::unique("char-stroke-order-pop"); - if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled { + if widgets::dropdown(ui, dr, &labels[current], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled { widgets::open_popup(ui, pop); } app.auto.add("character.strokeOverFill", dr, "Stroke order"); - if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) { - actions.push(json!({"strokeOverFill": i == 1})); + match widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(current)) { + Some(i @ (3 | 4)) => Some(i as u32 - 2), + Some(i) => { + actions.push(json!({"strokeOverFill": i == 1})); + // A per-character order puts the layer back on the Per Character Palette, or it + // would have no visible effect. + (fill_stroke != 0).then_some(0) + } + None => None, } } @@ -205,6 +227,36 @@ fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } +/// Sets the layer's More Options > Fill & Stroke to `v` together with the per-character order +/// it changes, as one undo step. +fn apply_layer_fill_stroke(app: &mut EffectcraftApp, ctx: &egui::Context, tt: &TextTarget, v: u32, actions: Vec) { + let set = json!({"command": "prop.set", "params": {"layer": tt.layer, "path": "text/moreOptions/fillStroke", "value": v}}); + let steps: Vec = + std::iter::once(set).chain(actions.into_iter().map(|a| json!({"command": "layer.setText", "params": tt.params(a)}))).collect(); + if let Err(e) = crate::menus::invoke(app, ctx, "engine.batch", json!({"steps": steps, "label": "Stroke Order"})) { + app.ui.status = e; + } +} + +/// A text layer's More Options > Fill & Stroke in the active comp: 0 per character palette, +/// 1 all fills over all strokes, 2 all strokes over all fills. +fn layer_fill_stroke(app: &EffectcraftApp, layer: u64) -> u32 { + let (Some(comp), Some(cid)) = (app.session.active_comp(), app.session.active_comp_id()) else { return 0 }; + let Some(l) = comp.layer(effectcraft_engine::project::LayerId(layer)) else { return 0 }; + let Some(more) = l.props.sub("text").and_then(|t| t.sub("moreOptions")) else { return 0 }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; + ectx.e(l, more, "fillStroke") +} + /// The Character panel for the text being edited (or the selected text layer): font family and /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning, /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes. @@ -246,7 +298,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let grid = Grid { x0, y, w, enabled }; numeric_fields(app, ui, &p, grid, &doc, &mut actions); kerning_field(app, ui, &p, grid, &doc, &mut actions); - stroke_order_field(app, ui, grid, &doc, &mut actions); + let fill_stroke = target.as_ref().map_or(0, |tt| layer_fill_stroke(app, tt.layer)); + let layer_fill_stroke_pick = stroke_order_field(app, ui, grid, &doc, fill_stroke, &mut actions); line_join_field(app, ui, &p, grid, &doc, &mut actions); y += 6.0 * 28.0 + 8.0; // Fill / stroke swatches with their enable boxes. @@ -383,6 +436,9 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => {} } let Some(tt) = target else { return }; + if let Some(v) = layer_fill_stroke_pick { + apply_layer_fill_stroke(app, &ctx, &tt, v, std::mem::take(&mut actions)); + } for a in actions { if let Err(e) = crate::menus::invoke(app, &ctx, "layer.setText", tt.params(a)) { app.ui.status = e; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 54f641687..c08b948a7 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -13,6 +13,7 @@ mod ui_batch_switches; mod ui_blank_context_menus; mod ui_camera_tracker; mod ui_character_eyedropper; +mod ui_character_stroke_order; mod ui_color_hex; mod ui_comp_presets; mod ui_control_modifiers; diff --git a/crates/ui-egui/tests/it/ui_character_stroke_order.rs b/crates/ui-egui/tests/it/ui_character_stroke_order.rs new file mode 100644 index 000000000..db89f7c1a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_character_stroke_order.rs @@ -0,0 +1,77 @@ +//! The Character panel's stroke order menu: the two per-character orders and the two layer-wide +//! ones (All Fills Over All Strokes, All Strokes Over All Fills), which set the text layer's +//! More Options ▸ Fill & Stroke. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{ + Event, Modifiers, PointerButton, Pos2, + accesskit::{Role, Toggled}, + pos2, vec2, +}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn open_menu(h: &mut Harness<'_, EffectcraftApp>) { + let e = h.state().auto.find("character.strokeOverFill").unwrap_or_else(|| panic!("missing character.strokeOverFill")).clone(); + click(h, pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5)); +} + +fn pick(h: &mut Harness<'_, EffectcraftApp>, label: &str) { + open_menu(h); + // A menu row: the dropdown shows the current order under the same label. + h.get_by_role_and_label(Role::Button, label).click(); + h.run_steps(3); +} + +fn fill_stroke(s: &mut Session, layer: u64) -> serde_json::Value { + s.execute("prop.get", json!({"layer": layer, "path": "text/moreOptions/fillStroke"})).unwrap()["value"].clone() +} + +#[test] +fn character_stroke_order_menu_sets_layer_fill_and_stroke() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "Caption", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": text, "applyStroke": true, "strokeWidth": 8.0, "strokeOverFill": false})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.set_workspace("Text"); + let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(4); + + pick(&mut h, "All Fills Over All Strokes"); + assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(1)); + pick(&mut h, "All Strokes Over All Fills"); + assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(2)); + // The menu shows the layer's order as the current one. + open_menu(&mut h); + assert_eq!(h.get_by_role_and_label(Role::Button, "All Strokes Over All Fills").accesskit_node().toggled(), Some(Toggled::True)); + assert_eq!(h.get_by_role_and_label(Role::Button, "Fill Over Stroke").accesskit_node().toggled(), Some(Toggled::False)); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/character-stroke-order.png")).unwrap(); + } + h.key_press(egui::Key::Escape); + h.run_steps(2); + + // A per-character order goes back to the Per Character Palette, in one undo step. + pick(&mut h, "Stroke Over Fill"); + assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(0)); + assert!(layer_doc(&h.state().session, LayerId(text)).unwrap().stroke_over_fill); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(fill_stroke(&mut h.state_mut().session, text), json!(2)); + assert!(!layer_doc(&h.state().session, LayerId(text)).unwrap().stroke_over_fill); +}