Add text layers

Introduce editable text layers: a resizable text box with editable text,
font size, color, font family, and alignment.

Core:
- New TextLayer/TextContent (text_layer.rs), wired into AnyLayer/LayerType
  and all the exhaustive match sites; structured so content can be keyframed
  later via content_at().
- fonts.rs: thread-local parley FontContext with three bundled fonts
  (Liberation Sans/Serif/Mono, SIL OFL), system-font enumeration consolidated
  to base families, document-embedded fonts, glyph/caret/selection geometry,
  and a background preloader for the picker fonts.
- Rendering via parley layout + Scene::draw_glyphs (renderer.rs); text
  composites through the vector path.
- Actions: CreateTextClipAction (vector-layer branch, undoable),
  SetTextContentAction, ResizeTextBoxAction.
- .beam font embedding: MediaKind::Font rows (content-hash dedupe) written on
  save and registered on load, with bundled-default fallback.
- VectorClip content bounds include text boxes so text-only clips are
  selectable/draggable.

Editor:
- Text tool: click empty/raster/video to create a top-level text layer, or a
  vector layer to create+enter a clip containing the text; click an existing
  box to edit it.
- Hybrid in-place editing: a hidden egui TextEdit drives input/IME/caret while
  the text and caret/selection render in Vello; empty just-created layers are
  removed on commit.
- Selection outline + 8 resize handles (re-wrap text) with hover cursors;
  factored the corner/edge resize-cursor mapping shared with the Transform tool.
- Info panel: edit text, size, color, alignment, box size, and a font-family
  picker that previews each entry in its own font (fonts preloaded in the
  background to avoid hitches).

Deps: add parley (git, pinned to match vello's peniko); bundle Liberation
fonts under lightningbeam-core/assets/fonts. Gitignore the local
.cargo/config.toml used to select a machine's ffmpeg.
This commit is contained in:
Skyler Lehmkuhl 2026-06-27 18:14:05 -04:00
parent 58a3c829d7
commit fd582828c2
37 changed files with 2070 additions and 184 deletions

View File

@ -15,3 +15,8 @@
# OS
.DS_Store
Thumbs.db
# Local, machine-specific cargo config (e.g. PKG_CONFIG_PATH for a local ffmpeg).
# CI/packaging build ffmpeg from source (see .github/workflows/build.yml), so this
# must never leak into a commit.
.cargo/config.toml

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,11 @@ vello = { git = "https://github.com/linebender/vello", branch = "main" }
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
kurbo = { version = "0.12", features = ["serde"] }
peniko = "0.5"
# Text layout/shaping for text layers. Pinned to git main to match the peniko 0.5
# / skrifa 0.37 that vello's git-main resolves to — a released parley still pins
# peniko 0.4, which would split `peniko::Font` into two incompatible types at the
# vello `draw_glyphs` boundary.
parley = { git = "https://github.com/linebender/parley", branch = "main" }
# Windowing
winit = "0.30"

View File

@ -18,6 +18,9 @@ bytemuck = { version = "1.14", features = ["derive"] }
kurbo = { workspace = true }
vello = { workspace = true }
# Text layout/shaping (text layers)
parley = { workspace = true }
# Image decoding for image fills
image = { workspace = true }

View File

@ -0,0 +1,14 @@
Bundled fonts for Lightningbeam text layers
===========================================
These are the Liberation fonts, used as the built-in serif / sans-serif / monospaced
families so text renders deterministically without depending on system fonts:
LiberationSans-Regular.ttf
LiberationSerif-Regular.ttf
LiberationMono-Regular.ttf
License: SIL Open Font License, Version 1.1.
Copyright (c) Red Hat, Inc., with Reserved Font Name "Liberation".
Full license text: https://openfontlicense.org/ (also distributed with the Liberation
fonts package as its OFL.txt / LICENSE).

View File

@ -104,7 +104,7 @@ impl Action for AddClipInstanceAction {
AnyLayer::Group(_) => {
return Err("Cannot add clip instances directly to group layers".to_string());
}
AnyLayer::Raster(_) => {
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
return Err("Cannot add clip instances directly to group layers".to_string());
}
}
@ -145,8 +145,8 @@ impl Action for AddClipInstanceAction {
AnyLayer::Group(_) => {
// Group layers don't have clip instances, nothing to rollback
}
AnyLayer::Raster(_) => {
// Raster layers don't have clip instances, nothing to rollback
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
// Raster/text layers don't have clip instances, nothing to rollback
}
}
self.executed = false;

View File

@ -138,6 +138,7 @@ impl Action for AddLayerAction {
AnyLayer::Effect(_) => "Add effect layer",
AnyLayer::Group(_) => "Add group layer",
AnyLayer::Raster(_) => "Add raster layer",
AnyLayer::Text(_) => "Add text layer",
}
.to_string()
}

View File

@ -0,0 +1,133 @@
//! Create-text-clip action
//!
//! Used by the text tool when the active layer is a **vector** layer: it creates a
//! VectorClip containing a single text layer, registers it, and places a clip
//! instance in the parent vector layer. The editor then enters that clip so the
//! text is directly editable. All of this is one undoable step so undo never
//! leaves an orphan clip.
use crate::action::Action;
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::text_layer::TextLayer;
use uuid::Uuid;
/// Minimum clip duration (seconds) when the document has no duration set yet.
const FALLBACK_DURATION: f64 = 10.0;
pub struct CreateTextClipAction {
/// The vector layer to place the clip instance in.
parent_layer_id: Uuid,
/// The text layer to embed (its id is stable across redo).
text_layer: TextLayer,
/// Instance position in the parent layer's space.
position: (f64, f64),
// Assigned on first execute; reused on redo so ids are stable.
clip_id: Option<Uuid>,
instance_id: Option<Uuid>,
executed: bool,
}
impl CreateTextClipAction {
pub fn new(parent_layer_id: Uuid, text_layer: TextLayer, position: (f64, f64)) -> Self {
Self {
parent_layer_id,
text_layer,
position,
clip_id: None,
instance_id: None,
executed: false,
}
}
/// Preset the clip + instance ids (so the caller knows them up-front for
/// entering the clip immediately after execute). Ids stay stable across redo.
pub fn with_ids(mut self, clip_id: Uuid, instance_id: Uuid) -> Self {
self.clip_id = Some(clip_id);
self.instance_id = Some(instance_id);
self
}
/// The vector clip id (after execute).
pub fn clip_id(&self) -> Option<Uuid> {
self.clip_id
}
/// The clip instance id placed in the parent layer (after execute).
pub fn instance_id(&self) -> Option<Uuid> {
self.instance_id
}
/// The embedded text layer's id.
pub fn text_layer_id(&self) -> Uuid {
self.text_layer.layer.id
}
}
impl Action for CreateTextClipAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let clip_id = self.clip_id.unwrap_or_else(Uuid::new_v4);
self.clip_id = Some(clip_id);
let instance_id = self.instance_id.unwrap_or_else(Uuid::new_v4);
self.instance_id = Some(instance_id);
let duration = if document.duration > 0.0 { document.duration } else { FALLBACK_DURATION };
// Build the clip with the text layer as its single root layer.
let mut clip = VectorClip::with_id(
clip_id,
"Text",
self.text_layer.box_width.max(1.0),
self.text_layer.box_height.max(1.0),
duration,
);
// A movie clip (not keyframe-gated) so the text persists across the timeline.
clip.is_group = false;
clip.layers.add_root(AnyLayer::Text(self.text_layer.clone()));
// Registers the clip and its layers in layer_to_clip_map for O(1) lookup.
document.add_vector_clip(clip);
// Place an instance of the clip in the parent vector layer.
let layer = document
.get_layer_mut(&self.parent_layer_id)
.ok_or_else(|| format!("Parent layer {} not found", self.parent_layer_id))?;
let AnyLayer::Vector(vector_layer) = layer else {
return Err("Text clip can only be created inside a vector layer".to_string());
};
let mut instance = ClipInstance::with_id(instance_id, clip_id);
instance.transform.x = self.position.0;
instance.transform.y = self.position.1;
vector_layer.clip_instances.push(instance);
self.executed = true;
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if !self.executed {
return Ok(());
}
// Remove the clip instance from the parent layer.
if let (Some(instance_id), Some(AnyLayer::Vector(vector_layer))) =
(self.instance_id, document.get_layer_mut(&self.parent_layer_id))
{
vector_layer.clip_instances.retain(|ci| ci.id != instance_id);
}
// Remove the clip and its layer_to_clip_map registrations.
if let Some(clip_id) = self.clip_id {
if let Some(clip) = document.vector_clips.remove(&clip_id) {
for node in &clip.layers.roots {
document.layer_to_clip_map.remove(&node.data.id());
}
}
}
self.executed = false;
Ok(())
}
fn description(&self) -> String {
"Add text".to_string()
}
}

View File

@ -37,6 +37,7 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
@ -61,6 +62,7 @@ impl Action for LoopClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {

View File

@ -44,6 +44,9 @@ pub mod resize_raster_layer;
pub mod move_layer;
pub mod set_fill_paint;
pub mod set_image_fill;
pub mod create_text_clip;
pub mod set_text_content;
pub mod resize_text_box;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -84,3 +87,6 @@ pub use set_fill_paint::SetFillPaintAction;
pub use set_image_fill::SetImageFillAction;
pub use change_bpm::ChangeBpmAction;
pub use change_fps::ChangeFpsAction;
pub use create_text_clip::CreateTextClipAction;
pub use set_text_content::SetTextContentAction;
pub use resize_text_box::ResizeTextBoxAction;

View File

@ -58,6 +58,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -97,6 +98,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
@ -132,6 +134,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
// Update timeline_start for each clip instance
@ -159,6 +162,7 @@ impl Action for MoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
// Restore original timeline_start for each clip instance

View File

@ -46,6 +46,7 @@ impl Action for RemoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
// Find and remove the instance, saving it for rollback
@ -72,6 +73,7 @@ impl Action for RemoveClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
clip_instances.push(instance);

View File

@ -0,0 +1,59 @@
//! Resize-text-box action
//!
//! Updates a text layer's box origin and dimensions (which changes the wrap
//! width, so parley re-wraps the text). One undoable step.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use kurbo::Point;
use uuid::Uuid;
pub struct ResizeTextBoxAction {
layer_id: Uuid,
new_origin: Point,
new_width: f64,
new_height: f64,
old: Option<(Point, f64, f64)>,
}
impl ResizeTextBoxAction {
pub fn new(layer_id: Uuid, new_origin: Point, new_width: f64, new_height: f64) -> Self {
Self { layer_id, new_origin, new_width, new_height, old: None }
}
}
impl Action for ResizeTextBoxAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Text(text_layer) = layer else {
return Err("ResizeTextBoxAction target is not a text layer".to_string());
};
if self.old.is_none() {
self.old = Some((text_layer.box_origin, text_layer.box_width, text_layer.box_height));
}
text_layer.box_origin = self.new_origin;
text_layer.box_width = self.new_width.max(1.0);
text_layer.box_height = self.new_height.max(1.0);
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some((origin, w, h)) = self.old else { return Ok(()) };
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Text(text_layer) = layer {
text_layer.box_origin = origin;
text_layer.box_width = w;
text_layer.box_height = h;
}
Ok(())
}
fn description(&self) -> String {
"Resize text box".to_string()
}
}

