mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M0.9: AE-style Properties panel, context tab titles, AE default workspace; headless UI snapshot tool
- Properties panel (panels/properties.rs): Layer Transform with keyframe navigator or stopwatch, AE value formatting (integers, %, Nx+N° rotation), linked Scale, Reset; Text (font/style/size/leading/tracking/stroke, fill/stroke toggles); Paragraph (7 alignments); Text Animation > Add Animator; scrolling. - Tabs name what they show: Composition <comp>, <comp> timeline, Effect Controls <layer>, Properties: <layer>. - Default workspace: full-height right column, Preview over Properties. - engine: time.go nextKey/prevKey accept prop uid; layer.setText supports all seven justify modes, applyFill/applyStroke and Auto leading (fixes the Paragraph 'justify' button mapping to Justify All). - examples/snapshot: render the real UI headless (egui_kittest + wgpu) with scripted control-channel steps. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
214345df13
commit
4f47a01fed
12 files changed
+765
-65
No files matched your search
@@ -23,5 +23,6 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run -p effectcraft -- --control 9877` opens the desktop app with the JSON-lines control server (`docs/control-protocol.md`).
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`; or headless: `cargo run -p effectcraft-cli -- snapshot out.png`.
|
||||
- For UI work, **look at the result**. Headless (no window, works while the user is busy on the machine): `cargo run -p effectcraft-ui-egui --example snapshot -- --out ui.png --step '{"method":"engine.execute","params":{"command":"layer.select","params":{"layers":["#2"]}}}'` (steps are control-channel requests; `--script steps.jsonl`; `{"method":"snap","params":{"path":…}}` for intermediate shots). Or drive the live app via the control channel and take `ui.screenshot` (needs the window visible on screen).
|
||||
- After Effects reference: drive AE via ExtendScript (`osascript -e 'tell application "Adobe After Effects 2026" to DoScriptFile "x.jsx"'`) and capture only AE's window (`screencapture -l <windowid>`); keep captures in `plan/aftereffects/ref/` (gitignored), never commit them, never capture the Home screen.
|
||||
- Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-<name>`; keep every `Cargo.toml` valid (the `crates/*` glob).
|
||||
Generated
+1
@@ -1249,6 +1249,7 @@ dependencies = [
|
||||
"effectcraft-color",
|
||||
"effectcraft-engine",
|
||||
"effectcraft-geom",
|
||||
"effectcraft-host",
|
||||
"effectcraft-keyframe",
|
||||
"effectcraft-path",
|
||||
"effectcraft-project",
|
||||
|
||||
@@ -95,8 +95,17 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
|
||||
if let Some(v) = f_p(p, "tracking") {
|
||||
d.tracking = v;
|
||||
}
|
||||
if let Some(v) = f_p(p, "leading") {
|
||||
d.leading = Some(v);
|
||||
match p.get("leading") {
|
||||
Some(Value::Number(n)) => d.leading = n.as_f64(),
|
||||
// "auto" or null: Auto Leading (120% of the font size).
|
||||
Some(Value::String(_) | Value::Null) => d.leading = None,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyFill") {
|
||||
d.apply_fill = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "applyStroke") {
|
||||
d.apply_stroke = v;
|
||||
}
|
||||
if let Some(v) = b_p(p, "allCaps") {
|
||||
d.all_caps = v;
|
||||
@@ -108,10 +117,14 @@ fn text_doc_from(p: &Value, base: TextDoc) -> TextDoc {
|
||||
d.faux_italic = v;
|
||||
}
|
||||
if let Some(j) = str_p(p, "justify") {
|
||||
// AE's seven Paragraph alignment buttons.
|
||||
d.justify = match j.to_ascii_lowercase().as_str() {
|
||||
"center" | "centre" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
"justify" | "justifyall" => Justify::JustifyAll,
|
||||
"justify" | "justifyleft" | "justifylastleft" => Justify::JustifyLastLeft,
|
||||
"justifycenter" | "justifylastcenter" => Justify::JustifyLastCenter,
|
||||
"justifyright" | "justifylastright" => Justify::JustifyLastRight,
|
||||
"justifyall" => Justify::JustifyAll,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
}
|
||||
@@ -927,7 +940,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{layer?, text?, size?, font?, style?, fill?, stroke?, strokeWidth?, tracking?, leading?, justify?, allCaps?, fauxBold?, fauxItalic?}",
|
||||
"{layer?, text?, size?, font?, style?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, allCaps?, fauxBold?, fauxItalic?}",
|
||||
has_layers,
|
||||
set_text
|
||||
),
|
||||
|
||||
@@ -56,13 +56,23 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
comp.layers.iter().filter(|l| s.state.selected_layers.contains(&l.id)).collect()
|
||||
};
|
||||
// `prop` (uid) narrows the search to one property: the Properties panel's ◀ ▶ arrows.
|
||||
let only = p.get("prop").and_then(Value::as_u64);
|
||||
if only.is_some() {
|
||||
times.clear();
|
||||
}
|
||||
for l in layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if only.is_some_and(|u| u != pr.uid) {
|
||||
return;
|
||||
}
|
||||
for k in &pr.keys {
|
||||
times.push(l.comp_time(k.time));
|
||||
}
|
||||
});
|
||||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
if only.is_none() {
|
||||
times.extend(l.markers.iter().map(|m| l.comp_time(m.time)));
|
||||
}
|
||||
}
|
||||
times.sort();
|
||||
times.dedup();
|
||||
@@ -93,6 +103,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))),
|
||||
cmd!("time.nextKey", "Go to Next Keyframe or Marker", [], Some("K"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextKey"}))),
|
||||
cmd!("time.previousKey", "Go to Previous Keyframe or Marker", [], Some("J"), "{}", has_comp, |s, _| go(s, &json!({"to": "prevKey"}))),
|
||||
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey}", has_comp, go),
|
||||
cmd!("time.go", "Go To", [], None, "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid}", has_comp, go),
|
||||
]
|
||||
}
|
||||
@@ -114,3 +114,58 @@ fn time_navigation() {
|
||||
s.execute("time.set", json!({"frame": 45})).unwrap();
|
||||
assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_navigation_per_property() {
|
||||
let mut s = demo();
|
||||
// An animated property and the comp times of its keys.
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
let mut found = None;
|
||||
for l in &comp.layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if found.is_none() && pr.keys.len() >= 2 {
|
||||
found = Some((pr.uid, pr.keys.iter().map(|k| l.comp_time(k.time)).collect::<Vec<_>>()));
|
||||
}
|
||||
});
|
||||
}
|
||||
let (uid, times) = found.expect("demo has an animated property");
|
||||
s.set_time(effectcraft_time::Tick::ZERO);
|
||||
let half = comp.frame_duration().0 / 2;
|
||||
let expect = times.iter().copied().filter(|t| t.0 > half).min().unwrap();
|
||||
s.execute("time.go", json!({"to": "nextKey", "prop": uid})).unwrap();
|
||||
let snap = |t| comp.frame_rate.snap(t);
|
||||
assert_eq!(s.time(), snap(expect));
|
||||
// Going back from past the last key lands on the last key.
|
||||
s.set_time(comp.duration);
|
||||
s.execute("time.go", json!({"to": "prevKey", "prop": uid})).unwrap();
|
||||
assert_eq!(s.time(), snap(*times.iter().max().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_paragraph_fill_and_leading() {
|
||||
use effectcraft_keyframe::{Justify, Value as KValue};
|
||||
let mut s = demo();
|
||||
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let doc = |s: &Session| {
|
||||
let l = s.active_comp().unwrap().layer(crate::project::LayerId(t)).unwrap().clone();
|
||||
match &l.props.prop("text/sourceText").unwrap().value {
|
||||
KValue::Text(d) => (**d).clone(),
|
||||
v => panic!("{v:?}"),
|
||||
}
|
||||
};
|
||||
for (key, j) in [
|
||||
("justifyLeft", Justify::JustifyLastLeft),
|
||||
("justifyCenter", Justify::JustifyLastCenter),
|
||||
("justifyRight", Justify::JustifyLastRight),
|
||||
("justifyAll", Justify::JustifyAll),
|
||||
("center", Justify::Center),
|
||||
] {
|
||||
s.execute("layer.setText", json!({"layer": t, "justify": key})).unwrap();
|
||||
assert_eq!(doc(&s).justify, j, "{key}");
|
||||
}
|
||||
s.execute("layer.setText", json!({"layer": t, "leading": 50, "applyFill": false, "applyStroke": true})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!((d.leading, d.apply_fill, d.apply_stroke), (Some(50.0), false, true));
|
||||
s.execute("layer.setText", json!({"layer": t, "leading": "auto"})).unwrap();
|
||||
assert_eq!(doc(&s).leading, None);
|
||||
}
|
||||
@@ -31,3 +31,4 @@ kurbo = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
image = { workspace = true }
|
||||
pollster = "0.4"
|
||||
effectcraft-host = { workspace = true }
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Headless UI snapshot: runs the real [`EffectcraftApp`] without a window (egui_kittest + wgpu)
|
||||
//! and writes PNGs. Works regardless of window focus, Spaces or display sleep, so agents can
|
||||
//! look at the UI at any time.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p effectcraft-ui-egui --example snapshot -- [--out ui.png] [--size 1680x1020]
|
||||
//! [--scale 2] [--settle 1.5] [--empty] [--script steps.jsonl | --step '<json>']...
|
||||
//! ```
|
||||
//!
|
||||
//! Each script step is a control-channel request (`{"method":"engine.execute","params":{...}}`,
|
||||
//! see `docs/control-protocol.md`), run in order with the app settling in between. The extra
|
||||
//! method `{"method":"snap","params":{"path":"x.png"}}` writes an intermediate snapshot.
|
||||
//! Replies are printed to stdout as JSON lines.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_ui_egui::{ControlRequest, EffectcraftApp};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Args {
|
||||
out: String,
|
||||
size: (f32, f32),
|
||||
scale: f32,
|
||||
settle: f64,
|
||||
demo: bool,
|
||||
steps: Vec<Value>,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut a = Args { out: "ui.png".into(), size: (1680.0, 1020.0), scale: 2.0, settle: 1.5, demo: true, steps: Vec::new() };
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
let mut val = || it.next().ok_or_else(|| format!("{arg} needs a value"));
|
||||
match arg.as_str() {
|
||||
"--out" => a.out = val()?,
|
||||
"--size" => {
|
||||
let v = val()?;
|
||||
let (w, h) = v.split_once('x').ok_or("--size WxH")?;
|
||||
a.size = (w.parse().map_err(|_| "bad width")?, h.parse().map_err(|_| "bad height")?);
|
||||
}
|
||||
"--scale" => a.scale = val()?.parse().map_err(|_| "bad --scale")?,
|
||||
"--settle" => a.settle = val()?.parse().map_err(|_| "bad --settle")?,
|
||||
"--empty" => a.demo = false,
|
||||
"--step" => a.steps.push(serde_json::from_str(&val()?).map_err(|e| format!("--step: {e}"))?),
|
||||
"--script" => {
|
||||
let path = val()?;
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) {
|
||||
a.steps.push(serde_json::from_str(line).map_err(|e| format!("{path}: {e}: {line}"))?);
|
||||
}
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
return Err("usage: snapshot [--out ui.png] [--size WxH] [--scale 2] [--settle secs] [--empty] [--script f.jsonl | --step json]...".into());
|
||||
}
|
||||
_ => return Err(format!("unknown argument {arg}")),
|
||||
}
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Step the app for `secs` of wall time so background frame renders land in the viewer.
|
||||
fn settle(h: &mut Harness<'_, EffectcraftApp>, secs: f64) {
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
h.step();
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
}
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn snap(h: &mut Harness<'_, EffectcraftApp>, path: &str) -> Result<(), String> {
|
||||
let img = h.render()?;
|
||||
img.save(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
println!("{}", json!({"snapshot": path, "width": img.width(), "height": img.height()}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = match parse_args() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let mut session = effectcraft_host::session();
|
||||
if args.demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let (tx, rx) = mpsc::channel::<ControlRequest>();
|
||||
let mut harness = Harness::builder()
|
||||
.with_size(egui::vec2(args.size.0, args.size.1))
|
||||
.with_pixels_per_point(args.scale)
|
||||
.wgpu()
|
||||
.build_eframe(move |_cc| EffectcraftApp::new(session).with_control(rx));
|
||||
settle(&mut harness, args.settle);
|
||||
for step in &args.steps {
|
||||
let method = step["method"].as_str().unwrap_or_default().to_string();
|
||||
let params = step.get("params").cloned().unwrap_or(json!({}));
|
||||
if method == "snap" {
|
||||
if let Err(e) = snap(&mut harness, params["path"].as_str().unwrap_or("snap.png")) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
let _ = tx.send(ControlRequest { method: method.clone(), params, reply: reply_tx });
|
||||
// Requests are answered on a later frame (some wait for input to be processed).
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let reply = loop {
|
||||
harness.step();
|
||||
if let Ok(v) = reply_rx.try_recv() {
|
||||
break v;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
break json!({"ok": false, "error": "no reply within 10 s"});
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
println!("{}", json!({"method": method, "reply": reply}));
|
||||
settle(&mut harness, 0.3);
|
||||
}
|
||||
settle(&mut harness, args.settle.min(1.0));
|
||||
if let Err(e) = snap(&mut harness, &args.out) {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+18
-16
@@ -240,19 +240,12 @@ pub fn workspace(name: &str) -> DockNode {
|
||||
),
|
||||
tabs(&[Timeline, RenderQueue], 0),
|
||||
),
|
||||
// Default: Project/Effect Controls | Composition | Info, Audio, Preview, Effects & Presets…
|
||||
_ => vsplit(
|
||||
Ratio(0.56),
|
||||
hsplit(
|
||||
FixedA(300.0),
|
||||
tabs(&[Project, EffectControls], 0),
|
||||
hsplit(
|
||||
FixedB(290.0),
|
||||
tabs(&[Composition, Layer], 0),
|
||||
vsplit(Ratio(0.42), tabs(&[Info, Audio, Preview], 2), tabs(&[EffectsPresets, Properties, Character, Paragraph, Align], 0)),
|
||||
),
|
||||
),
|
||||
tabs(&[Timeline, RenderQueue], 0),
|
||||
// Default (as in After Effects): Project/Effect Controls | Composition over the Timeline,
|
||||
// and a full-height right column with Preview above the Properties panel.
|
||||
_ => hsplit(
|
||||
FixedB(300.0),
|
||||
vsplit(Ratio(0.56), hsplit(FixedA(300.0), tabs(&[Project, EffectControls], 0), tabs(&[Composition, Layer], 0)), tabs(&[Timeline, RenderQueue], 0)),
|
||||
vsplit(FixedA(250.0), tabs(&[Preview, Info, Audio], 0), tabs(&[Properties, EffectsPresets, Character, Paragraph, Align], 0)),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -411,7 +404,15 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
}
|
||||
|
||||
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
|
||||
pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry) -> Vec<DockAction> {
|
||||
/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style).
|
||||
pub fn draw_group_chrome(
|
||||
ui: &mut egui::Ui,
|
||||
g: &Group,
|
||||
focused: PanelKind,
|
||||
t: &Tokens,
|
||||
reg: &mut crate::automation::Registry,
|
||||
title: &dyn Fn(PanelKind) -> String,
|
||||
) -> Vec<DockAction> {
|
||||
let mut actions = Vec::new();
|
||||
let painter = ui.painter().clone();
|
||||
painter.rect_filled(g.rect, t.radius, t.panel_bg);
|
||||
@@ -429,7 +430,8 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
|
||||
let text_y = strip.min.y + 16.0;
|
||||
for (i, p) in g.panels.iter().enumerate() {
|
||||
let is_active = i == g.active;
|
||||
let galley = painter.layout_no_wrap(p.title().to_string(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let label = title(*p);
|
||||
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 20.0 } else { 0.0 };
|
||||
let w = galley.size().x + 16.0 + menu_w;
|
||||
if x + w > strip.max.x - 20.0 && i > g.active {
|
||||
@@ -445,7 +447,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &T
|
||||
}
|
||||
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click());
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, p.title());
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
|
||||
let label_x = tab.min.x + 8.0;
|
||||
let label_w = galley.size().x;
|
||||
let col = if is_active || resp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
|
||||
@@ -467,14 +467,31 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
|
||||
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
|
||||
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
|
||||
let mut out = Vec::new();
|
||||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
out.push((PanelKind::Composition, format!("Composition {cname}")));
|
||||
out.push((PanelKind::Timeline, cname));
|
||||
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
|
||||
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
|
||||
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) {
|
||||
let t = self.tokens;
|
||||
let mut dock = std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 });
|
||||
let mut groups = Vec::new();
|
||||
dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto);
|
||||
let mut actions = Vec::new();
|
||||
let titles = self.tab_titles();
|
||||
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
|
||||
for g in &groups {
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto));
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title));
|
||||
}
|
||||
self.ui.dock = dock;
|
||||
for g in &groups {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Effect Controls (the selected layer's effects with all parameters) and the Properties panel
|
||||
//! (essential properties: transform + text/shape basics).
|
||||
//! Effect Controls: the selected layer's effects with all parameters.
|
||||
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::project::{GroupKind, Layer, Node, ParamUi, PropGroup, Property};
|
||||
@@ -376,41 +375,3 @@ impl EffectKind for PropGroup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties panel: the selected layer's transform (and text basics), always visible.
|
||||
pub fn properties(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some(layer) = selected_layer(app) else {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
};
|
||||
let Some(cid) = app.session.active_comp_id() else { return };
|
||||
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()));
|
||||
let snap_project = app.session.project.clone();
|
||||
let snap_expr = app.session.expr.clone();
|
||||
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref() };
|
||||
let mut y = rect.min.y + 8.0;
|
||||
p.text(pos2(rect.min.x + 10.0, y + 8.0), Align2::LEFT_CENTER, &layer.name, Tokens::semibold(13.0), t.text);
|
||||
p.text(pos2(rect.min.x + 10.0, y + 24.0), Align2::LEFT_CENTER, layer.source.type_name(), Tokens::ui(11.0), t.text_dim);
|
||||
y += 40.0;
|
||||
let mut actions: Actions = vec![];
|
||||
if let Some(tr) = layer.transform().cloned() {
|
||||
p.text(pos2(rect.min.x + 10.0, y + 10.0), Align2::LEFT_CENTER, "Layer Transform", Tokens::semibold(12.0), t.text);
|
||||
y += 24.0;
|
||||
for pr in tr.props() {
|
||||
if pr.three_d_only && !layer.is_3d() {
|
||||
continue;
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 24.0));
|
||||
prop_row(app, ui, &p, &layer, pr, &ectx, r, 16.0, &mut actions);
|
||||
y += 24.0;
|
||||
}
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod effects_presets;
|
||||
pub mod info;
|
||||
pub mod misc;
|
||||
pub mod project;
|
||||
pub mod properties;
|
||||
pub mod text_panels;
|
||||
pub mod timeline;
|
||||
pub mod viewer;
|
||||
@@ -58,7 +59,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
|
||||
PanelKind::Character => text_panels::character(app, ui, rect),
|
||||
PanelKind::Paragraph => text_panels::paragraph(app, ui, rect),
|
||||
PanelKind::Align => text_panels::align(app, ui, rect),
|
||||
PanelKind::Properties => effect_controls::properties(app, ui, rect),
|
||||
PanelKind::Properties => properties::show(app, ui, rect),
|
||||
PanelKind::Audio => misc::audio(app, ui, rect),
|
||||
PanelKind::History => misc::history(app, ui, rect),
|
||||
PanelKind::Markers => misc::markers(app, ui, rect),
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
//! Properties panel: the selected layer's essential properties in one context-sensitive stack,
|
||||
//! like After Effects' Properties panel. Sections appear by layer type:
|
||||
//!
|
||||
//! - **Layer Transform** (all layers but cameras/lights): keyframe navigator (◀ ◆ ▶) or
|
||||
//! stopwatch, scrubbable values, linked Scale, `Nx+N°` Rotation, Reset.
|
||||
//! - **Text** (text layers): font family/style, size, leading, tracking, stroke width, fill and
|
||||
//! stroke with enable checkboxes; "More" opens the Character panel.
|
||||
//! - **Paragraph** (text layers): the seven alignment buttons; "More" opens the Paragraph panel.
|
||||
//! - **Text Animation** (text layers): Add Animator menu.
|
||||
|
||||
use effectcraft_engine::keyframe::{Justify, TextDoc, Value};
|
||||
use effectcraft_engine::project::{Layer, LayerSource, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dock::PanelKind;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{EffectcraftApp, widgets};
|
||||
|
||||
type Actions = Vec<(String, serde_json::Value)>;
|
||||
|
||||
const ROW_H: f32 = 22.0;
|
||||
const PAD: f32 = 12.0;
|
||||
|
||||
/// Text Animation › Add Animator entries: (menu label, `layer.addTextAnimator` property).
|
||||
const ANIMATORS: &[(&str, &str)] = &[
|
||||
("Anchor Point", "anchor"),
|
||||
("Position", "position"),
|
||||
("Scale", "scale"),
|
||||
("Skew", "skew"),
|
||||
("Rotation", "rotation"),
|
||||
("Opacity", "opacity"),
|
||||
("Fill Color", "fillColor"),
|
||||
("Stroke Color", "strokeColor"),
|
||||
("Stroke Width", "strokeWidth"),
|
||||
("Tracking", "tracking"),
|
||||
("Line Spacing", "lineSpacing"),
|
||||
("Character Offset", "characterOffset"),
|
||||
("Blur", "blur"),
|
||||
];
|
||||
|
||||
/// AE's seven Paragraph alignment buttons: (justify, `layer.setText` key).
|
||||
const ALIGNS: [(Justify, &str); 7] = [
|
||||
(Justify::Left, "left"),
|
||||
(Justify::Center, "center"),
|
||||
(Justify::Right, "right"),
|
||||
(Justify::JustifyLastLeft, "justifyLeft"),
|
||||
(Justify::JustifyLastCenter, "justifyCenter"),
|
||||
(Justify::JustifyLastRight, "justifyRight"),
|
||||
(Justify::JustifyAll, "justifyAll"),
|
||||
];
|
||||
|
||||
/// Value text the way AE prints it: whole numbers without decimals, otherwise one decimal.
|
||||
fn decimals(v: f64) -> usize {
|
||||
if (v - v.round()).abs() < 1e-6 { 0 } else { 1 }
|
||||
}
|
||||
|
||||
/// Rotation as AE shows it: revolutions and remaining degrees (`1x+30°`, `-0x-45°`).
|
||||
pub fn split_rotation(deg: f64) -> (i64, f64) {
|
||||
let rev = (deg / 360.0).trunc();
|
||||
(rev as i64, deg - rev * 360.0)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().with_clip_rect(rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let comp = app.session.active_comp().cloned();
|
||||
let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|id| c.layer(*id)).cloned());
|
||||
let (Some(comp), Some(cid), Some(layer)) = (comp, app.session.active_comp_id(), layer) else {
|
||||
p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer to see its properties", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
};
|
||||
let project = app.session.project.clone();
|
||||
let expr = app.session.expr.clone();
|
||||
let now = app.session.time();
|
||||
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref() };
|
||||
let x0 = rect.min.x + PAD;
|
||||
let w = rect.width() - 2.0 * PAD;
|
||||
// Vertical scrolling: content height is known after drawing, so clamp with last frame's.
|
||||
let scroll_id = egui::Id::new("props-scroll");
|
||||
let (mut scroll, content_h): (f32, f32) = ui.data(|d| d.get_temp(scroll_id)).unwrap_or((0.0, 0.0));
|
||||
if ui.rect_contains_pointer(rect) {
|
||||
scroll -= ui.input(|i| i.smooth_scroll_delta.y);
|
||||
}
|
||||
scroll = scroll.clamp(0.0, (content_h - rect.height()).max(0.0));
|
||||
let top = rect.min.y + 8.0 - scroll;
|
||||
let mut y = top;
|
||||
let mut actions: Actions = vec![];
|
||||
|
||||
// ---------------------------------------------------------------- Layer Transform
|
||||
if let Some(tr) = layer.transform().cloned() {
|
||||
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
|
||||
let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
|
||||
let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click());
|
||||
p.text(rr.right_center(), Align2::RIGHT_CENTER, "Reset", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
|
||||
app.auto.add("properties.transform.reset", rr, "Reset Transform");
|
||||
if resp.clicked() {
|
||||
for pr in tr.props() {
|
||||
actions.push(("prop.reset".into(), json!({"layer": layer.id.0, "prop": pr.uid})));
|
||||
}
|
||||
}
|
||||
y += ROW_H + 2.0;
|
||||
for pr in tr.props() {
|
||||
if pr.three_d_only && !layer.is_3d() {
|
||||
continue;
|
||||
}
|
||||
transform_row(app, ui, &p, &layer, pr, &ectx, Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &mut actions);
|
||||
y += ROW_H;
|
||||
}
|
||||
y += 10.0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Text / Paragraph / Animation
|
||||
if matches!(layer.source, LayerSource::Text)
|
||||
&& let Some(doc) = effectcraft_engine::render::text::source_text(&ectx, &layer)
|
||||
{
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
y = text_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
|
||||
y += ROW_H + 10.0;
|
||||
let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0));
|
||||
let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click());
|
||||
p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text);
|
||||
p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, "Add Animator", Tokens::ui(12.0), t.text);
|
||||
app.auto.add("properties.addAnimator", br, "Add Animator");
|
||||
let pop = egui::Id::new("props-add-animator-pop");
|
||||
if resp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
let labels: Vec<String> = ANIMATORS.iter().map(|(l, _)| l.to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None) {
|
||||
actions.push(("layer.addTextAnimator".into(), json!({"layer": layer.id.0, "property": ANIMATORS[i].1})));
|
||||
}
|
||||
y += 24.0;
|
||||
}
|
||||
|
||||
y += 40.0;
|
||||
let content_h = y - top;
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id, (scroll, content_h)));
|
||||
if content_h > rect.height() {
|
||||
// Thin scroll indicator.
|
||||
let frac = rect.height() / content_h;
|
||||
let bar_h = (rect.height() * frac).max(24.0);
|
||||
let by = rect.min.y + (rect.height() - bar_h) * (scroll / (content_h - rect.height()).max(1.0));
|
||||
p.rect_filled(Rect::from_min_size(pos2(rect.max.x - 5.0, by), vec2(3.0, bar_h)), 1.5, t.text_faint.gamma_multiply(0.6));
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
|
||||
let t = app.tokens;
|
||||
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
|
||||
let _ = ui;
|
||||
app.auto.add(auto, r, title);
|
||||
}
|
||||
|
||||
fn divider(p: &egui::Painter, x0: f32, w: f32, y: f32, t: &Tokens) {
|
||||
p.line_segment([pos2(x0 - 4.0, y), pos2(x0 + w + 4.0, y)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
|
||||
fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) {
|
||||
let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0);
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE));
|
||||
}
|
||||
|
||||
/// One transform row: keyframe navigator or stopwatch, name, value fields.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, pr: &Property, ectx: &EvalCtx, r: Rect, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let cy = r.center().y;
|
||||
let uid = pr.uid;
|
||||
let lid = layer.id.0;
|
||||
let base = format!("properties.prop.{uid}");
|
||||
if pr.is_animated() {
|
||||
// ◀ ◆ ▶: previous key, add/remove key at the CTI, next key.
|
||||
let now = ectx.time;
|
||||
let half = ectx.comp.frame_duration().0 / 2;
|
||||
let on_key = pr.keys.iter().any(|k| (layer.comp_time(k.time).0 - now.0).abs() <= half);
|
||||
let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)];
|
||||
for (what, cx) in cells {
|
||||
let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0));
|
||||
let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click());
|
||||
let col = if resp.hovered() { t.text } else { t.text_dim };
|
||||
match what {
|
||||
"prev" => triangle(p, cr.center(), true, col),
|
||||
"next" => triangle(p, cr.center(), false, col),
|
||||
_ => {
|
||||
let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0));
|
||||
if on_key {
|
||||
icons::paint(p, kr, Icon::Keyframe, t.keyframe);
|
||||
} else {
|
||||
let c = kr.center();
|
||||
let d = 4.5;
|
||||
p.add(egui::Shape::closed_line(
|
||||
vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)],
|
||||
Stroke::new(1.0, col),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
app.auto.add(&format!("{base}.{what}Key"), cr, &pr.name);
|
||||
if resp.clicked() {
|
||||
match what {
|
||||
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
|
||||
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
|
||||
_ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0));
|
||||
let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click());
|
||||
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid})));
|
||||
}
|
||||
}
|
||||
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
|
||||
let vx = r.min.x + (r.width() * 0.5).max(130.0);
|
||||
let merge = format!("props-{uid}");
|
||||
let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge})));
|
||||
let value = ectx.value(layer, pr);
|
||||
let is_scale = pr.name == "Scale";
|
||||
let pct = is_scale || pr.name == "Opacity";
|
||||
let suffix = if pct { "%" } else { "" };
|
||||
match &value {
|
||||
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
|
||||
let (rev, deg) = split_rotation(*v);
|
||||
let g = p.layout_no_wrap(format!("{rev}x{}", if deg < 0.0 { "" } else { "+" }), Tokens::ui(12.0), t.hot_text);
|
||||
let gw = g.size().x;
|
||||
p.galley(pos2(vx + 2.0, cy - g.size().y / 2.0), g, t.hot_text);
|
||||
let (vr, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(vx + gw, cy - 9.0), egui::Id::new(("props-v", uid)), deg, 0.5, (-1e9, 1e9), decimals(deg), "°", &t);
|
||||
app.auto.add(&format!("{base}.value"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(rev as f64 * 360.0 + nv));
|
||||
}
|
||||
}
|
||||
Value::Scalar(v) => {
|
||||
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
|
||||
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
|
||||
app.auto.add(&format!("{base}.value"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
set(actions, json!(nv));
|
||||
}
|
||||
}
|
||||
Value::Vec2(_) | Value::Vec3(_) => {
|
||||
let c = value.components();
|
||||
let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() };
|
||||
let mut x = vx;
|
||||
let link_id = egui::Id::new(("props-scale-link", lid));
|
||||
let linked = is_scale && ui.data(|d| d.get_temp::<bool>(link_id)).unwrap_or(true);
|
||||
if is_scale {
|
||||
// Constrain Proportions toggle, left of the values.
|
||||
let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0));
|
||||
let resp = ui.interact(lr, link_id.with("btn"), Sense::click());
|
||||
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
|
||||
app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions");
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(link_id, !linked));
|
||||
}
|
||||
}
|
||||
for d in 0..n.min(c.len()) {
|
||||
let (vr, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
|
||||
app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
|
||||
if let Some(nv) = nv {
|
||||
let mut nc = c.clone();
|
||||
if linked && c[d].abs() > 1e-9 {
|
||||
let k = nv / c[d];
|
||||
for v in nc.iter_mut().take(n) {
|
||||
*v *= k;
|
||||
}
|
||||
}
|
||||
nc[d] = nv;
|
||||
set(actions, json!(nc));
|
||||
}
|
||||
x = vr.max.x + 10.0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text section; returns the y below it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_section(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
doc: &TextDoc,
|
||||
x0: f32,
|
||||
w: f32,
|
||||
mut y: f32,
|
||||
actions: &mut Actions,
|
||||
) -> f32 {
|
||||
let t = app.tokens;
|
||||
let lid = layer.id.0;
|
||||
let set = |actions: &mut Actions, v: serde_json::Value| {
|
||||
let mut v = v;
|
||||
v["layer"] = json!(lid);
|
||||
actions.push(("layer.setText".into(), v));
|
||||
};
|
||||
section_header(app, ui, p, x0, w, y, "Text", "properties.text");
|
||||
y += ROW_H + 4.0;
|
||||
// Font family and style.
|
||||
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
|
||||
let pop = egui::Id::new("props-font-pop");
|
||||
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("properties.text.font", fr, "Font family");
|
||||
let fams: Vec<String> = effectcraft_engine::text_families();
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)) {
|
||||
set(actions, json!({"font": fams[i]}));
|
||||
}
|
||||
y += 28.0;
|
||||
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
|
||||
let spop = egui::Id::new("props-style-pop");
|
||||
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() {
|
||||
widgets::open_popup(ui, spop);
|
||||
}
|
||||
app.auto.add("properties.text.style", sr, "Font style");
|
||||
let styles: Vec<String> = ["Regular", "Medium", "SemiBold", "Bold", "Italic"].iter().map(|s| s.to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, spop, sr.left_bottom(), &styles, styles.iter().position(|s| *s == doc.style)) {
|
||||
set(actions, json!({"style": styles[i]}));
|
||||
}
|
||||
y += 32.0;
|
||||
// Size / leading, tracking / stroke width.
|
||||
let col2 = x0 + w / 2.0;
|
||||
let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
|
||||
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
|
||||
app.auto.add(&format!("properties.text.{key}"), r, key);
|
||||
nv
|
||||
};
|
||||
if let Some(v) = field(app, ui, x0, y, "T", "size", doc.size, (1.0, 2000.0), " px") {
|
||||
set(actions, json!({"size": v, "merge": "props-text-size"}));
|
||||
}
|
||||
match doc.leading {
|
||||
Some(l) => {
|
||||
if let Some(v) = field(app, ui, col2, y, "A", "leading", l, (0.0, 5000.0), " px") {
|
||||
set(actions, json!({"leading": v, "merge": "props-text-leading"}));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Auto Leading: click to switch to an explicit value (120% of the size).
|
||||
p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim);
|
||||
let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click());
|
||||
p.text(pos2(r.min.x + 2.0, r.center().y), Align2::LEFT_CENTER, "Auto", Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.hot_text });
|
||||
app.auto.add("properties.text.leading", r, "leading");
|
||||
if resp.clicked() {
|
||||
set(actions, json!({"leading": (doc.size * 1.2).round()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
y += 26.0;
|
||||
if let Some(v) = field(app, ui, x0, y, "VA", "tracking", doc.tracking, (-1000.0, 10000.0), "") {
|
||||
set(actions, json!({"tracking": v.round(), "merge": "props-text-tracking"}));
|
||||
}
|
||||
if let Some(v) = field(app, ui, col2, y, "W", "strokeWidth", doc.stroke_width, (0.0, 500.0), " px") {
|
||||
set(actions, json!({"strokeWidth": v, "merge": "props-text-strokeWidth"}));
|
||||
}
|
||||
y += 28.0;
|
||||
// Fill and Stroke, each with an enable checkbox, swatch and label.
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let ry = y + i as f32 * 24.0;
|
||||
let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() {
|
||||
let k = if key == "fill" { "applyFill" } else { "applyStroke" };
|
||||
set(actions, json!({k: !on}));
|
||||
}
|
||||
app.auto.add(&format!("properties.text.{key}.enabled"), cr, label);
|
||||
let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0));
|
||||
let pop = egui::Id::new(("props-color-pop", key));
|
||||
if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add(&format!("properties.text.{key}"), sw, label);
|
||||
let mut rgb = [c[0], c[1], c[2]];
|
||||
if crate::header::color_popup(ui, pop, sw.left_bottom(), &mut rgb) {
|
||||
set(actions, json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("props-text-{key}")}));
|
||||
}
|
||||
p.text(pos2(sw.max.x + 8.0, sw.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
|
||||
}
|
||||
y += 52.0;
|
||||
y = more_button(app, ui, p, x0, y, "props-text-more", "properties.text.more", PanelKind::Character);
|
||||
y + 10.0
|
||||
}
|
||||
|
||||
/// Paragraph section; returns the y below it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paragraph_section(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
p: &egui::Painter,
|
||||
layer: &Layer,
|
||||
doc: &TextDoc,
|
||||
x0: f32,
|
||||
w: f32,
|
||||
mut y: f32,
|
||||
actions: &mut Actions,
|
||||
) -> f32 {
|
||||
let t = app.tokens;
|
||||
section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
|
||||
y += ROW_H + 4.0;
|
||||
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
|
||||
let on = doc.justify == j;
|
||||
let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click());
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.pressed
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
},
|
||||
);
|
||||
paint_align_glyph(p, r, j, if on { t.text } else { t.text_dim });
|
||||
app.auto.add(&format!("properties.paragraph.{key}"), r, key);
|
||||
if resp.clicked() {
|
||||
actions.push(("layer.setText".into(), json!({"layer": layer.id.0, "justify": key})));
|
||||
}
|
||||
}
|
||||
y += 30.0;
|
||||
y = more_button(app, ui, p, x0, y, "props-para-more", "properties.paragraph.more", PanelKind::Paragraph);
|
||||
y + 10.0
|
||||
}
|
||||
|
||||
/// Four lines showing an alignment: last line short for the "justify last …" modes.
|
||||
fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) {
|
||||
let full = r.width() - 8.0;
|
||||
for k in 0..4 {
|
||||
let ly = r.min.y + 6.0 + k as f32 * 3.5;
|
||||
let last = k == 3;
|
||||
let lw = match j {
|
||||
Justify::Left | Justify::Center | Justify::Right => {
|
||||
if k % 2 == 1 {
|
||||
full * 0.6
|
||||
} else {
|
||||
full
|
||||
}
|
||||
}
|
||||
Justify::JustifyAll => full,
|
||||
_ if last => full * 0.55,
|
||||
_ => full,
|
||||
};
|
||||
let lx = match j {
|
||||
Justify::Center | Justify::JustifyLastCenter => r.center().x - lw / 2.0,
|
||||
Justify::Right | Justify::JustifyLastRight => r.max.x - 4.0 - lw,
|
||||
_ => r.min.x + 4.0,
|
||||
};
|
||||
p.line_segment([pos2(lx, ly), pos2(lx + lw, ly)], Stroke::new(1.2, c));
|
||||
}
|
||||
}
|
||||
|
||||
/// "··· More" button that opens the full panel; returns the y below it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0));
|
||||
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
|
||||
p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg });
|
||||
p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text);
|
||||
p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "More", Tokens::ui(12.0), t.text);
|
||||
app.auto.add(auto, r, "More");
|
||||
if resp.clicked() {
|
||||
app.show_panel(panel);
|
||||
}
|
||||
y + 28.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rotation_splits_like_ae() {
|
||||
assert_eq!(split_rotation(0.0), (0, 0.0));
|
||||
assert_eq!(split_rotation(390.0), (1, 30.0));
|
||||
assert_eq!(split_rotation(-45.0), (0, -45.0));
|
||||
assert_eq!(split_rotation(-725.0), (-2, -5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_hide_whole_numbers() {
|
||||
assert_eq!(decimals(960.0), 0);
|
||||
assert_eq!(decimals(960.5), 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user