M10.6: Graphics and Titles menu: New Layer (Vertical Text, Rectangle, Ellipse, Polygon, From file), Align to Video Frame / as Group / to Selection, Distribute (centres and gaps), Arrange, Select Next/Previous Graphic and Layer, Reset All Parameters, Reset Duration

Vertical text layout in the text engine (one line per character, columns right to left).
All edits are undoable engine commands; engine tests cover each menu group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 23:23:27 -04:00
co-authored by Claude Opus 5.5
parent 89cfbf24f6
commit 012bcf7aa5
6 changed files with 798 additions and 89 deletions
+435 -83
View File
@@ -96,22 +96,38 @@ fn graphic_source(p: &mut filmcraft_project::Project, w: u32, h: u32, rate: film
/// Place a new graphic clip holding `layer` at the playhead: on the first video track above the
/// topmost clip at the playhead that is free for the duration (a track is added if needed).
fn new_graphic_clip(s: &mut Session, layer: filmcraft_project::EffectInstance, name: &str, p: &Value) -> Result<ClipId> {
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
place_video_clip(s, name, p, "New Graphic", vec![layer], move |pr, (w, h, rate)| {
let src = graphic_source(pr, w, h, rate);
(src, rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration()))
})
}
/// Place a video clip of the item `source` returns (with its duration) at the playhead (or
/// `time`), above the clips there, as one undo step; selects it.
fn place_video_clip(
s: &mut Session,
name: &str,
p: &Value,
label: &str,
extra: Vec<filmcraft_project::EffectInstance>,
source: impl FnOnce(&mut filmcraft_project::Project, (u32, u32, filmcraft_time::FrameRate)) -> (ItemId, Tick),
) -> Result<ClipId> {
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
let t = time_p(s, p, "").unwrap_or_else(|| s.playhead());
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
let want_track = u64_p(p, "track").map(|v| v as usize);
let name = name.to_string();
s.edit("New Graphic", |pr, st| {
s.edit(label, |pr, st| {
let (w, h, rate) = {
let q = pr.sequence(seq_id).ok_or(EngineError::NoSequence)?;
(q.settings.width, q.settings.height, q.settings.frame_rate)
};
let src = graphic_source(pr, w, h, rate);
let (src, dur) = source(pr, (w, h, rate));
let t = rate.snap(t);
let dur = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
let dur = dur.max(rate.frame_duration());
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad("graphics.newText", "bad item"))?;
ti.name = name;
ti.effects.push(layer);
ti.effects.extend(extra);
for e in &mut ti.effects {
filmcraft_project::resolve_auto_points(e, (w, h), (w, h));
}
@@ -307,20 +323,416 @@ fn move_layers(s: &mut Session, clip: ClipId, moves: Vec<(usize, Vec2)>, label:
})
}
pub fn commands() -> Vec<CommandSpec> {
#[derive(Clone, Copy, PartialEq)]
enum AlignTo {
/// Each layer to the video frame.
Frame,
/// The layers' union to the video frame (they keep their relative positions).
FrameGroup,
/// Each layer to the union of the selection.
Selection,
}
fn align_delta(how: &str, r: [f64; 4], b: [f64; 4]) -> Result<(f64, f64)> {
Ok(match how {
"left" => (r[0] - b[0], 0.0),
"right" => (r[2] - b[2], 0.0),
"hcenter" | "center" => ((r[0] + r[2]) / 2.0 - (b[0] + b[2]) / 2.0, 0.0),
"top" => (0.0, r[1] - b[1]),
"bottom" => (0.0, r[3] - b[3]),
"vcenter" | "middle" => (0.0, (r[1] + r[3]) / 2.0 - (b[1] + b[3]) / 2.0),
o => return Err(bad("graphics.align", format!("unknown alignment `{o}`"))),
})
}
fn union_box(boxes: &[(usize, Vec2, [f64; 4])]) -> [f64; 4] {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
}
/// Graphics ▸ Align to Video Frame / Align to Video Frame as Group / Align to Selection.
fn align_layers(s: &mut Session, p: &Value, how: &str, mode: AlignTo) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
let frame = [0.0, 0.0, size.0 as f64, size.1 as f64];
let mode = if mode == AlignTo::Selection && boxes.len() < 2 { AlignTo::Frame } else { mode };
let union = union_box(&boxes);
let group = align_delta(how, frame, union)?;
let mut moves = Vec::new();
for (ei, pos, b) in &boxes {
let (dx, dy) = match mode {
AlignTo::Frame => align_delta(how, frame, *b)?,
AlignTo::FrameGroup => group,
AlignTo::Selection => align_delta(how, union, *b)?,
};
moves.push((*ei, Vec2::new(pos.x + dx, pos.y + dy)));
}
move_layers(s, clip, moves, "Align Layers")?;
Ok(json!({"clip": clip.0, "layers": layers}))
}
/// Graphics ▸ Distribute: equal centre spacing, or (`space`) equal gaps between the layers.
fn distribute_layers(s: &mut Session, p: &Value, vertical: bool, space: bool) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.distribute", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (mut boxes, _) = layer_boxes(s, clip, &layers)?;
if boxes.len() < 3 {
return Err(bad("graphics.distribute", "select three or more layers"));
}
let (lo, hi) = if vertical { (1, 3) } else { (0, 2) };
let c = |b: &[f64; 4]| (b[lo] + b[hi]) / 2.0;
boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2)));
let n = boxes.len() - 1;
let deltas: Vec<f64> = if space {
let span = boxes[n].2[hi] - boxes[0].2[lo];
let total: f64 = boxes.iter().map(|b| b.2[hi] - b.2[lo]).sum();
let gap = (span - total) / n as f64;
let mut at = boxes[0].2[lo];
boxes
.iter()
.map(|b| {
let d = at - b.2[lo];
at += b.2[hi] - b.2[lo] + gap;
d
})
.collect()
} else {
let (first, last) = (c(&boxes[0].2), c(&boxes[n].2));
boxes.iter().enumerate().map(|(i, b)| first + (last - first) * i as f64 / n as f64 - c(&b.2)).collect()
};
let moves =
boxes.iter().zip(deltas).map(|((ei, pos, _), d)| (*ei, if vertical { Vec2::new(pos.x, pos.y + d) } else { Vec2::new(pos.x + d, pos.y) })).collect();
move_layers(s, clip, moves, "Distribute Layers")?;
Ok(json!({"clip": clip.0, "layers": layers}))
}
/// Move a graphic layer in the paint order (`to`: front / back / forward / backward / index).
fn arrange_layer(s: &mut Session, p: &Value, to: Value) -> Result<Value> {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?;
let (l, _) = layer_effect_index(s, clip, p)?;
let dest = s.edit_sequence("Arrange Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let idx = layer_indices(&it.effects);
let last = idx.len() - 1;
let dest = match &to {
Value::Number(v) => (v.as_u64().unwrap_or(0) as usize).min(last),
Value::String(w) => match w.as_str() {
"front" => last,
"back" => 0,
"forward" => (l + 1).min(last),
"backward" => l.saturating_sub(1),
o => return Err(bad("graphics.arrangeLayer", format!("unknown `to` `{o}`"))),
},
_ => last,
};
let e = it.effects.remove(idx[l]);
let mut idx2 = layer_indices(&it.effects);
let insert_at = if dest >= idx2.len() { idx2.pop().map_or(idx[0], |x| x + 1) } else { idx2[dest] };
it.effects.insert(insert_at, e);
st.selection = vec![clip];
st.graphic_layers = vec![dest];
Ok(dest)
})?;
Ok(json!({"clip": clip.0, "layer": dest}))
}
/// Graphic clips of the active sequence in timeline order (start, then track bottom → top).
fn graphic_clips(s: &Session) -> Vec<(Tick, usize, ClipId)> {
let Some(q) = s.active_sequence() else { return Vec::new() };
let mut v: Vec<(Tick, usize, ClipId)> = q
.video_tracks
.iter()
.enumerate()
.flat_map(|(ti, tr)| tr.items.iter().map(move |it| (it.start, ti, it.id)))
.filter(|(_, _, c)| is_graphic(s, q, *c))
.collect();
v.sort();
v
}
/// Graphics ▸ Select ▸ Select Next / Previous Graphic.
fn select_graphic(s: &mut Session, forward: bool) -> Result<Value> {
let all = graphic_clips(s);
let cur = s.state.selection.iter().find_map(|c| all.iter().position(|g| g.2 == *c));
let ph = s.playhead();
let pick = match (cur, forward) {
(Some(i), true) => all.get(i + 1),
(Some(i), false) => i.checked_sub(1).and_then(|j| all.get(j)),
(None, true) => all.iter().find(|g| g.0 >= ph).or(all.first()),
(None, false) => all.iter().rev().find(|g| g.0 < ph).or(all.last()),
};
let (start, _, clip) = *pick.ok_or_else(|| bad("graphics.select", if forward { "no next graphic" } else { "no previous graphic" }))?;
let dur = s.active_sequence().and_then(|q| q.find_item(clip)).map_or(Tick::ZERO, |(_, it)| it.duration);
s.state.selection = vec![clip];
s.state.graphic_layers.clear();
if !(ph >= start && ph < start + dur) {
s.set_playhead(start);
}
Ok(json!({"clip": clip.0}))
}
/// Graphics ▸ Select ▸ Select Next / Previous Layer (cycles through the clip's layers).
fn select_layer_step(s: &mut Session, forward: bool) -> Result<Value> {
let clip = target_clip(s, &Value::Null).ok_or_else(|| bad("graphics.selectLayer", "no graphic clip"))?;
let n = s.active_sequence().and_then(|q| q.find_item(clip)).map_or(0, |(_, it)| layer_indices(&it.effects).len());
if n == 0 {
return Err(bad("graphics.selectLayer", "the graphic has no layers"));
}
let l = match (s.state.graphic_layers.first().copied().filter(|l| *l < n), forward) {
(Some(l), true) => (l + 1) % n,
(Some(l), false) => (l + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
s.state.selection = vec![clip];
s.state.graphic_layers = vec![l];
Ok(json!({"clip": clip.0, "layer": l}))
}
fn has_two_layers(s: &Session) -> std::result::Result<(), String> {
has_graphic(s)?;
if s.state.graphic_layers.len() < 2 { Err("select two or more graphic layers".into()) } else { Ok(()) }
}
fn has_three_layers(s: &Session) -> std::result::Result<(), String> {
has_graphic(s)?;
if s.state.graphic_layers.len() < 3 { Err("select three or more graphic layers".into()) } else { Ok(()) }
}
fn has_graphics_in_seq(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
if graphic_clips(s).is_empty() { Err("the sequence has no graphics".into()) } else { Ok(()) }
}
const ALIGN_PARAMS: &str = r#"{"clip":id?,"layers":[n]? (default: the selected layers)}"#;
macro_rules! align_cmd {
($id:literal, $label:literal, $menu:expr, $how:literal, $mode:expr, $en:expr) => {
spec($id, $label, &["Graphics and Titles", $menu], None, ALIGN_PARAMS, $en, |s, p| align_layers(s, p, $how, $mode))
};
}
macro_rules! arrange_cmd {
($id:literal, $label:literal, $sc:literal, $to:literal) => {
spec($id, $label, &["Graphics and Titles", "Arrange"], Some($sc), r#"{"clip":id?,"layer":n?}"#, has_graphic, |s, p| arrange_layer(s, p, json!($to)))
};
}
fn new_shape_cmd(s: &mut Session, p: &Value, shape: &str) -> Result<Value> {
let mut q = p.clone();
q["shape"] = json!(shape);
(find_spec("graphics.newShape").run)(s, &q)
}
fn find_spec(id: &str) -> CommandSpec {
commands().into_iter().find(|c| c.id == id).expect("graphics command")
}
/// Graphics menu commands (New Layer shapes, Align/Distribute/Arrange/Select submenus, resets).
fn menu_commands() -> Vec<CommandSpec> {
const F: &str = "Align to Video Frame";
const G: &str = "Align to Video Frame as Group";
const S: &str = "Align to Selection";
const D: &[&str] = &["Graphics and Titles", "Distribute"];
const SEL: &[&str] = &["Graphics and Titles", "Select"];
vec![
spec(
"graphics.newVerticalText",
"Vertical Text",
&["Graphics and Titles", "New Layer"],
None,
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"size":px=100,"seconds":f64=5,"time":ticks?}"#,
has_seq,
|s, p| {
let mut p = if p.is_object() { p.clone() } else { json!({}) };
p["vertical"] = json!(true);
(find_spec("graphics.newText").run)(s, &p)
},
),
spec(
"graphics.newRectangle",
"Rectangle",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+R"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "rectangle"),
),
spec(
"graphics.newEllipse",
"Ellipse",
&["Graphics and Titles", "New Layer"],
Some("Cmd+Alt+E"),
r#"{"position":[x,y]?,"size":[w,h]=[400,200],"clip":id?}"#,
has_seq,
|s, p| new_shape_cmd(s, p, "ellipse"),
),
spec(
"graphics.newPolygon",
"Polygon",
&["Graphics and Titles", "New Layer"],
None,
r#"{"position":[x,y]?,"size":[w,h]=[300,300],"sides":n=6,"clip":id?}"#,
has_seq,
|s, p| {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if q.get("size").is_none() {
q["size"] = json!([300, 300]);
}
let r = new_shape_cmd(s, &q, "polygon")?;
if let Some(n) = f64_p(p, "sides") {
// fold the side count into the same undo step
let clip = ClipId(r["clip"].as_u64().unwrap_or(0));
let (_, ei) = layer_effect_index(s, clip, &json!({"layer": r["layer"]}))?;
let mut props = serde_json::Map::new();
props.insert("sides".into(), json!(n));
let ph = s.playhead();
let before = s.history.undo.len();
set_props(s, clip, ei, &props, ph, "Change Graphic Property")?;
if s.history.undo.len() > before {
s.history.undo.pop();
}
}
Ok(r)
},
),
spec(
"graphics.newFromFile",
"From file…",
&["Graphics and Titles", "New Layer"],
None,
r#"{"path":str,"time":ticks?,"track":index?} (imports the image or video and places it above the clips at the playhead)"#,
has_seq,
|s, p| {
let path = str_p(p, "path").ok_or_else(|| bad("graphics.newFromFile", "need `path`"))?.to_string();
let r = s.execute("file.import", json!({"paths": [path]}))?;
let item = r["items"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).map(ItemId).ok_or_else(|| {
bad(
"graphics.newFromFile",
r["errors"].as_array().and_then(|e| e.first()).and_then(Value::as_str).unwrap_or("nothing imported").to_string(),
)
})?;
let pi = s.project.item(item).ok_or_else(|| bad("graphics.newFromFile", "no such item"))?;
if matches!(&pi.kind, ItemKind::Media(m) if m.info.video.is_none()) {
return Err(bad("graphics.newFromFile", "the file has no picture"));
}
let (name, dur) = (pi.name.clone(), pi.duration());
let clip = place_video_clip(s, &name, p, "New Layer from File", Vec::new(), move |_, _| (item, dur))?;
s.state.graphic_layers.clear();
Ok(json!({"clip": clip.0, "item": item.0}))
},
),
align_cmd!("graphics.alignFrame.left", "Left", F, "left", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.hcenter", "Center Horizontally", F, "hcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.right", "Right", F, "right", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.top", "Top", F, "top", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.vcenter", "Center Vertically", F, "vcenter", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignFrame.bottom", "Bottom", F, "bottom", AlignTo::Frame, has_graphic),
align_cmd!("graphics.alignGroup.left", "Left", G, "left", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.hcenter", "Center Horizontally", G, "hcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.right", "Right", G, "right", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.top", "Top", G, "top", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.vcenter", "Center Vertically", G, "vcenter", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignGroup.bottom", "Bottom", G, "bottom", AlignTo::FrameGroup, has_two_layers),
align_cmd!("graphics.alignSelection.left", "Left", S, "left", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.hcenter", "Center Horizontally", S, "hcenter", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.right", "Right", S, "right", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.top", "Top", S, "top", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.vcenter", "Center Vertically", S, "vcenter", AlignTo::Selection, has_two_layers),
align_cmd!("graphics.alignSelection.bottom", "Bottom", S, "bottom", AlignTo::Selection, has_two_layers),
spec("graphics.distributeVertically", "Distribute Vertically", D, None, ALIGN_PARAMS, has_three_layers, |s, p| distribute_layers(s, p, true, false)),
spec("graphics.distributeSpaceVertically", "Distribute Space Vertically", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, true, true)
}),
spec("graphics.distributeHorizontally", "Distribute Horizontally", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, false, false)
}),
spec("graphics.distributeSpaceHorizontally", "Distribute Space Horizontally", D, None, ALIGN_PARAMS, has_three_layers, |s, p| {
distribute_layers(s, p, false, true)
}),
arrange_cmd!("graphics.bringToFront", "Bring to Front", "Cmd+Shift+]", "front"),
arrange_cmd!("graphics.bringForward", "Bring Forward", "Cmd+]", "forward"),
arrange_cmd!("graphics.sendBackward", "Send Backward", "Cmd+[", "backward"),
arrange_cmd!("graphics.sendToBack", "Send to Back", "Cmd+Shift+[", "back"),
spec("graphics.selectNextGraphic", "Select Next Graphic", SEL, None, "{}", has_graphics_in_seq, |s, _| select_graphic(s, true)),
spec("graphics.selectPreviousGraphic", "Select Previous Graphic", SEL, None, "{}", has_graphics_in_seq, |s, _| select_graphic(s, false)),
spec("graphics.selectNextLayer", "Select Next Layer", SEL, Some("Cmd+Alt+]"), "{}", has_graphic, |s, _| select_layer_step(s, true)),
spec("graphics.selectPreviousLayer", "Select Previous Layer", SEL, Some("Cmd+Alt+["), "{}", has_graphic, |s, _| select_layer_step(s, false)),
spec(
"graphics.resetAllParameters",
"Reset All Parameters",
&["Graphics and Titles"],
None,
r#"{"clip":id?,"layers":[n]? (default: the selected layers, else all)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.resetAllParameters", "no graphic clip"))?;
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let want: Vec<usize> = match p.get("layers").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect(),
None => s.state.graphic_layers.clone(),
};
let n = s.edit_sequence("Reset All Parameters", |q, _, _| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let mut n = 0;
for (l, ei) in layer_indices(&it.effects).into_iter().enumerate() {
if want.is_empty() || want.contains(&l) {
graphic::reset_layer_params(&mut it.effects[ei]);
filmcraft_project::resolve_auto_points(&mut it.effects[ei], frame, frame);
n += 1;
}
}
Ok(n)
})?;
Ok(json!({"clip": clip.0, "reset": n}))
},
),
spec(
"graphics.resetDuration",
"Reset Duration",
&["Graphics and Titles"],
None,
r#"{"clip":id?,"seconds":f64=5 (the default graphic duration; limited by the next clip on the track)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.resetDuration", "no graphic clip"))?;
let seconds = f64_p(p, "seconds").unwrap_or(5.0).max(0.01);
let d = s.edit_sequence("Reset Duration", |q, _, _| {
let rate = q.settings.frame_rate;
let want = rate.snap_nearest(Tick::from_seconds_f64(seconds)).max(rate.frame_duration());
let tr = q.video_tracks.iter_mut().find(|tr| tr.items.iter().any(|i| i.id == clip)).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let i = tr.items.iter().position(|i| i.id == clip).expect("found");
let start = tr.items[i].start;
let limit = tr.items.get(i + 1).map_or(want, |n| n.start - start);
let d = want.min(limit).max(rate.frame_duration());
tr.items[i].duration = d;
Ok(d)
})?;
Ok(json!({"clip": clip.0, "duration": d.0}))
},
),
]
}
pub fn commands() -> Vec<CommandSpec> {
let mut v = vec![
spec(
"graphics.newText",
"Text",
&["Graphics and Titles", "New Layer"],
Some("Cmd+T"),
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
r#"{"text":str="New Text","position":[x,y]?,"clip":id?,"newClip":bool?,"vertical":bool=false,"size":px=100,"font":str?,"fontStyle":str?,"seconds":f64=5,"track":index?,"time":ticks?}"#,
has_seq,
|s, p| {
let text = str_p(p, "text").unwrap_or("New Text").to_string();
let (w, h) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let pos = vec2_p(p, "position").unwrap_or(Vec2::new(w as f64 / 2.0, h as f64 / 2.0));
let mut layer = new_text_layer(&text, pos, f64_p(p, "size").unwrap_or(100.0));
let size = f64_p(p, "size").unwrap_or(100.0);
let mut layer = if p.get("vertical").and_then(Value::as_bool) == Some(true) {
graphic::new_vertical_text_layer(&text, pos, size)
} else {
new_text_layer(&text, pos, size)
};
for (k, id) in [("font", "font"), ("fontStyle", "font_style")] {
if let Some(v) = str_p(p, k) {
layer.params.insert(id.into(), filmcraft_project::Param::new(ParamValue::Text(v.into())));
@@ -341,7 +753,7 @@ pub fn commands() -> Vec<CommandSpec> {
spec(
"graphics.newShape",
"Shape",
&["Graphics and Titles", "New Layer"],
&[],
None,
r#"{"shape":"rectangle|ellipse|polygon|path","position":[x,y]?,"size":[w,h]=[400,200],"points":[[x,y],…]?,"clip":id?,"seconds":f64=5}"#,
has_seq,
@@ -437,34 +849,8 @@ pub fn commands() -> Vec<CommandSpec> {
r#"{"clip":id?,"layer":n?,"to":"front|back|forward|backward"|index}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.arrangeLayer", "no graphic clip"))?;
let (l, _) = layer_effect_index(s, clip, p)?;
let to = p.get("to").cloned().unwrap_or(json!("front"));
let n = s.state.graphic_layers.len();
let _ = n;
s.edit_sequence("Arrange Graphic Layer", |q, _, st| {
let (_, it) = q.find_item_mut(clip).ok_or(filmcraft_edit::EditError::NoItem(clip))?;
let idx = layer_indices(&it.effects);
let last = idx.len() - 1;
let dest = match &to {
Value::Number(v) => (v.as_u64().unwrap_or(0) as usize).min(last),
Value::String(w) => match w.as_str() {
"front" => last,
"back" => 0,
"forward" => (l + 1).min(last),
"backward" => l.saturating_sub(1),
o => return Err(bad("graphics.arrangeLayer", format!("unknown `to` `{o}`"))),
},
_ => last,
};
let e = it.effects.remove(idx[l]);
let mut idx2 = layer_indices(&it.effects);
let insert_at = if dest >= idx2.len() { idx2.pop().map_or(idx[0], |x| x + 1) } else { idx2[dest] };
it.effects.insert(insert_at, e);
st.graphic_layers = vec![dest];
Ok(())
})?;
Ok(Value::Null)
arrange_layer(s, p, to)
},
),
spec(
@@ -472,34 +858,16 @@ pub fn commands() -> Vec<CommandSpec> {
"Align Layers",
&[],
None,
r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|selection"="frame" (one layer always aligns to the frame)}"#,
r#"{"clip":id?,"layers":[n]?,"align":"left|hcenter|right|top|vcenter|bottom","to":"frame|group|selection"="frame" (frame: each layer; group: the layers' union; one layer always aligns to the frame)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.align", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (boxes, size) = layer_boxes(s, clip, &layers)?;
let how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?;
let to_sel = str_p(p, "to") == Some("selection") && boxes.len() > 1;
let r = if to_sel {
boxes.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |a, b| [a[0].min(b.2[0]), a[1].min(b.2[1]), a[2].max(b.2[2]), a[3].max(b.2[3])])
} else {
[0.0, 0.0, size.0 as f64, size.1 as f64]
let how = str_p(p, "align").ok_or_else(|| bad("graphics.align", "need `align`"))?.to_string();
let mode = match str_p(p, "to") {
Some("selection") => AlignTo::Selection,
Some("group") => AlignTo::FrameGroup,
_ => AlignTo::Frame,
};
let mut moves = Vec::new();
for (ei, pos, b) in boxes {
let (dx, dy) = match how {
"left" => (r[0] - b[0], 0.0),
"right" => (r[2] - b[2], 0.0),
"hcenter" | "center" => ((r[0] + r[2]) / 2.0 - (b[0] + b[2]) / 2.0, 0.0),
"top" => (0.0, r[1] - b[1]),
"bottom" => (0.0, r[3] - b[3]),
"vcenter" | "middle" => (0.0, (r[1] + r[3]) / 2.0 - (b[1] + b[3]) / 2.0),
o => return Err(bad("graphics.align", format!("unknown alignment `{o}`"))),
};
moves.push((ei, Vec2::new(pos.x + dx, pos.y + dy)));
}
move_layers(s, clip, moves, "Align Layers")?;
Ok(Value::Null)
align_layers(s, p, &how, mode)
},
),
spec(
@@ -507,30 +875,12 @@ pub fn commands() -> Vec<CommandSpec> {
"Distribute Layers",
&[],
None,
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical"}"#,
r#"{"clip":id?,"layers":[n] (3 or more)?,"axis":"horizontal|vertical","space":bool=false (equal gaps instead of equal centre spacing)}"#,
has_graphic,
|s, p| {
let clip = target_clip(s, p).ok_or_else(|| bad("graphics.distribute", "no graphic clip"))?;
let layers = layers_p(s, clip, p)?;
let (mut boxes, _) = layer_boxes(s, clip, &layers)?;
if boxes.len() < 3 {
return Err(bad("graphics.distribute", "select three or more layers"));
}
let vertical = str_p(p, "axis") == Some("vertical");
let c = |b: &[f64; 4]| if vertical { (b[1] + b[3]) / 2.0 } else { (b[0] + b[2]) / 2.0 };
boxes.sort_by(|a, b| c(&a.2).total_cmp(&c(&b.2)));
let (first, last) = (c(&boxes[0].2), c(&boxes[boxes.len() - 1].2));
let n = boxes.len() - 1;
let moves = boxes
.iter()
.enumerate()
.map(|(i, (ei, pos, b))| {
let d = first + (last - first) * i as f64 / n as f64 - c(b);
(*ei, if vertical { Vec2::new(pos.x, pos.y + d) } else { Vec2::new(pos.x + d, pos.y) })
})
.collect();
move_layers(s, clip, moves, "Distribute Layers")?;
Ok(Value::Null)
let space = p.get("space").and_then(Value::as_bool).unwrap_or(false);
distribute_layers(s, p, vertical, space)
},
),
query("graphics.list", "List Graphic Layers", r#"{"clip":id?}"#, |s, p| {
@@ -543,7 +893,9 @@ pub fn commands() -> Vec<CommandSpec> {
}
Ok(json!(filmcraft_text::families().into_iter().map(|(f, st)| json!({"family": f, "styles": st})).collect::<Vec<_>>()))
}),
]
];
v.extend(menu_commands());
v
}
#[cfg(test)]
+218
View File
@@ -128,3 +128,221 @@ fn graphic_renders_in_the_program_and_survives_save() {
let fonts = s.execute("fonts.list", json!({"system": false})).unwrap();
assert!(fonts.as_array().unwrap().iter().any(|f| f["family"] == "Inter"));
}
fn quad_box(s: &mut Session, clip: u64, layer: usize) -> [f64; 4] {
let l = s.execute("graphics.list", json!({"clip": clip})).unwrap();
let q = l["layers"][layer]["quad"].as_array().unwrap().clone();
let xs: Vec<f64> = q.iter().map(|p| p[0].as_f64().unwrap()).collect();
let ys: Vec<f64> = q.iter().map(|p| p[1].as_f64().unwrap()).collect();
[
xs.iter().copied().fold(f64::MAX, f64::min),
ys.iter().copied().fold(f64::MAX, f64::min),
xs.iter().copied().fold(f64::MIN, f64::max),
ys.iter().copied().fold(f64::MIN, f64::max),
]
}
fn three_shapes(s: &mut Session) -> u64 {
let r = s.execute("graphics.newRectangle", json!({"position": [300, 200], "size": [100, 60]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.newEllipse", json!({"clip": clip, "position": [500, 400], "size": [40, 40]})).unwrap();
s.execute("graphics.newPolygon", json!({"clip": clip, "position": [1500, 900], "size": [80, 80], "sides": 5})).unwrap();
clip
}
#[test]
fn new_layer_menu_items_make_shapes_and_vertical_text() {
let mut s = demo();
let clip = three_shapes(&mut s);
let list = s.execute("graphics.list", json!({"clip": clip})).unwrap();
let kinds: Vec<&str> = list["layers"].as_array().unwrap().iter().map(|l| l["kind"].as_str().unwrap()).collect();
assert_eq!(kinds, ["Rectangle", "Ellipse", "Polygon"]);
let l = layers(&s, ClipId(clip));
let LayerContent::Shape(p) = &l[2].content else { panic!() };
assert_eq!(p.sides, 5, "sides set");
// the polygon (with its side count) is one undo step
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layers(&s, ClipId(clip)).len(), 2);
// vertical text: taller than wide
let r = s.execute("graphics.newVerticalText", json!({"text": "TALL", "newClip": true})).unwrap();
let v = r["clip"].as_u64().unwrap();
let LayerContent::Text(t) = &layers(&s, ClipId(v))[0].content else { panic!() };
assert!(t.vertical);
let b = quad_box(&mut s, v, 0);
assert!(b[3] - b[1] > 2.5 * (b[2] - b[0]), "vertical text is tall: {b:?}");
// the menu has the New Layer entries and no generic "Shape"
let ids: Vec<&str> = crate::command_specs().iter().filter(|c| c.menu == ["Graphics and Titles", "New Layer"]).map(|c| c.id).collect();
assert_eq!(
ids,
["graphics.newText", "graphics.newVerticalText", "graphics.newRectangle", "graphics.newEllipse", "graphics.newPolygon", "graphics.newFromFile"]
);
}
#[test]
fn align_menu_commands_frame_group_and_selection() {
let mut s = demo();
let (w, h) = {
let q = s.active_sequence().unwrap();
(q.settings.width as f64, q.settings.height as f64)
};
let clip = three_shapes(&mut s);
// one layer: align to the video frame
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0]})).unwrap();
assert!(s.execute("graphics.alignGroup.left", json!({})).is_err(), "group align needs two layers");
s.execute("graphics.alignFrame.right", json!({})).unwrap();
let b = quad_box(&mut s, clip, 0);
assert!((b[2] - w).abs() < 1e-6, "{b:?}");
s.execute("graphics.alignFrame.vcenter", json!({})).unwrap();
let b = quad_box(&mut s, clip, 0);
assert!(((b[1] + b[3]) / 2.0 - h / 2.0).abs() < 1e-6);
// group: the union moves, relative positions kept
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [1, 2]})).unwrap();
let (b1, b2) = (quad_box(&mut s, clip, 1), quad_box(&mut s, clip, 2));
s.execute("graphics.alignGroup.top", json!({})).unwrap();
let (c1, c2) = (quad_box(&mut s, clip, 1), quad_box(&mut s, clip, 2));
assert!(c1[1].abs() < 1e-6, "topmost layer on the frame top: {c1:?}");
assert!(((c2[1] - c1[1]) - (b2[1] - b1[1])).abs() < 1e-6, "relative offset kept");
// each frame-aligned: both on the left edge individually
s.execute("graphics.alignFrame.left", json!({})).unwrap();
assert!(quad_box(&mut s, clip, 1)[0].abs() < 1e-6 && quad_box(&mut s, clip, 2)[0].abs() < 1e-6);
// to selection: bottoms meet the lowest bottom
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 2]})).unwrap();
let lowest = (0..3).map(|i| quad_box(&mut s, clip, i)[3]).fold(f64::MIN, f64::max);
s.execute("graphics.alignSelection.bottom", json!({})).unwrap();
for i in 0..3 {
assert!((quad_box(&mut s, clip, i)[3] - lowest).abs() < 1e-6);
}
// undo restores
s.execute("edit.undo", json!({})).unwrap();
assert!((0..3).any(|i| (quad_box(&mut s, clip, i)[3] - lowest).abs() > 1.0));
}
#[test]
fn distribute_centres_and_spaces() {
let mut s = demo();
let r = s.execute("graphics.newRectangle", json!({"position": [100, 500], "size": [100, 40]})).unwrap();
let clip = r["clip"].as_u64().unwrap();
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [300, 500], "size": [20, 40]})).unwrap();
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [1000, 500], "size": [300, 40]})).unwrap();
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1]})).unwrap();
assert!(s.execute("graphics.distributeHorizontally", json!({})).is_err(), "needs three");
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 2]})).unwrap();
s.execute("graphics.distributeSpaceHorizontally", json!({})).unwrap();
let b: Vec<[f64; 4]> = (0..3).map(|i| quad_box(&mut s, clip, i)).collect();
let (g1, g2) = (b[1][0] - b[0][2], b[2][0] - b[1][2]);
assert!((g1 - g2).abs() < 1e-6, "equal gaps {g1} {g2}");
s.execute("graphics.distributeHorizontally", json!({})).unwrap();
let c: Vec<f64> = (0..3).map(|i| quad_box(&mut s, clip, i)).map(|b| (b[0] + b[2]) / 2.0).collect();
assert!(((c[1] - c[0]) - (c[2] - c[1])).abs() < 1e-6, "{c:?}");
// vertical variants move only y
s.execute("graphics.newRectangle", json!({"clip": clip, "position": [800, 100], "size": [40, 40]})).unwrap();
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0, 1, 3]})).unwrap();
let x0 = quad_box(&mut s, clip, 3)[0];
s.execute("graphics.distributeVertically", json!({})).unwrap();
s.execute("graphics.distributeSpaceVertically", json!({})).unwrap();
assert!((quad_box(&mut s, clip, 3)[0] - x0).abs() < 1e-6);
}
#[test]
fn arrange_select_and_reset() {
let mut s = demo();
let clip = three_shapes(&mut s);
let kind = |s: &mut Session, i: usize| s.execute("graphics.list", json!({"clip": clip})).unwrap()["layers"][i]["kind"].as_str().unwrap().to_string();
s.execute("graphics.selectLayer", json!({"clip": clip, "layers": [0]})).unwrap();
s.execute("graphics.bringToFront", json!({})).unwrap();
assert_eq!(kind(&mut s, 2), "Rectangle");
assert_eq!(s.state.graphic_layers, vec![2], "selection follows the layer");
s.execute("graphics.sendBackward", json!({})).unwrap();
assert_eq!(kind(&mut s, 1), "Rectangle");
s.execute("graphics.sendToBack", json!({})).unwrap();
assert_eq!(kind(&mut s, 0), "Rectangle");
s.execute("graphics.bringForward", json!({})).unwrap();
assert_eq!(kind(&mut s, 1), "Rectangle");
// menu shortcuts
let spec = |id: &str| crate::command_specs().iter().find(|c| c.id == id).unwrap();
assert_eq!(spec("graphics.bringToFront").shortcut, Some("Cmd+Shift+]"));
assert_eq!(spec("graphics.sendBackward").shortcut, Some("Cmd+["));
// select next / previous layer cycles
s.execute("graphics.selectNextLayer", json!({})).unwrap();
assert_eq!(s.state.graphic_layers, vec![2]);
s.execute("graphics.selectNextLayer", json!({})).unwrap();
assert_eq!(s.state.graphic_layers, vec![0]);
s.execute("graphics.selectPreviousLayer", json!({})).unwrap();
assert_eq!(s.state.graphic_layers, vec![2]);
// reset all parameters of the selected layer: position back to the centre, content kept
s.execute("graphics.set", json!({"clip": clip, "layer": 2, "props": {"rotation": 30, "opacity": 50}})).unwrap();
s.execute("graphics.resetAllParameters", json!({})).unwrap();
let l = &layers(&s, ClipId(clip))[2];
let (w, h) = {
let q = s.active_sequence().unwrap();
(q.settings.width as f64, q.settings.height as f64)
};
assert_eq!((l.transform.rotation, l.transform.opacity), (0.0, 1.0));
assert_eq!((l.transform.position.x, l.transform.position.y), (w / 2.0, h / 2.0));
let LayerContent::Shape(sh) = &l.content else { panic!() };
assert_eq!(sh.shape, 2, "still a polygon");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layers(&s, ClipId(clip))[2].transform.rotation, 30.0);
}
#[test]
fn select_next_graphic_and_reset_duration() {
let mut s = demo();
s.execute("playhead.set", json!({"seconds": 1})).unwrap();
let a = s.execute("graphics.newText", json!({"text": "A"})).unwrap()["clip"].as_u64().unwrap();
s.execute("playhead.set", json!({"seconds": 8})).unwrap();
let b = s.execute("graphics.newText", json!({"text": "B", "seconds": 2})).unwrap()["clip"].as_u64().unwrap();
s.execute("graphics.selectPreviousGraphic", json!({})).unwrap();
assert_eq!(s.state.selection, vec![ClipId(a)]);
assert!(s.playhead() < Tick::from_seconds_f64(6.0), "playhead moved onto the graphic");
s.execute("graphics.selectNextGraphic", json!({})).unwrap();
assert_eq!(s.state.selection, vec![ClipId(b)]);
assert!(s.execute("graphics.selectNextGraphic", json!({})).is_err(), "no next graphic");
// reset duration: 2 s → 5 s
let r = s.execute("graphics.resetDuration", json!({})).unwrap();
let rate = s.active_sequence().unwrap().settings.frame_rate;
assert_eq!(Tick(r["duration"].as_i64().unwrap()), rate.snap_nearest(Tick::from_seconds_f64(5.0)));
s.execute("edit.undo", json!({})).unwrap();
let q = s.active_sequence().unwrap();
assert_eq!(q.find_item(ClipId(b)).unwrap().1.duration, rate.snap_nearest(Tick::from_seconds_f64(2.0)));
}
#[test]
fn new_layer_from_file_places_the_image_above() {
let mut s = demo();
let dir = crate::media_test_util::tmp_dir("gfx-file");
// a 4×2 24-bit BMP
let (w, h) = (4u32, 2u32);
let row = (w * 3).div_ceil(4) * 4;
let size = 54 + row * h;
let mut b = Vec::new();
b.extend(b"BM");
b.extend(size.to_le_bytes());
b.extend([0u8; 4]);
b.extend(54u32.to_le_bytes());
b.extend(40u32.to_le_bytes());
b.extend((w as i32).to_le_bytes());
b.extend((h as i32).to_le_bytes());
b.extend(1u16.to_le_bytes());
b.extend(24u16.to_le_bytes());
b.extend([0u8; 24]);
for _ in 0..h {
for _ in 0..w {
b.extend([0u8, 0, 255]);
}
b.resize(b.len() + (row - w * 3) as usize, 0);
}
let path = dir.join("logo.bmp");
std::fs::write(&path, &b).unwrap();
let t = s.playhead();
let r = s.execute("graphics.newFromFile", json!({"path": path.to_string_lossy()})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
let q = s.active_sequence().unwrap();
let (tid, it) = q.find_item(clip).unwrap();
assert_eq!(it.start, q.settings.frame_rate.snap(t));
let ti = q.video_tracks.iter().position(|tr| tr.id == tid).unwrap();
assert!(q.video_tracks[..ti].iter().any(|tr| tr.item_at(t).is_some()), "above the footage");
assert_eq!(s.state.selection, vec![clip]);
assert!(s.execute("graphics.newFromFile", json!({})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
+25
View File
@@ -125,6 +125,7 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> {
chc("caps", "Capitalisation", CAPS_OPTS, 0, X),
bo("underline", "Underline", false, X),
fl("box_width", "Text Box Width", 0.0, (0.0, 100_000.0), (0.0, 3840.0), "", X),
bo("vertical", "Vertical Text", false, X),
],
),
layer_def(
@@ -193,6 +194,8 @@ pub struct TextProps {
pub underline: bool,
/// Area-text width (0 = point text).
pub box_width: f32,
/// Vertical text (characters stacked top to bottom; columns right to left).
pub vertical: bool,
}
#[derive(Clone, Debug, PartialEq)]
@@ -273,6 +276,7 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye
caps: ch(e, "caps", t),
underline: bb(e, "underline", t),
box_width: ff(e, "box_width", t).max(0.0),
vertical: bb(e, "vertical", t),
}),
SHAPE_LAYER => {
let sz = vv(e, "size", t);
@@ -337,6 +341,27 @@ pub fn new_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance {
e
}
/// A new vertical text layer (Graphics ▸ New Layer ▸ Vertical Text).
pub fn new_vertical_text_layer(text: &str, position: Vec2, size: f64) -> EffectInstance {
let mut e = new_text_layer(text, position, size);
set(&mut e, "vertical", ParamValue::Bool(true));
e
}
/// Reset a layer's parameters to their defaults, keeping its content (name, text, shape kind,
/// geometry and path) — Graphics ▸ Reset All Parameters.
pub fn reset_layer_params(e: &mut EffectInstance) {
const KEEP: &[&str] = &["name", "text", "shape", "size", "sides", "points", "vertical"];
let Some(def) = crate::effect::find_effect(&e.effect) else { return };
let mut fresh = def.instance();
for k in KEEP {
if let Some(p) = e.params.remove(*k) {
fresh.params.insert((*k).to_string(), p);
}
}
e.params = fresh.params;
}
/// A new shape layer centred at `position`.
pub fn new_shape_layer(shape: u32, position: Vec2, size: Vec2, points: Vec<[f32; 2]>) -> EffectInstance {
let mut e = crate::effect::find_effect(SHAPE_LAYER).expect("registered").instance();
+2 -1
View File
@@ -57,8 +57,9 @@ pub fn text_styles(t: &TextProps) -> (TextStyle, ParagraphStyle) {
_ => Align::Left,
},
leading: t.leading,
width: (t.box_width > 0.0).then_some(t.box_width),
width: (t.box_width > 0.0 && !t.vertical).then_some(t.box_width),
rtl: None,
vertical: t.vertical,
},
)
}
+98 -1
View File
@@ -83,6 +83,9 @@ pub struct ParagraphStyle {
pub width: Option<f32>,
/// Base direction: None = from the first strong character.
pub rtl: Option<bool>,
/// Vertical text: characters stack top to bottom, paragraphs are columns laid out right to
/// left. Alignment applies along the column (Left = top, Center, Right = bottom).
pub vertical: bool,
}
fn hf(h: &mut impl Hasher, v: f32) {
@@ -106,6 +109,7 @@ impl Hash for ParagraphStyle {
hf(h, self.leading);
self.width.map(f32::to_bits).hash(h);
self.rtl.hash(h);
self.vertical.hash(h);
}
}
@@ -150,6 +154,8 @@ pub struct Layout {
/// The face requested was missing (substituted).
pub missing_font: bool,
pub text_len: usize,
/// Laid out vertically (one line per character; columns right to left).
pub vertical: bool,
}
impl Layout {
@@ -172,7 +178,15 @@ impl Layout {
}
/// Nearest caret byte offset to point `(x, y)`.
pub fn hit(&self, x: f32, y: f32) -> usize {
let Some(l) = self.lines.iter().min_by(|a, b| line_dist(a, y).total_cmp(&line_dist(b, y))) else {
let d = |l: &Line| {
if self.vertical {
let dx = if x < l.x - l.ascent * 0.5 { l.x - l.ascent * 0.5 - x } else { (x - l.x - l.width - l.ascent * 0.5).max(0.0) };
line_dist(l, y) + dx
} else {
line_dist(l, y)
}
};
let Some(l) = self.lines.iter().min_by(|a, b| d(a).total_cmp(&d(b))) else {
return 0;
};
l.carets.iter().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(l.range.start, |c| c.0)
@@ -504,6 +518,9 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
let pm = fonts::face(primary.face).metrics(style.size);
let line_h = (pm.ascent + pm.descent + pm.line_gap).max(style.size * 0.5) + para.leading;
let mut lay = Layout { missing_font: primary.missing, text_len: text.len(), ..Default::default() };
if para.vertical {
return layout_vertical(text, style, para, primary, line_h, lay);
}
let mut base = 0usize;
let mut plines = Vec::new();
for p in text.split('\n') {
@@ -558,6 +575,63 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
lay
}
/// Vertical layout: each character is its own line (so carets, hit testing and selection work
/// unchanged), stacked top to bottom and centred on its column; columns run right to left.
fn layout_vertical(text: &str, style: &TextStyle, para: &ParagraphStyle, primary: Resolved, col_w: f32, mut lay: Layout) -> Layout {
let pm = fonts::face(primary.face).metrics(style.size);
let row = (pm.ascent + pm.descent).max(style.size * 0.5) + style.tracking * style.size / 1000.0;
let flat = ParagraphStyle::default();
lay.vertical = true;
let mut base = 0usize;
let mut ncols = 0usize;
for (c, p) in text.split('\n').enumerate() {
ncols = c + 1;
let p_clean = p.strip_suffix('\r').unwrap_or(p);
let mut cells: Vec<ParaLine> = Vec::new();
if p_clean.is_empty() {
cells.extend(paragraph("", base, style, &flat, primary));
}
for (off, ch) in p_clean.char_indices() {
cells.extend(paragraph(&p_clean[off..off + ch.len_utf8()], base + off, style, &flat, primary));
}
let h = row * cells.len() as f32;
let y0 = match para.align {
Align::Center => -h / 2.0,
Align::Right => -h,
_ => 0.0,
};
let cx = -(c as f32) * col_w;
for (k, pl) in cells.into_iter().enumerate() {
let baseline = y0 + row * k as f32;
let shift = cx - pl.content / 2.0 - pl.left_trim;
let g0 = lay.glyphs.len();
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
g.x += shift;
g.y += baseline;
g
}));
lay.lines.push(Line {
range: pl.range,
baseline,
x: shift + pl.left_trim,
width: pl.content,
ascent: pl.ascent,
descent: pl.descent,
glyphs: g0..lay.glyphs.len(),
carets: pl.carets.into_iter().map(|(b, x)| (b, x + shift)).collect(),
});
}
base += p.len() + 1;
}
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
for l in &lay.lines {
y0 = y0.min(l.baseline - l.ascent);
y1 = y1.max(l.baseline + l.descent);
}
lay.bounds = [-(ncols.max(1) as f32 - 0.5) * col_w, y0, col_w / 2.0, y1.max(y0)];
lay
}
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {
static C: OnceLock<Mutex<HashMap<u64, (u64, Arc<Layout>)>>> = OnceLock::new();
C.get_or_init(Default::default)
@@ -653,6 +727,29 @@ mod tests {
assert!((l2.lines[1].baseline - l2.lines[0].baseline - gap - 10.0).abs() < 1e-3);
}
#[test]
fn vertical_text_stacks_characters_in_columns() {
let p = ParagraphStyle { vertical: true, ..Default::default() };
let l = layout("abc\nde", &st(40.0), &p);
assert!(l.vertical);
assert_eq!(l.lines.len(), 5, "one line per character");
// first column: same centre x, increasing baselines
let cx = |i: usize| l.lines[i].x + l.lines[i].width / 2.0;
assert!((cx(0) - cx(2)).abs() < 1e-3 && l.lines[1].baseline > l.lines[0].baseline && l.lines[2].baseline > l.lines[1].baseline);
// second column is to the left of the first
assert!(cx(3) < cx(0) - 20.0, "{} {}", cx(3), cx(0));
assert_eq!(l.lines[3].range, 4..5);
// taller than wide for a single column
let one = layout("abcdef", &st(40.0), &p);
assert!(one.bounds[3] - one.bounds[1] > 3.0 * (one.bounds[2] - one.bounds[0]), "{:?}", one.bounds);
// hit testing finds the character in the second column
let (x, b, _) = l.caret(4);
assert_eq!(l.hit(x + 1.0, b - 5.0), 4);
// differs from horizontal layout in the cache
let h = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
assert_eq!(h.lines.len(), 2);
}
#[test]
fn carets_and_hit_testing() {
let l = layout("abc\nde", &st(40.0), &ParagraphStyle::default());
+20 -4
View File
@@ -45,14 +45,26 @@ Timecode and Clip Name effects also draw with the text engine.
| Command | Menu / shortcut | Params |
|---|---|---|
| `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `clip` (add to this graphic), `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` |
| `graphics.newShape` | Graphics and Titles ▸ New Layer ▸ Shape | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` |
| `graphics.newText` | Graphics and Titles ▸ New Layer ▸ Text (⌘T) | `text`, `position`, `clip` (add to this graphic), `vertical`, `size`, `font`, `fontStyle`, `seconds` (5), `track`, `time` |
| `graphics.newVerticalText` | New Layer ▸ Vertical Text | as `graphics.newText`; characters stack top to bottom, paragraphs are columns right to left |
| `graphics.newRectangle`, `graphics.newEllipse`, `graphics.newPolygon` | New Layer ▸ Rectangle (⌥⌘R), Ellipse (⌥⌘E), Polygon | `position`, `size`, `clip`; polygon `sides` (6) |
| `graphics.newFromFile` | New Layer ▸ From file… | `path` — imports the image or video and places it above the clips at the playhead (a separate clip; graphics have no media layers yet) |
| `graphics.newShape` | (agents) | `shape` (rectangle/ellipse/polygon/path), `position`, `size`, `points`, `clip` |
| `graphics.setText` | typing on the monitor | `clip`, `layer`, `text`, `merge` (coalesce one typing session into one undo step) |
| `graphics.set` | Properties panel | `clip`, `layer`, `props` {parameter id or camelCase alias: value; choices by index or name}, `time` |
| `graphics.selectLayer` | layer list / monitor click | `clip`, `layers` |
| `graphics.deleteLayer`, `graphics.arrangeLayer` | layer list | `clip`, `layer`, `to` (front/back/forward/backward/index) |
| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/selection), `layers` |
| `graphics.distribute` | Align and Transform | `axis` (horizontal/vertical), `layers` (3+) |
| `graphics.align` | Align and Transform | `align` (left/hcenter/right/top/vcenter/bottom), `to` (frame/group/selection), `layers` |
| `graphics.alignFrame.<how>` | Align to Video Frame ▸ Left / Center Horizontally / Right / Top / Center Vertically / Bottom | each selected layer to the frame |
| `graphics.alignGroup.<how>` | Align to Video Frame as Group ▸ … | the selected layers' union to the frame (2+ layers; relative positions kept) |
| `graphics.alignSelection.<how>` | Align to Selection ▸ … | each selected layer to the selection's union (2+ layers) |
| `graphics.distribute` | Align and Transform | `axis` (horizontal/vertical), `space` (equal gaps), `layers` (3+) |
| `graphics.distributeVertically`, `…SpaceVertically`, `…Horizontally`, `…SpaceHorizontally` | Distribute ▸ … | the selected layers (3+): equal centre spacing or equal gaps |
| `graphics.bringToFront`, `graphics.bringForward`, `graphics.sendBackward`, `graphics.sendToBack` | Arrange ▸ … (⇧⌘], ⌘], ⌘[, ⇧⌘[) | `clip`, `layer` |
| `graphics.selectNextGraphic`, `graphics.selectPreviousGraphic` | Select ▸ Select Next / Previous Graphic | selects the next / previous graphic clip in timeline order (moves the playhead onto it) |
| `graphics.selectNextLayer`, `graphics.selectPreviousLayer` | Select ▸ Select Next / Previous Layer (⌥⌘], ⌥⌘[) | cycles the selected layer |
| `graphics.resetAllParameters` | Reset All Parameters | `layers` (default: the selected layers, else all) — appearance, text formatting and transform back to defaults; text, shape and geometry kept |
| `graphics.resetDuration` | Reset Duration | `seconds` (5), limited by the next clip on the track |
| `graphics.list` | (query) | `clip` — layers with names, kinds, text, position and on-canvas quads |
| `fonts.list` | (query) | `system` (scan system font folders, default true) — families and styles |
@@ -70,6 +82,10 @@ duration (a track is added if needed).
| Rectangle / Ellipse | Drag to draw a shape layer. |
| Pen (P) | Click to place points; click the first point (or Return) to close the path; Esc cancels. |
Moving a layer snaps its edges or centre to the frame edges, the frame centre and the guides
(View ▸ Snap in Program Monitor, on by default; hold ⌘/Ctrl to move freely). See
[monitors.md](monitors.md) for rulers and guides.
Automation ids: `program.layer.<clip>.<layer>`, `program.layer.<clip>.<layer>.handle.<n>`,
`program.textEdit` (while editing).