View File

@ -0,0 +1,61 @@
//! Set-text-content action
//!
//! Replaces a text layer's [`TextContent`] (text string, font size, color, family,
//! alignment) as one undoable step. Used both by in-place editing (text changes)
//! and the info panel (style changes).
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::text_layer::TextContent;
use uuid::Uuid;
pub struct SetTextContentAction {
layer_id: Uuid,
new: TextContent,
old: Option<TextContent>,
}
impl SetTextContentAction {
pub fn new(layer_id: Uuid, new: TextContent) -> Self {
Self { layer_id, new, old: None }
}
/// Construct with an explicit `old` value. Used by in-place editing, which mutates
/// the document live for preview and then records one undoable step capturing the
/// content as it was *before* editing began.
pub fn with_old(layer_id: Uuid, old: TextContent, new: TextContent) -> Self {
Self { layer_id, new, old: Some(old) }
}
}
impl Action for SetTextContentAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let AnyLayer::Text(text_layer) = layer else {
return Err("SetTextContentAction target is not a text layer".to_string());
};
if self.old.is_none() {
self.old = Some(text_layer.content.clone());
}
text_layer.content = self.new.clone();
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(old) = self.old.clone() else { return Ok(()) };
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
if let AnyLayer::Text(text_layer) = layer {
text_layer.content = old;
}
Ok(())
}
fn description(&self) -> String {
"Edit text".to_string()
}
}

View File

@ -114,6 +114,7 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()),
AnyLayer::Raster(_) => return Err("Cannot split clip instances on group layers".to_string()),
AnyLayer::Text(_) => return Err("Cannot split clip instances on group layers".to_string()),
};
let instance = clip_instances
@ -233,7 +234,7 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Group(_) => {
return Err("Cannot split clip instances on group layers".to_string());
}
AnyLayer::Raster(_) => {
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
return Err("Cannot split clip instances on group layers".to_string());
}
}
@ -294,8 +295,8 @@ impl Action for SplitClipInstanceAction {
AnyLayer::Group(_) => {
// Group layers don't have clip instances, nothing to rollback
}
AnyLayer::Raster(_) => {
// Raster layers don't have clip instances, nothing to rollback
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
// Raster/text layers don't have clip instances, nothing to rollback
}
}

View File

@ -101,6 +101,7 @@ impl Action for TransformClipInstancesAction {
AnyLayer::Effect(_) => {}
AnyLayer::Group(_) => {}
AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
}
Ok(())
}
@ -140,6 +141,7 @@ impl Action for TransformClipInstancesAction {
AnyLayer::Effect(_) => {}
AnyLayer::Group(_) => {}
AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
}
Ok(())
}

View File

@ -101,6 +101,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -138,6 +139,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
@ -182,6 +184,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
let instance = clip_instances.iter()
@ -275,6 +278,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
// Apply trims
@ -315,6 +319,7 @@ impl Action for TrimClipInstancesAction {
AnyLayer::Effect(el) => &mut el.clip_instances,
AnyLayer::Group(_) => continue,
AnyLayer::Raster(_) => continue,
AnyLayer::Text(_) => continue,
};
// Restore original trim values

View File

@ -62,6 +62,10 @@ pub enum MediaKind {
/// the keyframe id). Decoded eagerly on load and shown while the full-res pixels
/// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`.
RasterProxy = 6,
/// An embedded font file (TTF/OTF) used by a text layer (keyed by a content hash
/// of the bytes so identical fonts dedupe). Registered into the font collection
/// on load so documents render faithfully on machines lacking the font.
Font = 7,
}
impl MediaKind {
@ -74,6 +78,7 @@ impl MediaKind {
4 => Some(Self::Waveform),
5 => Some(Self::Thumbnail),
6 => Some(Self::RasterProxy),
7 => Some(Self::Font),
_ => None,
}
}

View File

@ -122,6 +122,7 @@ impl VectorClip {
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
for ci in clip_instances {
// Compute end position of this clip instance in beats
@ -223,6 +224,17 @@ impl VectorClip {
Some(existing) => existing.union(transformed_bounds),
});
}
} else if let AnyLayer::Text(text_layer) = &layer_node.data {
// Text layers contribute their box bounds (so a text-only clip is
// selectable/draggable, not a degenerate point).
let r = Rect::from_origin_size(
text_layer.box_origin,
(text_layer.box_width, text_layer.box_height),
);
combined_bounds = Some(match combined_bounds {
None => r,
Some(existing) => existing.union(r),
});
}
}

View File

