Merge pull request #2245 from marcoarnulfo/feat/char-all-fills-strokes

Character panel: All Fills Over All Strokes and All Strokes Over All Fills in the stroke order menu
This commit is contained in:
BFlat authored and GitHub committed 2026-10-30 09:54:21 +07:00
commit 1f4b1f92ea
3 files changed
+132 -7

No files matched your search

+54 -7
View File
@@ -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<serde_json::Value>) {
/// 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<serde_json::Value>,
) -> Option<u32> {
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<String> =
["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,27 @@ 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<serde_json::Value>) {
let set = json!({"command": "prop.set", "params": {"layer": tt.layer, "path": "text/moreOptions/fillStroke", "value": v}});
let steps: Vec<serde_json::Value> =
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 { expr: app.session.expr.as_deref(), ..EvalCtx::new(&app.session.project, cid, comp, app.session.time()) };
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 +289,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.
@@ -384,6 +428,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;
+1
View File
@@ -14,6 +14,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;
@@ -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);
}