@ -62,6 +62,7 @@ impl ClipboardLayerType {
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
AnyLayer::Group(_) => ClipboardLayerType::Vector,
AnyLayer::Raster(_) => ClipboardLayerType::Vector,
AnyLayer::Text(_) => ClipboardLayerType::Vector,
}
}
@ -449,6 +450,13 @@ fn regen_any_layer(layer: &AnyLayer, id_map: &mut HashMap<Uuid, Uuid>) -> AnyLay
nl.layer.id = new_layer_id;
AnyLayer::Raster(nl)
}
AnyLayer::Text(tl) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(tl.layer.id, new_layer_id);
let mut nl = tl.clone();
nl.layer.id = new_layer_id;
AnyLayer::Text(nl)
}
AnyLayer::Group(gl) => {
let new_layer_id = Uuid::new_v4();
id_map.insert(gl.layer.id, new_layer_id);

View File

@ -489,8 +489,8 @@ impl Document {
}
}
}
crate::layer::AnyLayer::Raster(_) => {
// Raster layers don't have clip instances
crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
// Raster and text layers don't have clip instances
}
crate::layer::AnyLayer::Group(group) => {
// Recurse into group children to find their clip instance endpoints
@ -530,8 +530,8 @@ impl Document {
}
}
}
crate::layer::AnyLayer::Raster(_) => {
// Raster layers don't have clip instances
crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
// Raster and text layers don't have clip instances
}
crate::layer::AnyLayer::Group(g) => {
process_group_children(&g.children, doc, max_end, calc_end);
@ -937,6 +937,7 @@ impl Document {
AnyLayer::Effect(effect) => &effect.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
@ -977,6 +978,7 @@ impl Document {
AnyLayer::Effect(effect) => &effect.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
for instance in instances {
@ -1039,6 +1041,7 @@ impl Document {
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips
AnyLayer::Raster(_) => return Some(desired_start), // Raster layers don't have own clips
AnyLayer::Text(_) => return Some(desired_start), // Text layers don't have own clips
};
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
@ -1134,6 +1137,7 @@ impl Document {
AnyLayer::Vector(v) => &v.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
// Collect non-group clip ranges
@ -1205,6 +1209,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
for other in instances {
@ -1253,6 +1258,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
let mut nearest_start = f64::MAX;
@ -1300,6 +1306,7 @@ impl Document {
AnyLayer::Vector(vector) => &vector.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
let mut nearest_end = 0.0;

View File

@ -242,6 +242,21 @@ fn thumbnail_media_id(clip_id: Uuid) -> Uuid {
Uuid::from_u128(clip_id.as_u128() ^ SENTINEL)
}
/// Content-hash id for an embedded font row, so identical font files dedupe to one
/// row regardless of which / how many text layers use them. A 128-bit id is built
/// from two salted 64-bit hashes of the bytes.
fn font_media_id(bytes: &[u8]) -> Uuid {
use std::hash::{Hash, Hasher};
let mut h1 = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h1);
let mut h2 = std::collections::hash_map::DefaultHasher::new();
0xF0E1_D2C3_B4A5_9687u64.hash(&mut h2);
bytes.hash(&mut h2);
let hi = h1.finish() as u128;
let lo = h2.finish() as u128;
Uuid::from_u128((hi << 64) | lo)
}
/// Derived id for a raster keyframe's low-res proxy row (distinct from the keyframe's
/// own full-res `Raster` row, which is keyed by the raw keyframe id).
fn raster_proxy_media_id(kf_id: Uuid) -> Uuid {
@ -535,6 +550,32 @@ pub fn save_beam(
}
let _ = image_count;
// --- embedded fonts -> media rows (Font), keyed by content hash so identical
// fonts dedupe. Embed every non-bundled family used by a text layer so the
// document renders faithfully on machines lacking the font. The bundled
// three ship with the app and are never embedded. ---
{
use crate::layer::AnyLayer;
let mut families: HashSet<String> = HashSet::new();
for layer in document.all_layers() {
if let AnyLayer::Text(tl) = layer {
let fam = &tl.content.font_family;
if !fam.is_empty() && !crate::fonts::is_bundled(fam) {
families.insert(fam.clone());
}
}
}
for fam in families {
if let Some(bytes) = crate::fonts::family_font_bytes(&fam) {
let id = font_media_id(&bytes);
if !txn.media_exists(id)? {
txn.put_media_packed(id, MediaKind::Font, &fam, &bytes, MediaMeta::default())?;
}
live_media.insert(id);
}
}
}
// --- orphan cleanup: drop media for removed clips/keyframes ---
let removed = txn.retain_media(&live_media)?;
@ -598,6 +639,17 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)...");
let archive = BeamArchive::open(path)?;
// Register document-embedded fonts into the runtime font collection so text
// layers referencing them render faithfully even if the host lacks the font.
if let Ok(font_ids) = archive.media_ids_of_kind(MediaKind::Font) {
for id in font_ids {
if let Ok(bytes) = archive.read_media_full(id) {
crate::fonts::register_embedded(bytes);
}
}
}
let json = archive.get_project_json()?;
let beam_project: BeamProject = serde_json::from_str(&json)
.map_err(|e| format!("Failed to deserialize project.json: {}", e))?;

View File

@ -0,0 +1,296 @@
//! Font registry + text layout for text layers.
//!
//! Wraps a thread-local parley [`FontContext`]/[`LayoutContext`]. Three fonts are
//! bundled (serif, sans-serif, monospaced) so text renders deterministically even
//! offline; system fonts are also enumerated (parley's fontique source) and
//! document-embedded fonts can be registered at load time (see `file_io`).
//!
//! The same `linebender_resource_handle` crate (0.1.1) backs both vello's
//! `peniko::FontData` and parley's `FontData`, so a glyph run's `run.font()` can be
//! handed straight to `vello::Scene::draw_glyphs` with no conversion.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, Layout, LayoutContext,
PositionedLayoutItem, StyleProperty,
};
use vello::peniko::Blob;
use crate::text_layer::{TextAlign, TextContent};
// ── Bundled fonts (SIL OFL; vendored under assets/fonts) ─────────────────────
static BUNDLED_SANS: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf");
static BUNDLED_SERIF: &[u8] = include_bytes!("../assets/fonts/LiberationSerif-Regular.ttf");
static BUNDLED_MONO: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf");
/// Parley requires a brush type, but glyph color is applied by vello at draw time,
/// so we use a zero-sized placeholder.
#[derive(Clone, Copy, PartialEq, Default, Debug)]
pub struct NoBrush;
struct FontStore {
fcx: FontContext,
lcx: LayoutContext<NoBrush>,
/// Registered family names of the three bundled fonts (sans, serif, mono).
bundled: Vec<String>,
/// The default family (bundled sans-serif), used when `font_family` is empty.
default_family: String,
}
impl FontStore {
fn new() -> Self {
let mut fcx = FontContext::new();
let mut bundled = Vec::new();
for bytes in [BUNDLED_SANS, BUNDLED_SERIF, BUNDLED_MONO] {
let blob = Blob::new(Arc::new(bytes) as Arc<dyn AsRef<[u8]> + Send + Sync>);
for (family_id, _) in fcx.collection.register_fonts(blob, None) {
if let Some(name) = fcx.collection.family_name(family_id) {
let name = name.to_string();
if !bundled.contains(&name) {
bundled.push(name);
}
}
}
}
let default_family = bundled.first().cloned().unwrap_or_else(|| "sans-serif".to_string());
Self { fcx, lcx: LayoutContext::new(), bundled, default_family }
}
}
thread_local! {
static STORE: RefCell<FontStore> = RefCell::new(FontStore::new());
}
/// The default family name (bundled sans-serif), used when a text layer's
/// `font_family` is empty.
pub fn default_family() -> String {
STORE.with(|s| s.borrow().default_family.clone())
}
/// Whether `family` is one of the three bundled families (which therefore must
/// never be embedded into a `.beam`).
pub fn is_bundled(family: &str) -> bool {
STORE.with(|s| s.borrow().bundled.iter().any(|f| f == family))
}
/// True if a shorter word-prefix of `name` is itself a family in `set` — i.e. `name`
/// is a variant of a base family (e.g. "Noto Sans Arabic" when "Noto Sans" exists).
/// Used to collapse the many script/region-specific fonts (mostly Noto) into their
/// base family in the picker; parley's fallback still resolves the right script font
/// automatically when rendering non-Latin text.
fn is_variant_of_listed_base(name: &str, set: &std::collections::HashSet<&str>) -> bool {
let words: Vec<&str> = name.split(' ').collect();
for k in 1..words.len() {
if set.contains(words[..k].join(" ").as_str()) {
return true;
}
}
false
}
/// Available family names for the info-panel picker: the three bundled families first,
/// then a consolidated, alphabetical list of system families — script/region variants
/// whose base family is present are dropped (monospace variants are kept).
pub fn families() -> Vec<String> {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let mut system: Vec<String> =
s.fcx.collection.family_names().map(|n| n.to_string()).collect();
system.sort();
system.dedup();
let set: std::collections::HashSet<&str> = system.iter().map(|x| x.as_str()).collect();
let mut out = s.bundled.clone();
for name in &system {
if out.iter().any(|b| b == name) {
continue;
}
// Keep base families and monospace variants; drop script/region variants
// whose base family is already in the list.
if name.contains("Mono") || !is_variant_of_listed_base(name, &set) {
out.push(name.clone());
}
}
out
})
}
/// Register a document-embedded font (raw TTF/OTF bytes) into the runtime font
/// collection so text layers referencing its family resolve to it. Returns the
/// registered family names.
pub fn register_embedded(bytes: Vec<u8>) -> Vec<String> {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let blob = Blob::new(Arc::new(bytes) as Arc<dyn AsRef<[u8]> + Send + Sync>);
let mut names = Vec::new();
for (family_id, _) in s.fcx.collection.register_fonts(blob, None) {
if let Some(name) = s.fcx.collection.family_name(family_id) {
names.push(name.to_string());
}
}
names
})
}
/// Whether `family` currently resolves to a registered family (bundled, system,
/// or embedded). Used to flag "missing font" on load.
pub fn family_available(family: &str) -> bool {
if family.is_empty() {
return true;
}
STORE.with(|s| {
let s = &mut *s.borrow_mut();
s.fcx.collection.family_id(family).is_some()
})
}
/// The raw bytes of the font file that `family` resolves to (for embedding into a
/// `.beam`). Returns `None` if the family resolves to no glyphs. Lays out a single
/// glyph and reads the resolved run's font blob (the same `linebender_resource_handle`
/// blob vello uses).
pub fn family_font_bytes(family: &str) -> Option<Vec<u8>> {
let content = TextContent {
text: "A".to_string(),
font_family: family.to_string(),
..TextContent::default()
};
with_layout(&content, 10_000.0, |layout| {
for line in layout.lines() {
for item in line.items() {
if let PositionedLayoutItem::GlyphRun(run) = item {
return Some(run.run().font().data.data().to_vec());
}
}
}
None
})
}
// ── Background font preloading ───────────────────────────────────────────────
//
// Loading a family's bytes (`family_font_bytes`) is the expensive part (font file IO +
// shaping). Doing it for the whole picker on the UI thread causes hitches, so a
// background thread (started at app launch) loads every picker family's bytes ahead of
// time into a shared map. The UI then only has to *register* them with its renderer
// (cheap), and synchronously loads the few stragglers only if needed before they're ready.
struct PreloadState {
loaded: HashMap<String, Vec<u8>>,
started: bool,
done: bool,
}
fn preload() -> &'static Mutex<PreloadState> {
static P: OnceLock<Mutex<PreloadState>> = OnceLock::new();
P.get_or_init(|| Mutex::new(PreloadState {
loaded: HashMap::new(),
started: false,
done: false,
}))
}
/// Start (once) a background thread that loads every picker font's bytes. Idempotent;
/// safe to call every frame or from app startup.
pub fn start_preload() {
{
let mut p = preload().lock().unwrap();
if p.started {
return;
}
p.started = true;
}
let _ = std::thread::Builder::new()
.name("font-preload".into())
.spawn(|| {
// This thread gets its own thread-local FontContext (enumerates system fonts
// off the UI thread). Bytes are plain `Vec<u8>` (Send) handed back via the map.
for fam in families() {
if let Some(bytes) = family_font_bytes(&fam) {
preload().lock().unwrap().loaded.insert(fam, bytes);
}
}
preload().lock().unwrap().done = true;
});
}
/// Remove and return a preloaded font's bytes if the background thread has them ready.
pub fn take_preloaded(family: &str) -> Option<Vec<u8>> {
preload().lock().unwrap().loaded.remove(family)
}
/// Whether the background preloader has finished loading every family.
pub fn preload_done() -> bool {
preload().lock().unwrap().done
}
/// Caret rectangle (x0, y0, x1, y1, in layout space relative to the box origin) for
/// `byte_index` into `content.text`, wrapped to `max_width`.
pub fn caret_geometry(content: &TextContent, max_width: f32, byte_index: usize) -> Option<(f64, f64, f64, f64)> {
let caret_w = (content.font_size as f32 * 0.06).max(1.0);
with_layout(content, max_width, |layout| {
let cur = parley::Cursor::from_byte_index(layout, byte_index, parley::Affinity::Downstream);
let bb = cur.geometry(layout, caret_w);
Some((bb.x0, bb.y0, bb.x1, bb.y1))
})
}
/// Selection highlight rectangles (each x0, y0, x1, y1 in layout space) for the byte
/// range `[start, end)` into `content.text`, wrapped to `max_width`.
pub fn selection_geometry(content: &TextContent, max_width: f32, start: usize, end: usize) -> Vec<(f64, f64, f64, f64)> {
if start == end {
return Vec::new();
}
with_layout(content, max_width, |layout| {
let anchor = parley::Cursor::from_byte_index(layout, start, parley::Affinity::Downstream);
let focus = parley::Cursor::from_byte_index(layout, end, parley::Affinity::Downstream);
let sel = parley::Selection::new(anchor, focus);
sel.geometry(layout)
.into_iter()
.map(|(bb, _)| (bb.x0, bb.y0, bb.x1, bb.y1))
.collect()
})
}
fn alignment_of(a: TextAlign) -> Alignment {
match a {
TextAlign::Left => Alignment::Left,
TextAlign::Center => Alignment::Center,
TextAlign::Right => Alignment::Right,
TextAlign::Justify => Alignment::Justify,
}
}
/// Build a parley layout for `content` wrapped to `max_width` (document units),
/// then invoke `f` with it. The layout is rebuilt on each call (v1: no cache).
pub fn with_layout<R>(
content: &TextContent,
max_width: f32,
f: impl FnOnce(&Layout<NoBrush>) -> R,
) -> R {
STORE.with(|s| {
let s = &mut *s.borrow_mut();
let default_family = s.default_family.clone();
let FontStore { fcx, lcx, .. } = s;
let mut builder = lcx.ranged_builder(fcx, &content.text, 1.0, true);
builder.push_default(StyleProperty::FontSize(content.font_size as f32));
let family_name = if content.font_family.is_empty() {
default_family
} else {
content.font_family.clone()
};
let family = FontFamily::Single(FontFamilyName::Named(Cow::Owned(family_name)));
builder.push_default(StyleProperty::FontFamily(family));
let mut layout = builder.build(&content.text);
layout.break_all_lines(Some(max_width));
layout.align(alignment_of(content.align), AlignmentOptions::default());
f(&layout)
})
}

View File

@ -9,6 +9,7 @@ use crate::vector_graph::VectorGraph;
use crate::effect_layer::EffectLayer;
use crate::object::ShapeInstance;
use crate::raster_layer::RasterLayer;
use crate::text_layer::TextLayer;
use crate::shape::Shape;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@ -31,6 +32,8 @@ pub enum LayerType {
Group,
/// Raster pixel-buffer painting layer
Raster,
/// Text layer (a single editable text box)
Text,
}
/// Common trait for all layer types
@ -866,6 +869,7 @@ impl GroupLayer {
AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[], // no nested groups
AnyLayer::Raster(_) => &[], // raster layers have no clip instances
AnyLayer::Text(_) => &[], // raster layers have no clip instances
};
for ci in instances {
result.push((child_id, ci));
@ -884,6 +888,7 @@ pub enum AnyLayer {
Effect(EffectLayer),
Group(GroupLayer),
Raster(RasterLayer),
Text(TextLayer),
}
impl LayerTrait for AnyLayer {
@ -895,6 +900,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.id(),
AnyLayer::Group(l) => l.id(),
AnyLayer::Raster(l) => l.id(),
AnyLayer::Text(l) => l.id(),
}
}
@ -906,6 +912,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.name(),
AnyLayer::Group(l) => l.name(),
AnyLayer::Raster(l) => l.name(),
AnyLayer::Text(l) => l.name(),
}
}
@ -917,6 +924,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_name(name),
AnyLayer::Group(l) => l.set_name(name),
AnyLayer::Raster(l) => l.set_name(name),
AnyLayer::Text(l) => l.set_name(name),
}
}
@ -928,6 +936,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.has_custom_name(),
AnyLayer::Group(l) => l.has_custom_name(),
AnyLayer::Raster(l) => l.has_custom_name(),
AnyLayer::Text(l) => l.has_custom_name(),
}
}
@ -939,6 +948,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_has_custom_name(custom),
AnyLayer::Group(l) => l.set_has_custom_name(custom),
AnyLayer::Raster(l) => l.set_has_custom_name(custom),
AnyLayer::Text(l) => l.set_has_custom_name(custom),
}
}
@ -950,6 +960,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.visible(),
AnyLayer::Group(l) => l.visible(),
AnyLayer::Raster(l) => l.visible(),
AnyLayer::Text(l) => l.visible(),
}
}
@ -961,6 +972,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_visible(visible),
AnyLayer::Group(l) => l.set_visible(visible),
AnyLayer::Raster(l) => l.set_visible(visible),
AnyLayer::Text(l) => l.set_visible(visible),
}
}
@ -972,6 +984,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.opacity(),
AnyLayer::Group(l) => l.opacity(),
AnyLayer::Raster(l) => l.opacity(),
AnyLayer::Text(l) => l.opacity(),
}
}
@ -983,6 +996,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_opacity(opacity),
AnyLayer::Group(l) => l.set_opacity(opacity),
AnyLayer::Raster(l) => l.set_opacity(opacity),
AnyLayer::Text(l) => l.set_opacity(opacity),
}
}
@ -994,6 +1008,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.volume(),
AnyLayer::Group(l) => l.volume(),
AnyLayer::Raster(l) => l.volume(),
AnyLayer::Text(l) => l.volume(),
}
}
@ -1005,6 +1020,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_volume(volume),
AnyLayer::Group(l) => l.set_volume(volume),
AnyLayer::Raster(l) => l.set_volume(volume),
AnyLayer::Text(l) => l.set_volume(volume),
}
}
@ -1016,6 +1032,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.muted(),
AnyLayer::Group(l) => l.muted(),
AnyLayer::Raster(l) => l.muted(),
AnyLayer::Text(l) => l.muted(),
}
}
@ -1027,6 +1044,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_muted(muted),
AnyLayer::Group(l) => l.set_muted(muted),
AnyLayer::Raster(l) => l.set_muted(muted),
AnyLayer::Text(l) => l.set_muted(muted),
}
}
@ -1038,6 +1056,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.soloed(),
AnyLayer::Group(l) => l.soloed(),
AnyLayer::Raster(l) => l.soloed(),
AnyLayer::Text(l) => l.soloed(),
}
}
@ -1049,6 +1068,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_soloed(soloed),
AnyLayer::Group(l) => l.set_soloed(soloed),
AnyLayer::Raster(l) => l.set_soloed(soloed),
AnyLayer::Text(l) => l.set_soloed(soloed),
}
}
@ -1060,6 +1080,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.locked(),
AnyLayer::Group(l) => l.locked(),
AnyLayer::Raster(l) => l.locked(),
AnyLayer::Text(l) => l.locked(),
}
}
@ -1071,6 +1092,7 @@ impl LayerTrait for AnyLayer {
AnyLayer::Effect(l) => l.set_locked(locked),
AnyLayer::Group(l) => l.set_locked(locked),
AnyLayer::Raster(l) => l.set_locked(locked),
AnyLayer::Text(l) => l.set_locked(locked),
}
}
}
@ -1085,6 +1107,7 @@ impl AnyLayer {
AnyLayer::Effect(l) => &l.layer,
AnyLayer::Group(l) => &l.layer,
AnyLayer::Raster(l) => &l.layer,
AnyLayer::Text(l) => &l.layer,
}
}
@ -1097,6 +1120,7 @@ impl AnyLayer {
AnyLayer::Effect(l) => &mut l.layer,
AnyLayer::Group(l) => &mut l.layer,
AnyLayer::Raster(l) => &mut l.layer,
AnyLayer::Text(l) => &mut l.layer,
}
}

View File

@ -54,6 +54,8 @@ pub mod svg_export;
pub mod snap;
pub mod webcam;
pub mod raster_layer;
pub mod text_layer;
pub mod fonts;
pub mod raster_store;
pub mod brush_settings;
pub mod brush_engine;

View File

@ -698,6 +698,11 @@ pub fn render_layer_isolated(
};
}
}
AnyLayer::Text(text_layer) => {
// Text composites as vector geometry (glyphs in the Vello scene).
rendered.has_content =
render_text_layer_to_scene(text_layer, time, &mut rendered.scene, base_transform);
}
}
rendered
@ -757,6 +762,57 @@ fn render_raster_layer_to_scene(
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
}
/// Render a text layer's glyphs into a Vello scene.
///
/// Text is laid out with parley (wrapped to the box width) and drawn via
/// `Scene::draw_glyphs`. The box origin offsets the whole layout; `base_transform`
/// carries the layer/clip-instance transform and camera. Returns whether anything
/// was drawn.
fn render_text_layer_to_scene(
layer: &crate::text_layer::TextLayer,
time: f64,
scene: &mut Scene,
base_transform: Affine,
) -> bool {
let content = layer.content_at(time);
if content.text.is_empty() {
return false;
}
let color = vello::peniko::Color::new(content.color);
let origin = Affine::translate((layer.box_origin.x, layer.box_origin.y));
let mut drew = false;
crate::fonts::with_layout(content, layer.box_width as f32, |layout| {
for line in layout.lines() {
for item in line.items() {
let parley::PositionedLayoutItem::GlyphRun(glyph_run) = item else { continue };
let run = glyph_run.run();
let font = run.font();
let font_size = run.font_size();
let synthesis = run.synthesis();
let glyph_xform = synthesis
.skew()
.map(|angle| Affine::skew((angle as f64).to_radians().tan(), 0.0));
drew = true;
scene
.draw_glyphs(font)
.font_size(font_size)
.brush(color)
.transform(base_transform * origin)
.glyph_transform(glyph_xform)
.draw(
Fill::NonZero,
glyph_run.positioned_glyphs().map(|g| vello::Glyph {
id: g.id as u32,
x: g.x,
y: g.y,
}),
);
}
}
});
drew
}
// ============================================================================
// Legacy Single-Scene Rendering (kept for backwards compatibility)
// ============================================================================
@ -884,6 +940,11 @@ fn render_layer(
if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; }
render_raster_layer_to_scene(raster_layer, time, scene, base_transform);
}
AnyLayer::Text(text_layer) => {
// Text is non-video content — force the Vello fallback if extracting.
if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; }
render_text_layer_to_scene(text_layer, time, scene, base_transform);
}
}
}
@ -1900,7 +1961,9 @@ fn render_vector_content_cpu(
render_vector_content_cpu(document, time, child, pixmap, base_transform, parent_opacity, image_cache);
}
}
AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_) => {}
// Text is not rendered in the tiny-skia CPU fallback (GPU path only) for v1.
AnyLayer::Audio(_) | AnyLayer::Video(_) | AnyLayer::Effect(_) | AnyLayer::Raster(_)
| AnyLayer::Text(_) => {}
}
}

View File

@ -0,0 +1,145 @@
//! Text layer for Lightningbeam
//!
//! A text layer holds a single editable text field inside a resizable box,
//! with editable size, color, and font. Text is rendered as vector glyphs
//! (via parley + Vello) so it composites through the same path as vector art.
//!
//! The text/style fields are grouped in [`TextContent`] so they can move to a
//! per-keyframe model later without touching call sites; v1 stores a single
//! static instance and reads it through [`TextLayer::content_at`].
use crate::layer::{Layer, LayerTrait, LayerType};
use kurbo::Point;
use serde::{Deserialize, Serialize};
use std::hash::{Hash, Hasher};
use uuid::Uuid;
/// Horizontal alignment of text within the box.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextAlign {
Left,
Center,
Right,
Justify,
}
impl Default for TextAlign {
fn default() -> Self {
TextAlign::Left
}
}
/// The text content + styling for a text layer.
///
/// Grouped as its own struct so a future keyframed model can store
/// `Vec<TextKeyframe>` of these without changing the layer's public shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TextContent {
/// The text string.
pub text: String,
/// Font size in pixels (document space).
pub font_size: f64,
/// Fill color, linear RGBA in 0..=1.
pub color: [f32; 4],
/// Logical font family name. Empty string = the bundled default font.
pub font_family: String,
/// Horizontal alignment within the box.
pub align: TextAlign,
}
impl Default for TextContent {
fn default() -> Self {
Self {
text: String::new(),
font_size: 48.0,
color: [1.0, 1.0, 1.0, 1.0],
font_family: String::new(),
align: TextAlign::Left,
}
}
}
impl TextContent {
/// A stable hash of everything that affects shaped layout (everything except
/// `color`, which only affects the brush, not glyph positions). Used to key
/// the renderer's external parley-layout cache, including the wrap width.
pub fn layout_hash(&self, box_width: f64) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
self.text.hash(&mut h);
self.font_size.to_bits().hash(&mut h);
self.font_family.hash(&mut h);
self.align.hash(&mut h);
box_width.to_bits().hash(&mut h);
h.finish()
}
}
impl Hash for TextAlign {
fn hash<H: Hasher>(&self, state: &mut H) {
(*self as u8).hash(state);
}
}
/// A text layer: a single resizable text box.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TextLayer {
/// Base layer properties (id, name, opacity, visibility, animation data, …).
pub layer: Layer,
/// Top-left of the text box, in layer/clip-local space.
pub box_origin: Point,
/// Box width in document units (drives text wrapping).
pub box_width: f64,
/// Box height in document units.
pub box_height: f64,
/// The text content + styling (single static instance for v1).
pub content: TextContent,
}
impl TextLayer {
/// Default text-box dimensions for a freshly created layer.
pub const DEFAULT_WIDTH: f64 = 300.0;
pub const DEFAULT_HEIGHT: f64 = 100.0;
/// Create a new, empty text layer with the given name, positioned at `origin`.
pub fn new(name: impl Into<String>, origin: Point) -> Self {
Self {
layer: Layer::new(LayerType::Text, name),
box_origin: origin,
box_width: Self::DEFAULT_WIDTH,
box_height: Self::DEFAULT_HEIGHT,
content: TextContent::default(),
}
}
/// Read the active text content at `time`. v1 is static, so `time` is unused;
/// a future keyframed model will select the active keyframe here.
pub fn content_at(&self, _time: f64) -> &TextContent {
&self.content
}
/// Mutable access to the active text content at `time`.
pub fn content_at_mut(&mut self, _time: f64) -> &mut TextContent {
&mut self.content
}
}
// Delegate all LayerTrait methods to self.layer (mirrors RasterLayer).
impl LayerTrait for TextLayer {
fn id(&self) -> Uuid { self.layer.id }
fn name(&self) -> &str { &self.layer.name }
fn set_name(&mut self, name: String) { self.layer.name = name; }
fn has_custom_name(&self) -> bool { self.layer.has_custom_name }
fn set_has_custom_name(&mut self, custom: bool) { self.layer.has_custom_name = custom; }
fn visible(&self) -> bool { self.layer.visible }
fn set_visible(&mut self, visible: bool) { self.layer.visible = visible; }
fn opacity(&self) -> f64 { self.layer.opacity }
fn set_opacity(&mut self, opacity: f64) { self.layer.opacity = opacity; }
fn volume(&self) -> f64 { self.layer.volume }
fn set_volume(&mut self, volume: f64) { self.layer.volume = volume; }
fn muted(&self) -> bool { self.layer.muted }
fn set_muted(&mut self, muted: bool) { self.layer.muted = muted; }
fn soloed(&self) -> bool { self.layer.soloed }
fn set_soloed(&mut self, soloed: bool) { self.layer.soloed = soloed; }
fn locked(&self) -> bool { self.layer.locked }
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
}

View File

@ -381,7 +381,9 @@ impl Tool {
use crate::layer::LayerType;
match layer_type {
None | Some(LayerType::Vector) => Tool::all(),
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
// The Text tool is available on audio/video/raster too: clicking the
// stage with it there creates a new top-level text layer.
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split, Tool::Text],
Some(LayerType::Raster) => &[
// Brush tools
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
@ -397,9 +399,10 @@ impl Tool {
// Transform
Tool::Transform, Tool::Warp, Tool::Liquify,
// Utility
Tool::Eyedropper,
Tool::Eyedropper, Tool::Text,
],
_ => &[Tool::Select],
Some(LayerType::Text) => &[Tool::Select, Tool::Text, Tool::Transform],
_ => &[Tool::Select, Tool::Text],
}
}

View File

@ -2130,7 +2130,7 @@ impl EditorApp {
AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document),
AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document),
AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document),
AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![],
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![],
};
for instance_id in active_layer_clips {
@ -2148,7 +2148,7 @@ impl EditorApp {
AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document),
AnyLayer::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document),
AnyLayer::Effect(el) => find_splittable_clips(&el.clip_instances, split_time, document),
AnyLayer::Group(_) | AnyLayer::Raster(_) => vec![],
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => vec![],
};
if member_splittable.contains(member_instance_id) {
clips_to_split.push((*member_layer_id, *member_instance_id));
@ -2499,7 +2499,7 @@ impl EditorApp {
AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => &[],
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[],
};
let instances: Vec<_> = clip_slice
.iter()
@ -2911,7 +2911,7 @@ impl EditorApp {
AnyLayer::Audio(al) => &al.clip_instances,
AnyLayer::Video(vl) => &vl.clip_instances,
AnyLayer::Effect(el) => &el.clip_instances,
AnyLayer::Group(_) | AnyLayer::Raster(_) => &[],
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[],
};
instances.iter()
.filter(|ci| selection.contains_clip_instance(&ci.id))
@ -3303,7 +3303,7 @@ impl EditorApp {
AnyLayer::Video(_) => hint.has_video = true,
AnyLayer::Audio(_) => hint.has_audio = true,
AnyLayer::Raster(_) => hint.has_raster = true,
AnyLayer::Vector(_) | AnyLayer::Effect(_) => hint.has_vector = true,
AnyLayer::Vector(_) | AnyLayer::Effect(_) | AnyLayer::Text(_) => hint.has_vector = true,
AnyLayer::Group(g) => scan(&g.children, hint),
}
}

View File

@ -1292,6 +1292,9 @@ impl AssetLibraryPane {
lightningbeam_core::layer::AnyLayer::Raster(_) => {
// Raster layers don't have their own clip instances
}
lightningbeam_core::layer::AnyLayer::Text(_) => {
// Text layers don't have their own clip instances
}
}
}
false

View File

@ -46,10 +46,16 @@ pub struct InfopanelPane {
fps_drag_start: Option<f64>,
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
/// Font families already registered into egui for the family-picker previews
/// (rendered each entry in its own font), so we only load each once.
registered_preview_fonts: std::collections::HashSet<String>,
}
impl InfopanelPane {
pub fn new() -> Self {
// Kick off background loading of the font-picker fonts at startup so the dropdown
// is ready without a hitch by the time the user opens it.
lightningbeam_core::fonts::start_preload();
let presets = bundled_brushes();
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
Self {
@ -64,8 +70,26 @@ impl InfopanelPane {
selected_tool_gradient_stop: None,
fps_drag_start: None,
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
registered_preview_fonts: std::collections::HashSet::new(),
}
}
/// Register pre-loaded `bytes` for `family` into egui under `FontFamily::Name(family)`
/// so a label can be rendered in that font. Cheap (no font IO); the add is idempotent
/// and batched into one atlas rebuild at frame end, so the preview appears next frame.
fn register_font_bytes(&mut self, ctx: &egui::Context, family: &str, bytes: Vec<u8>) {
if family.is_empty() || !self.registered_preview_fonts.insert(family.to_string()) {
return;
}
ctx.add_font(egui::epaint::text::FontInsert::new(
family,
egui::FontData::from_owned(bytes),
vec![egui::epaint::text::InsertFontFamily {
family: egui::FontFamily::Name(family.into()),
priority: egui::epaint::text::FontPriority::Highest,
}],
));
}
}
/// Aggregated info about the current DCEL selection
@ -1167,6 +1191,7 @@ impl InfopanelPane {
AnyLayer::Effect(_) => "Effect",
AnyLayer::Group(_) => "Group",
AnyLayer::Raster(_) => "Raster",
AnyLayer::Text(_) => "Text",
};
ui.horizontal(|ui| {
ui.label("Type:");
@ -1247,6 +1272,186 @@ impl InfopanelPane {
});
}
/// Render a text-layer section: edit the text content, font, size, color,
/// alignment, and box size of the active text layer. Driven by the *active*
/// layer so it appears whenever a text layer is selected.
fn render_text_layer_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState, layer_id: Uuid) {
use lightningbeam_core::text_layer::TextAlign;
use lightningbeam_core::actions::{ResizeTextBoxAction, SetTextContentAction};
// Snapshot current values, then drop the document borrow before mutating shared.
let snapshot = {
let document = shared.action_executor.document();
match document.get_layer(&layer_id) {
Some(AnyLayer::Text(tl)) => {
Some((tl.content.clone(), tl.box_origin, tl.box_width, tl.box_height))
}
_ => None,
}
};
let Some((content0, origin, w0, h0)) = snapshot else { return };
// Families egui has actually bound (added fonts take effect next frame). Using an
// unbound `FontFamily::Name` panics, so only preview-render families in this set.
let bound_families: std::collections::HashSet<egui::FontFamily> =
ui.ctx().fonts(|f| f.families().into_iter().collect());
// Set when the font dropdown popup is open this frame (its closure only runs then),
// so we can synchronously finish loading any not-yet-preloaded fonts the user sees.
let mut dropdown_open = false;
egui::CollapsingHeader::new("Text")
.id_salt(("text_layer", path))
.default_open(true)
.show(ui, |ui| {
ui.add_space(4.0);
let mut content = content0.clone();
let mut content_changed = false;
ui.label("Text:");
if ui
.add(egui::TextEdit::multiline(&mut content.text).desired_rows(2).desired_width(f32::INFINITY))
.changed()
{
content_changed = true;
}
ui.horizontal(|ui| {
ui.label("Size:");
if ui
.add(egui::DragValue::new(&mut content.font_size).range(1.0..=2000.0).speed(0.5))
.changed()
{
content_changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Color:");
// content.color is sRGB-encoded RGBA in 0..1 (matches peniko Color::new).
let mut col = egui::Color32::from_rgba_unmultiplied(
(content.color[0] * 255.0).round() as u8,
(content.color[1] * 255.0).round() as u8,
(content.color[2] * 255.0).round() as u8,
(content.color[3] * 255.0).round() as u8,
);
if ui.color_edit_button_srgba(&mut col).changed() {
content.color = [
col.r() as f32 / 255.0,
col.g() as f32 / 255.0,
col.b() as f32 / 255.0,
col.a() as f32 / 255.0,
];
content_changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Align:");
for (label, a) in [("L", TextAlign::Left), ("C", TextAlign::Center), ("R", TextAlign::Right), ("J", TextAlign::Justify)] {
if ui.selectable_label(content.align == a, label).clicked() {
content.align = a;
content_changed = true;
}
}
});
// Font family picker (bundled families first, then system; embedded fonts
// register under their own names on load).
ui.horizontal(|ui| {
ui.label("Font:");
let current_label = if content.font_family.is_empty() {
"(Default)".to_string()
} else {
content.font_family.clone()
};
egui::ComboBox::from_id_salt(("text_font", path))
.selected_text(current_label)
.show_ui(ui, |ui| {
dropdown_open = true;
if ui.selectable_label(content.font_family.is_empty(), "(Default)").clicked() {
content.font_family.clear();
content_changed = true;
}
// The popup closure only runs while the dropdown is open, so each
// entry is rendered in its own font and queued for registration.
for fam in lightningbeam_core::fonts::families() {
let named = egui::FontFamily::Name(fam.as_str().into());
// Render in its own font only once egui has bound it; otherwise
// plain (and queue it for registration so it previews next frame).
let label = if bound_families.contains(&named) {
egui::RichText::new(&fam).family(named)
} else {
egui::RichText::new(&fam)
};
if ui.selectable_label(content.font_family == fam, label).clicked() {
content.font_family = fam.clone();
content_changed = true;
}
}
});
// Missing-font indicator.
if !content.font_family.is_empty()
&& !lightningbeam_core::fonts::family_available(&content.font_family)
{
ui.colored_label(egui::Color32::from_rgb(230, 160, 60), "⚠ missing");
}
});
// Box size.
let mut bw = w0;
let mut bh = h0;
let mut box_changed = false;
ui.horizontal(|ui| {
ui.label("Box:");
if ui.add(egui::DragValue::new(&mut bw).range(1.0..=100000.0).speed(1.0)).changed() {
box_changed = true;
}
ui.label("×");
if ui.add(egui::DragValue::new(&mut bh).range(1.0..=100000.0).speed(1.0)).changed() {
box_changed = true;
}
});
ui.add_space(4.0);
if content_changed {
shared.pending_actions.push(Box::new(SetTextContentAction::new(layer_id, content)));
}
if box_changed {
shared.pending_actions.push(Box::new(ResizeTextBoxAction::new(layer_id, origin, bw, bh)));
}
});
// Register fonts the background preloader has finished (cheap: bytes already loaded),
// a few per frame so each atlas rebuild stays small. Only fall back to synchronous
// loading for families the user is actively viewing in the open dropdown that the
// background hasn't reached yet. Repaint while there's more to do or the preloader
// is still running, so registration keeps flowing without input.
let ctx = ui.ctx().clone();
const PER_FRAME: usize = 4;
let mut budget = PER_FRAME;
let mut more = false;
for fam in lightningbeam_core::fonts::families() {
if self.registered_preview_fonts.contains(&fam) {
continue;
}
let bytes = lightningbeam_core::fonts::take_preloaded(&fam).or_else(|| {
if dropdown_open { lightningbeam_core::fonts::family_font_bytes(&fam) } else { None }
});
if let Some(bytes) = bytes {
if budget == 0 {
more = true;
break;
}
self.register_font_bytes(&ctx, &fam, bytes);
budget -= 1;
}
}
if more || !lightningbeam_core::fonts::preload_done() {
ctx.request_repaint();
}
}
/// Render clip instance info section
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
let document = shared.action_executor.document();
@ -1270,6 +1475,7 @@ impl InfopanelPane {
AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
};
if let Some(ci) = instances.iter().find(|c| c.id == ci_id) {
found = true;
@ -1601,6 +1807,7 @@ impl PaneRenderer for InfopanelPane {
// active (independent of selection focus, since painting doesn't focus the layer).
if let Some(active_id) = *shared.active_layer_id {
self.render_raster_layer_section(ui, path, shared, active_id);
self.render_text_layer_section(ui, path, shared, active_id);
}
// Onion-skinning view settings — always available, regardless of selection.

View File

@ -575,6 +575,9 @@ struct VelloRenderContext {
is_playing: bool,
/// Active layer for tool operations
active_layer_id: Option<uuid::Uuid>,
/// Text layer being edited in place, with the caret + selection as byte offsets
/// into its text (start, end). Drives the Vello-drawn caret/selection.
text_edit: Option<(uuid::Uuid, usize, usize)>,
/// Delta for drag preview (world space)
drag_delta: Option<vello::kurbo::Vec2>,
/// Current selection state
@ -2109,6 +2112,74 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
}
}
// Active text-layer border: the same black/yellow marching-ants outline as
// raster, around the text box bounds (so its size is visible while selected).
if let Some(active_id) = self.ctx.active_layer_id {
if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&active_id) {
let rect = vello::kurbo::Rect::from_origin_size(
tl.box_origin,
(tl.box_width, tl.box_height),
);
let inv_zoom = 1.0 / (self.ctx.zoom as f64).max(1e-3);
let stroke_w = 1.5 * inv_zoom;
let dash = 6.0 * inv_zoom;
let pattern = [dash, dash];
scene.stroke(
&vello::kurbo::Stroke::new(stroke_w).with_dashes(0.0, pattern),
overlay_transform,
vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]),
None,
&rect,
);
scene.stroke(
&vello::kurbo::Stroke::new(stroke_w).with_dashes(dash, pattern),
overlay_transform,
vello::peniko::Color::new([1.0, 0.85, 0.0, 1.0]),
None,
&rect,
);
// 8 resize handles (corners + edge midpoints), constant screen size.
let (ox, oy, bw, bh) = (tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height);
let hs = 4.0 * inv_zoom;
let handle_pts = [
(ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh),
(ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5),
(ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5),
];
for (hx, hy) in handle_pts {
let hr = vello::kurbo::Rect::new(hx - hs, hy - hs, hx + hs, hy + hs);
scene.fill(vello::peniko::Fill::NonZero, overlay_transform,
vello::peniko::Color::new([1.0, 1.0, 1.0, 1.0]), None, &hr);
scene.stroke(&vello::kurbo::Stroke::new(stroke_w), overlay_transform,
vello::peniko::Color::new([0.0, 0.0, 0.0, 1.0]), None, &hr);
}
}
}
// In-place text editing: Vello-drawn selection highlight + caret for the layer
// being edited (egui drives the input; we render the caret ourselves so it tracks
// any clip-instance transform). Byte offsets come from the egui field's cursor.
if let Some((edit_id, sel_start, sel_end)) = self.ctx.text_edit {
if let Some(lightningbeam_core::layer::AnyLayer::Text(tl)) = self.ctx.document.get_layer(&edit_id) {
use vello::peniko::{Color, Fill};
let box_xf = overlay_transform * vello::kurbo::Affine::translate((tl.box_origin.x, tl.box_origin.y));
let content = tl.content_at(self.ctx.playback_time);
let max_w = tl.box_width as f32;
// Selection highlight (behind would be ideal; semi-transparent so glyphs show through).
let (lo, hi) = (sel_start.min(sel_end), sel_start.max(sel_end));
for (x0, y0, x1, y1) in lightningbeam_core::fonts::selection_geometry(content, max_w, lo, hi) {
let r = vello::kurbo::Rect::new(x0, y0, x1, y1);
scene.fill(Fill::NonZero, box_xf, Color::new([0.30, 0.55, 1.0, 0.35]), None, &r);
}
// Caret at the primary cursor (sel_end).
if let Some((x0, y0, x1, y1)) = lightningbeam_core::fonts::caret_geometry(content, max_w, sel_end) {
let r = vello::kurbo::Rect::new(x0, y0, x1.max(x0 + 1.0), y1);
scene.fill(Fill::NonZero, box_xf, Color::new([0.05, 0.05, 0.05, 1.0]), None, &r);
}
}
}
// Render drag preview objects with transparency
if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) {
if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) {
@ -3267,6 +3338,28 @@ pub struct StagePane {
/// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>,
// --- Text layer in-place editing ---
/// The text layer currently being edited in place (None = not editing).
text_edit_layer: Option<uuid::Uuid>,
/// Working buffer mirrored to/from the hidden egui TextEdit while editing.
text_edit_buffer: String,
/// Set the frame editing begins so the egui field grabs focus exactly once.
text_edit_request_focus: bool,
/// Caret + selection as BYTE offsets into the text (anchor, focus), for the Vello caret.
text_edit_cursor: Option<(usize, usize)>,
/// The text content captured when editing began, for a single clean undo step.
text_edit_original: Option<lightningbeam_core::text_layer::TextContent>,
/// True when the current edit is of a freshly-created layer whose creation is still the
/// top undo entry — so committing it empty can remove it by rolling that creation back.
/// Cleared if another action (e.g. a resize) is pushed during the edit.
text_edit_is_new: bool,
/// If the current edit created a new clip (vector branch), its id — so an empty commit
/// also exits that clip after removing it.
text_edit_created_clip: Option<uuid::Uuid>,
/// Active drag of a text box resize handle:
/// (layer_id, handle_index, start_origin_x, start_origin_y, start_w, start_h).
text_box_drag: Option<(uuid::Uuid, usize, f64, f64, f64, f64)>,
}
/// Synthetic drag/click state injected during test mode replay
@ -3620,6 +3713,27 @@ fn selection_stipple_brush() -> &'static vello::peniko::ImageBrush {
})
}
/// Convert a character index (egui caret) to a byte offset (parley/`str`).
fn char_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices().nth(char_idx).map(|(b, _)| b).unwrap_or(s.len())
}
/// Diagonal resize cursor for an axis-aligned box corner (0=TL, 1=TR, 2=BR, 3=BL).
fn corner_resize_cursor(corner: usize) -> egui::CursorIcon {
match corner % 4 {
0 | 2 => egui::CursorIcon::ResizeNwSe, // TL / BR
_ => egui::CursorIcon::ResizeNeSw, // TR / BL
}
}
/// Axis resize cursor for an axis-aligned box edge (0=Top, 1=Right, 2=Bottom, 3=Left).
fn edge_resize_cursor(edge: usize) -> egui::CursorIcon {
match edge % 4 {
0 | 2 => egui::CursorIcon::ResizeVertical, // Top / Bottom
_ => egui::CursorIcon::ResizeHorizontal, // Right / Left
}
}
impl StagePane {
pub fn new() -> Self {
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@ -3661,6 +3775,14 @@ impl StagePane {
pending_tool_readback_b: None,
#[cfg(debug_assertions)]
replay_override: None,
text_edit_layer: None,
text_edit_buffer: String::new(),
text_edit_request_focus: false,
text_edit_cursor: None,
text_edit_original: None,
text_edit_is_new: false,
text_edit_created_clip: None,
text_box_drag: None,
}
}
@ -4836,6 +4958,364 @@ impl StagePane {
}
}
/// Text tool: click the stage to create a text layer.
/// - Active layer is Vector → create a clip (VectorClip + instance) containing a
/// text layer, then enter that clip so the text is directly editable.
/// - Otherwise (raster/video/none) → create a new top-level text layer.
fn handle_text_tool(
&mut self,
ui: &mut egui::Ui,
response: &egui::Response,
world_pos: egui::Vec2,
shared: &mut SharedPaneState,
) {
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::text_layer::{TextLayer, TextContent};
use lightningbeam_core::actions::{AddLayerAction, CreateTextClipAction};
use vello::kurbo::Point;
// Allow dragging the active text layer's resize handles with the Text tool too
// (runs on drag events, so it must precede the click-only guard below).
if self.handle_text_box_resize(ui, response, world_pos, shared) {
return;
}
// Only act on a click (ignore drags / hovers).
if !self.rsp_clicked(response) {
return;
}
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
// Click on an existing text box → edit it (don't spawn a duplicate).
if let Some(hit_id) = self.text_layer_at(point, shared) {
if self.text_edit_layer != Some(hit_id) {
if let Some(cur) = self.text_edit_layer {
self.commit_text_edit(shared, cur);
}
*shared.active_layer_id = Some(hit_id);
self.begin_text_edit(hit_id);
}
return;
}
// Empty space → commit any in-progress edit, then create a new text layer.
if let Some(cur) = self.text_edit_layer {
self.commit_text_edit(shared, cur);
}
let fc = *shared.fill_color;
let color = [
fc.r() as f32 / 255.0,
fc.g() as f32 / 255.0,
fc.b() as f32 / 255.0,
fc.a() as f32 / 255.0,
];
let is_vector = shared
.active_layer_id
.and_then(|id| shared.action_executor.document().get_layer(&id))
.map_or(false, |l| matches!(l, AnyLayer::Vector(_)));
if is_vector {
let parent_id = shared.active_layer_id.unwrap();
// The text layer sits at the clip's origin; the instance is placed at the click.
let mut tl = TextLayer::new("Text", Point::ZERO);
tl.content = TextContent { color, ..TextContent::default() };
let text_layer_id = tl.layer.id;
let clip_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4();
let action = CreateTextClipAction::new(parent_id, tl, (point.x, point.y))
.with_ids(clip_id, instance_id);
if shared.action_executor.execute(Box::new(action)).is_ok() {
// Enter the new clip; main.rs activates the clip's first layer (the text layer).
*shared.pending_enter_clip = Some((clip_id, instance_id, parent_id));
self.begin_text_edit(text_layer_id);
self.text_edit_is_new = true;
self.text_edit_created_clip = Some(clip_id);
}
} else {
// Raster / video / group / no layer → new top-level text layer, created in
// the current editing context (root, or the clip currently being edited).
let mut tl = TextLayer::new("Text", point);
tl.content = TextContent { color, ..TextContent::default() };
let text_layer_id = tl.layer.id;
let action = AddLayerAction::new(AnyLayer::Text(tl))
.with_target_clip(shared.editing_clip_id);
if shared.action_executor.execute(Box::new(action)).is_ok() {
*shared.active_layer_id = Some(text_layer_id);
self.begin_text_edit(text_layer_id);
self.text_edit_is_new = true;
}
}
}
/// Handle dragging a text box resize handle. Returns true if the interaction was
/// consumed (so the normal select/create logic should be skipped this frame).
/// `world_pos` is in the active layer's local space (clip-local when editing a clip),
/// which matches the box coordinates, so hit-testing is axis-aligned here.
/// Index (0..8) of the text-box resize handle under `(mx,my)`, if any.
/// 0=TL 1=TR 2=BR 3=BL (corners), 4=Top 5=Right 6=Bottom 7=Left (edges).
fn text_handle_at(ox: f64, oy: f64, bw: f64, bh: f64, mx: f64, my: f64, tol: f64) -> Option<usize> {
let pts = [
(ox, oy), (ox + bw, oy), (ox + bw, oy + bh), (ox, oy + bh),
(ox + bw * 0.5, oy), (ox + bw, oy + bh * 0.5),
(ox + bw * 0.5, oy + bh), (ox, oy + bh * 0.5),
];
pts.iter().position(|(hx, hy)| (hx - mx).abs() <= tol && (hy - my).abs() <= tol)
}
/// Resize cursor for a text-box handle index (reuses the shared corner/edge mapping).
fn text_handle_cursor(idx: usize) -> egui::CursorIcon {
if idx < 4 { corner_resize_cursor(idx) } else { edge_resize_cursor(idx - 4) }
}
fn handle_text_box_resize(
&mut self,
ui: &egui::Ui,
response: &egui::Response,
world_pos: egui::Vec2,
shared: &mut SharedPaneState,
) -> bool {
use lightningbeam_core::layer::AnyLayer;
let Some(layer_id) = *shared.active_layer_id else { return false };
let Some((ox, oy, bw, bh)) = (match shared.action_executor.document().get_layer(&layer_id) {
Some(AnyLayer::Text(tl)) => Some((tl.box_origin.x, tl.box_origin.y, tl.box_width, tl.box_height)),
_ => None,
}) else { return false };
let (mx, my) = (world_pos.x as f64, world_pos.y as f64);
let tol = 6.0 / (self.zoom as f64).max(1e-3);
// Hover feedback: show a resize cursor over a handle when not already dragging.
if self.text_box_drag.is_none() {
if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) {
ui.ctx().set_cursor_icon(Self::text_handle_cursor(idx));
}
}
// Begin a handle drag: hit-test the 8 handles.
if self.rsp_drag_started(response) && self.text_box_drag.is_none() {
if let Some(idx) = Self::text_handle_at(ox, oy, bw, bh, mx, my, tol) {
self.text_box_drag = Some((layer_id, idx, ox, oy, bw, bh));
return true;
}
}
let Some((drag_id, idx, sox, soy, sbw, sbh)) = self.text_box_drag else { return false };
if drag_id != layer_id { return false; }
if self.rsp_dragged(response) {
let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my);
// Live preview (no undo entry).
let doc = shared.action_executor.document_mut();
if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) {
tl.box_origin = no;
tl.box_width = nw;
tl.box_height = nh;
}
return true;
}
if self.rsp_drag_stopped(response) {
let (no, nw, nh) = Self::resize_text_box(idx, sox, soy, sbw, sbh, mx, my);
// Restore the start box, then record one undoable resize.
{
let doc = shared.action_executor.document_mut();
if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) {
tl.box_origin = vello::kurbo::Point::new(sox, soy);
tl.box_width = sbw;
tl.box_height = sbh;
}
}
let action = lightningbeam_core::actions::ResizeTextBoxAction::new(layer_id, no, nw, nh);
let _ = shared.action_executor.execute(Box::new(action));
self.text_box_drag = None;
// A resize is now the top undo entry, so the creation can no longer be
// rolled back by an empty-commit; keep the layer even if left empty.
self.text_edit_is_new = false;
return true;
}
true
}
/// Compute a new (origin, width, height) for a text box given a dragged handle.
/// Handles: 0=TL 1=TR 2=BR 3=BL (corners), 4=T 5=R 6=B 7=L (edges).
fn resize_text_box(idx: usize, ox: f64, oy: f64, w: f64, h: f64, mx: f64, my: f64)
-> (vello::kurbo::Point, f64, f64)
{
const MIN: f64 = 8.0;
let (mut l, mut r, mut t, mut b) = (ox, ox + w, oy, oy + h);
match idx {
0 => { l = mx; t = my; }
1 => { r = mx; t = my; }
2 => { r = mx; b = my; }
3 => { l = mx; b = my; }
4 => { t = my; }
5 => { r = mx; }
6 => { b = my; }
7 => { l = mx; }
_ => {}
}
if r - l < MIN {
if matches!(idx, 0 | 3 | 7) { l = r - MIN; } else { r = l + MIN; }
}
if b - t < MIN {
if matches!(idx, 0 | 1 | 4) { t = b - MIN; } else { b = t + MIN; }
}
(vello::kurbo::Point::new(l, t), r - l, b - t)
}
/// Begin in-place editing of an existing text layer (not a fresh creation).
fn begin_text_edit(&mut self, layer_id: uuid::Uuid) {
self.text_edit_layer = Some(layer_id);
self.text_edit_buffer.clear();
self.text_edit_request_focus = true;
self.text_edit_cursor = None;
self.text_edit_original = None; // captured on the first update frame
self.text_edit_is_new = false;
self.text_edit_created_clip = None;
}
/// Clear all in-place text editing state.
fn finish_text_edit_state(&mut self) {
self.text_edit_layer = None;
self.text_edit_buffer.clear();
self.text_edit_cursor = None;
self.text_edit_original = None;
self.text_edit_request_focus = false;
self.text_edit_is_new = false;
self.text_edit_created_clip = None;
}
/// Commit the current in-place edit as a single undoable step.
fn commit_text_edit(&mut self, shared: &mut SharedPaneState, layer_id: uuid::Uuid) {
use lightningbeam_core::actions::SetTextContentAction;
// A freshly-created layer left empty is removed by rolling back its creation
// (the top undo entry), which cleanly reverses both the root and clip branches.
if self.text_edit_is_new && self.text_edit_buffer.trim().is_empty() {
let _ = shared.action_executor.undo();
if *shared.active_layer_id == Some(layer_id) {
*shared.active_layer_id = None;
}
// If creating it entered a new clip, leave that clip (it no longer exists).
if self.text_edit_created_clip.is_some() {
*shared.pending_exit_clip = true;
}
self.finish_text_edit_state();
return;
}
if let Some(orig) = self.text_edit_original.take() {
let mut final_content = orig.clone();
final_content.text = self.text_edit_buffer.clone();
if final_content != orig {
// The document already holds `final_content` (live preview); with_old
// records the pre-edit value so undo restores it in one step.
let action = SetTextContentAction::with_old(layer_id, orig, final_content);
let _ = shared.action_executor.execute(Box::new(action));
}
}
self.finish_text_edit_state();
}
/// Find the topmost text layer in the current editing context whose box contains
/// `point` (in the context's local space, which matches `world_pos`).
fn text_layer_at(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> Option<uuid::Uuid> {
use lightningbeam_core::layer::AnyLayer;
let doc = shared.action_executor.document();
let layers = doc.context_layers(shared.editing_clip_id.as_ref());
for layer in layers.into_iter().rev() {
if let AnyLayer::Text(tl) = layer {
let r = vello::kurbo::Rect::from_origin_size(
tl.box_origin, (tl.box_width, tl.box_height));
if r.contains(point) {
return Some(tl.layer.id);
}
}
}
None
}
/// Drive the hybrid in-place text editor: a hidden, focused egui `TextEdit` captures
/// keystrokes/IME and the caret position; the text + caret are rendered in Vello.
/// The field is parked far offscreen (`constrain(false)` so egui doesn't clamp it back
/// on-screen) — invisible and not intercepting pointer events over the text box.
fn update_text_editing(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) {
use lightningbeam_core::layer::AnyLayer;
let Some(layer_id) = self.text_edit_layer else { return };
// Current text, or bail (and clear) if the layer is gone / not text anymore.
let cur_text = {
let doc = shared.action_executor.document();
match doc.get_layer(&layer_id) {
Some(AnyLayer::Text(tl)) => {
if self.text_edit_original.is_none() {
self.text_edit_original = Some(tl.content.clone());
self.text_edit_buffer = tl.content.text.clone();
}
tl.content.text.clone()
}
_ => { self.finish_text_edit_state(); return; }
}
};
// Escape cancels: restore the pre-edit content and exit.
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
if let Some(orig) = self.text_edit_original.take() {
let doc = shared.action_executor.document_mut();
if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) {
tl.content = orig;
}
}
self.finish_text_edit_state();
return;
}
// Hidden, focused TextEdit parked offscreen (constrain(false) keeps egui from
// clamping it back into view). Captures keyboard/IME + caret only.
let te_id = egui::Id::new(("lb_text_edit", layer_id));
let output = egui::Area::new(egui::Id::new(("lb_text_edit_area", layer_id)))
.constrain(false)
.fixed_pos(egui::pos2(-100_000.0, -100_000.0))
.show(ui.ctx(), |ui| {
egui::TextEdit::multiline(&mut self.text_edit_buffer)
.id(te_id)
.desired_width(1.0)
.show(ui)
})
.inner;
// Acquire and hold keyboard focus; once acquired, losing it commits the edit.
if self.text_edit_request_focus {
if output.response.has_focus() {
self.text_edit_request_focus = false;
} else {
output.response.request_focus();
}
} else if !output.response.has_focus() {
self.commit_text_edit(shared, layer_id);
return;
}
// Caret/selection → byte offsets for the Vello caret.
if let Some(cr) = output.cursor_range {
let anchor = char_to_byte(&self.text_edit_buffer, cr.secondary.index);
let focus = char_to_byte(&self.text_edit_buffer, cr.primary.index);
self.text_edit_cursor = Some((anchor, focus));
}
// Live preview: push the buffer into the document text (no undo entry).
if self.text_edit_buffer != cur_text {
let doc = shared.action_executor.document_mut();
if let Some(AnyLayer::Text(tl)) = doc.get_layer_mut(&layer_id) {
tl.content.text = self.text_edit_buffer.clone();
}
}
}
fn handle_ellipse_tool(
&mut self,
ui: &mut egui::Ui,
@ -9928,29 +10408,17 @@ impl StagePane {
// Check corner handles with correct diagonal cursors
for (idx, corner) in world_corners.iter().enumerate() {
if point.distance(*corner) < tolerance {
// Top-left (0) and bottom-right (2): NW-SE diagonal (\)
// Top-right (1) and bottom-left (3): NE-SW diagonal (/)
let cursor = match idx {
0 | 2 => egui::CursorIcon::ResizeNwSe, // Top-left, Bottom-right
1 | 3 => egui::CursorIcon::ResizeNeSw, // Top-right, Bottom-left
_ => egui::CursorIcon::Default,
};
ui.ctx().set_cursor_icon(cursor);
ui.ctx().set_cursor_icon(corner_resize_cursor(idx));
hovering_handle = true;
break;
}
}
// Check edge handles
// Check edge handles (midpoints are ordered top, right, bottom, left)
if !hovering_handle {
for (idx, edge_pos) in edge_midpoints.iter().enumerate() {
if point.distance(*edge_pos) < tolerance {
let cursor = match idx {
0 | 2 => egui::CursorIcon::ResizeVertical, // Top/Bottom
1 | 3 => egui::CursorIcon::ResizeHorizontal, // Right/Left
_ => egui::CursorIcon::Default,
};
ui.ctx().set_cursor_icon(cursor);
ui.ctx().set_cursor_icon(edge_resize_cursor(idx));
hovering_handle = true;
break;
}
@ -10863,10 +11331,19 @@ impl StagePane {
match *shared.selected_tool {
Tool::Select => {
let is_raster = shared.active_layer_id.and_then(|id| {
let active = shared.active_layer_id.and_then(|id| {
shared.action_executor.document().get_layer(&id)
}).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_)));
if is_raster {
});
let is_raster = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Raster(_)));
let is_text = matches!(active, Some(lightningbeam_core::layer::AnyLayer::Text(_)));
if is_text && self.handle_text_box_resize(ui, &response, world_pos, shared) {
// Consumed by a resize-handle drag.
} else if is_text && response.double_clicked() {
// Double-click a selected text layer to edit it in place.
if let Some(id) = *shared.active_layer_id {
self.begin_text_edit(id);
}
} else if is_raster {
self.handle_raster_select_tool(ui, &response, world_pos, shared);
} else {
self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
@ -10932,6 +11409,9 @@ impl StagePane {
Tool::Gradient => {
self.handle_raster_gradient_tool(ui, &response, world_pos, shared);
}
Tool::Text => {
self.handle_text_tool(ui, &response, world_pos, shared);
}
_ => {
// Other tools not implemented yet
}
@ -11765,6 +12245,9 @@ impl PaneRenderer for StagePane {
// Handle input for pan/zoom and tool controls
self.handle_input(ui, rect, shared);
// Drive the in-place text editor (hidden egui field → Vello caret).
self.update_text_editing(ui, shared);
// Handle asset drag-and-drop from Asset Library
if let Some(dragging) = shared.dragging_asset.clone() {
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
@ -12143,6 +12626,9 @@ impl PaneRenderer for StagePane {
container_path: shared.container_path.clone(),
is_playing: *shared.is_playing,
active_layer_id: *shared.active_layer_id,
text_edit: self.text_edit_layer.and_then(|id| {
self.text_edit_cursor.map(|(s, e)| (id, s, e))
}),
drag_delta,
selection: shared.selection.clone(),
fill_color: *shared.fill_color,

View File

@ -212,6 +212,7 @@ fn effective_clip_duration(
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
AnyLayer::Group(_) => None,
AnyLayer::Raster(_) => None,
AnyLayer::Text(_) => None,
}
}
@ -685,6 +686,7 @@ fn layer_clips<'a>(
AnyLayer::Effect(l) => &l.clip_instances,
AnyLayer::Group(_) => &[],
AnyLayer::Raster(_) => &[],
AnyLayer::Text(_) => &[],
}
}
@ -718,6 +720,7 @@ fn collect_clip_instances<'a>(
}
}
AnyLayer::Raster(_) => {}
AnyLayer::Text(_) => {}
}
}
@ -768,6 +771,7 @@ fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) {
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
AnyLayer::Text(_) => ("Text", egui::Color32::from_rgb(220, 200, 120)),
}
}

View File

@ -43,6 +43,7 @@ impl PaneRenderer for ToolbarPane {
AnyLayer::Effect(_) => LayerType::Effect,
AnyLayer::Group(_) => LayerType::Group,
AnyLayer::Raster(_) => LayerType::Raster,
AnyLayer::Text(_) => LayerType::Text,
});
// Auto-switch to Select if the current tool isn't available for this layer type