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:
parent
58a3c829d7
commit
fd582828c2
|
|
@ -15,3 +15,8 @@
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
|
|
@ -24,6 +24,11 @@ vello = { git = "https://github.com/linebender/vello", branch = "main" }
|
||||||
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
|
wgpu = { version = "27", features = ["vulkan", "metal", "gles"] }
|
||||||
kurbo = { version = "0.12", features = ["serde"] }
|
kurbo = { version = "0.12", features = ["serde"] }
|
||||||
peniko = "0.5"
|
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
|
# Windowing
|
||||||
winit = "0.30"
|
winit = "0.30"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ bytemuck = { version = "1.14", features = ["derive"] }
|
||||||
kurbo = { workspace = true }
|
kurbo = { workspace = true }
|
||||||
vello = { workspace = true }
|
vello = { workspace = true }
|
||||||
|
|
||||||
|
# Text layout/shaping (text layers)
|
||||||
|
parley = { workspace = true }
|
||||||
|
|
||||||
# Image decoding for image fills
|
# Image decoding for image fills
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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).
|
||||||
|
|
@ -104,7 +104,7 @@ impl Action for AddClipInstanceAction {
|
||||||
AnyLayer::Group(_) => {
|
AnyLayer::Group(_) => {
|
||||||
return Err("Cannot add clip instances directly to group layers".to_string());
|
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());
|
return Err("Cannot add clip instances directly to group layers".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,8 +145,8 @@ impl Action for AddClipInstanceAction {
|
||||||
AnyLayer::Group(_) => {
|
AnyLayer::Group(_) => {
|
||||||
// Group layers don't have clip instances, nothing to rollback
|
// Group layers don't have clip instances, nothing to rollback
|
||||||
}
|
}
|
||||||
AnyLayer::Raster(_) => {
|
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
|
||||||
// Raster layers don't have clip instances, nothing to rollback
|
// Raster/text layers don't have clip instances, nothing to rollback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.executed = false;
|
self.executed = false;
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ impl Action for AddLayerAction {
|
||||||
AnyLayer::Effect(_) => "Add effect layer",
|
AnyLayer::Effect(_) => "Add effect layer",
|
||||||
AnyLayer::Group(_) => "Add group layer",
|
AnyLayer::Group(_) => "Add group layer",
|
||||||
AnyLayer::Raster(_) => "Add raster layer",
|
AnyLayer::Raster(_) => "Add raster layer",
|
||||||
|
AnyLayer::Text(_) => "Add text layer",
|
||||||
}
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ impl Action for LoopClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
|
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::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {
|
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ pub mod resize_raster_layer;
|
||||||
pub mod move_layer;
|
pub mod move_layer;
|
||||||
pub mod set_fill_paint;
|
pub mod set_fill_paint;
|
||||||
pub mod set_image_fill;
|
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_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -84,3 +87,6 @@ pub use set_fill_paint::SetFillPaintAction;
|
||||||
pub use set_image_fill::SetImageFillAction;
|
pub use set_image_fill::SetImageFillAction;
|
||||||
pub use change_bpm::ChangeBpmAction;
|
pub use change_bpm::ChangeBpmAction;
|
||||||
pub use change_fps::ChangeFpsAction;
|
pub use change_fps::ChangeFpsAction;
|
||||||
|
pub use create_text_clip::CreateTextClipAction;
|
||||||
|
pub use set_text_content::SetTextContentAction;
|
||||||
|
pub use resize_text_box::ResizeTextBoxAction;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
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::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
|
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::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update timeline_start for each clip instance
|
// Update timeline_start for each clip instance
|
||||||
|
|
@ -159,6 +162,7 @@ impl Action for MoveClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restore original timeline_start for each clip instance
|
// Restore original timeline_start for each clip instance
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ impl Action for RemoveClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find and remove the instance, saving it for rollback
|
// 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::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
clip_instances.push(instance);
|
clip_instances.push(instance);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -114,6 +114,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()),
|
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::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
|
let instance = clip_instances
|
||||||
|
|
@ -233,7 +234,7 @@ impl Action for SplitClipInstanceAction {
|
||||||
AnyLayer::Group(_) => {
|
AnyLayer::Group(_) => {
|
||||||
return Err("Cannot split clip instances on group layers".to_string());
|
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());
|
return Err("Cannot split clip instances on group layers".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -294,8 +295,8 @@ impl Action for SplitClipInstanceAction {
|
||||||
AnyLayer::Group(_) => {
|
AnyLayer::Group(_) => {
|
||||||
// Group layers don't have clip instances, nothing to rollback
|
// Group layers don't have clip instances, nothing to rollback
|
||||||
}
|
}
|
||||||
AnyLayer::Raster(_) => {
|
AnyLayer::Raster(_) | AnyLayer::Text(_) => {
|
||||||
// Raster layers don't have clip instances, nothing to rollback
|
// Raster/text layers don't have clip instances, nothing to rollback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ impl Action for TransformClipInstancesAction {
|
||||||
AnyLayer::Effect(_) => {}
|
AnyLayer::Effect(_) => {}
|
||||||
AnyLayer::Group(_) => {}
|
AnyLayer::Group(_) => {}
|
||||||
AnyLayer::Raster(_) => {}
|
AnyLayer::Raster(_) => {}
|
||||||
|
AnyLayer::Text(_) => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -140,6 +141,7 @@ impl Action for TransformClipInstancesAction {
|
||||||
AnyLayer::Effect(_) => {}
|
AnyLayer::Effect(_) => {}
|
||||||
AnyLayer::Group(_) => {}
|
AnyLayer::Group(_) => {}
|
||||||
AnyLayer::Raster(_) => {}
|
AnyLayer::Raster(_) => {}
|
||||||
|
AnyLayer::Text(_) => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
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::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
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::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance = clip_instances.iter()
|
let instance = clip_instances.iter()
|
||||||
|
|
@ -275,6 +278,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply trims
|
// Apply trims
|
||||||
|
|
@ -315,6 +319,7 @@ impl Action for TrimClipInstancesAction {
|
||||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||||
AnyLayer::Group(_) => continue,
|
AnyLayer::Group(_) => continue,
|
||||||
AnyLayer::Raster(_) => continue,
|
AnyLayer::Raster(_) => continue,
|
||||||
|
AnyLayer::Text(_) => continue,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restore original trim values
|
// Restore original trim values
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ pub enum MediaKind {
|
||||||
/// the keyframe id). Decoded eagerly on load and shown while the full-res pixels
|
/// 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`.
|
/// page in, so cold scrubs don't flash blank. See `raster_proxy_media_id`.
|
||||||
RasterProxy = 6,
|
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 {
|
impl MediaKind {
|
||||||
|
|
@ -74,6 +78,7 @@ impl MediaKind {
|
||||||
4 => Some(Self::Waveform),
|
4 => Some(Self::Waveform),
|
||||||
5 => Some(Self::Thumbnail),
|
5 => Some(Self::Thumbnail),
|
||||||
6 => Some(Self::RasterProxy),
|
6 => Some(Self::RasterProxy),
|
||||||
|
7 => Some(Self::Font),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,7 @@ impl VectorClip {
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
for ci in clip_instances {
|
for ci in clip_instances {
|
||||||
// Compute end position of this clip instance in beats
|
// Compute end position of this clip instance in beats
|
||||||
|
|
@ -223,6 +224,17 @@ impl VectorClip {
|
||||||
Some(existing) => existing.union(transformed_bounds),
|
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),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ impl ClipboardLayerType {
|
||||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
||||||
AnyLayer::Group(_) => ClipboardLayerType::Vector,
|
AnyLayer::Group(_) => ClipboardLayerType::Vector,
|
||||||
AnyLayer::Raster(_) => 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;
|
nl.layer.id = new_layer_id;
|
||||||
AnyLayer::Raster(nl)
|
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) => {
|
AnyLayer::Group(gl) => {
|
||||||
let new_layer_id = Uuid::new_v4();
|
let new_layer_id = Uuid::new_v4();
|
||||||
id_map.insert(gl.layer.id, new_layer_id);
|
id_map.insert(gl.layer.id, new_layer_id);
|
||||||
|
|
|
||||||
|
|
@ -489,8 +489,8 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::layer::AnyLayer::Raster(_) => {
|
crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
|
||||||
// Raster layers don't have clip instances
|
// Raster and text layers don't have clip instances
|
||||||
}
|
}
|
||||||
crate::layer::AnyLayer::Group(group) => {
|
crate::layer::AnyLayer::Group(group) => {
|
||||||
// Recurse into group children to find their clip instance endpoints
|
// Recurse into group children to find their clip instance endpoints
|
||||||
|
|
@ -530,8 +530,8 @@ impl Document {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crate::layer::AnyLayer::Raster(_) => {
|
crate::layer::AnyLayer::Raster(_) | crate::layer::AnyLayer::Text(_) => {
|
||||||
// Raster layers don't have clip instances
|
// Raster and text layers don't have clip instances
|
||||||
}
|
}
|
||||||
crate::layer::AnyLayer::Group(g) => {
|
crate::layer::AnyLayer::Group(g) => {
|
||||||
process_group_children(&g.children, doc, max_end, calc_end);
|
process_group_children(&g.children, doc, max_end, calc_end);
|
||||||
|
|
@ -937,6 +937,7 @@ impl Document {
|
||||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||||
|
|
@ -977,6 +978,7 @@ impl Document {
|
||||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
for instance in instances {
|
for instance in instances {
|
||||||
|
|
@ -1039,6 +1041,7 @@ impl Document {
|
||||||
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
|
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
|
||||||
AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips
|
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::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();
|
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
|
||||||
|
|
@ -1134,6 +1137,7 @@ impl Document {
|
||||||
AnyLayer::Vector(v) => &v.clip_instances,
|
AnyLayer::Vector(v) => &v.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collect non-group clip ranges
|
// Collect non-group clip ranges
|
||||||
|
|
@ -1205,6 +1209,7 @@ impl Document {
|
||||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
for other in instances {
|
for other in instances {
|
||||||
|
|
@ -1253,6 +1258,7 @@ impl Document {
|
||||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut nearest_start = f64::MAX;
|
let mut nearest_start = f64::MAX;
|
||||||
|
|
@ -1300,6 +1306,7 @@ impl Document {
|
||||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut nearest_end = 0.0;
|
let mut nearest_end = 0.0;
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,21 @@ fn thumbnail_media_id(clip_id: Uuid) -> Uuid {
|
||||||
Uuid::from_u128(clip_id.as_u128() ^ SENTINEL)
|
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
|
/// 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).
|
/// own full-res `Raster` row, which is keyed by the raw keyframe id).
|
||||||
fn raster_proxy_media_id(kf_id: Uuid) -> Uuid {
|
fn raster_proxy_media_id(kf_id: Uuid) -> Uuid {
|
||||||
|
|
@ -535,6 +550,32 @@ pub fn save_beam(
|
||||||
}
|
}
|
||||||
let _ = image_count;
|
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 ---
|
// --- orphan cleanup: drop media for removed clips/keyframes ---
|
||||||
let removed = txn.retain_media(&live_media)?;
|
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)...");
|
eprintln!("📊 [LOAD_BEAM] Starting load_beam() (SQLite container)...");
|
||||||
|
|
||||||
let archive = BeamArchive::open(path)?;
|
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 json = archive.get_project_json()?;
|
||||||
let beam_project: BeamProject = serde_json::from_str(&json)
|
let beam_project: BeamProject = serde_json::from_str(&json)
|
||||||
.map_err(|e| format!("Failed to deserialize project.json: {}", e))?;
|
.map_err(|e| format!("Failed to deserialize project.json: {}", e))?;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ use crate::vector_graph::VectorGraph;
|
||||||
use crate::effect_layer::EffectLayer;
|
use crate::effect_layer::EffectLayer;
|
||||||
use crate::object::ShapeInstance;
|
use crate::object::ShapeInstance;
|
||||||
use crate::raster_layer::RasterLayer;
|
use crate::raster_layer::RasterLayer;
|
||||||
|
use crate::text_layer::TextLayer;
|
||||||
use crate::shape::Shape;
|
use crate::shape::Shape;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -31,6 +32,8 @@ pub enum LayerType {
|
||||||
Group,
|
Group,
|
||||||
/// Raster pixel-buffer painting layer
|
/// Raster pixel-buffer painting layer
|
||||||
Raster,
|
Raster,
|
||||||
|
/// Text layer (a single editable text box)
|
||||||
|
Text,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Common trait for all layer types
|
/// Common trait for all layer types
|
||||||
|
|
@ -866,6 +869,7 @@ impl GroupLayer {
|
||||||
AnyLayer::Effect(l) => &l.clip_instances,
|
AnyLayer::Effect(l) => &l.clip_instances,
|
||||||
AnyLayer::Group(_) => &[], // no nested groups
|
AnyLayer::Group(_) => &[], // no nested groups
|
||||||
AnyLayer::Raster(_) => &[], // raster layers have no clip instances
|
AnyLayer::Raster(_) => &[], // raster layers have no clip instances
|
||||||
|
AnyLayer::Text(_) => &[], // raster layers have no clip instances
|
||||||
};
|
};
|
||||||
for ci in instances {
|
for ci in instances {
|
||||||
result.push((child_id, ci));
|
result.push((child_id, ci));
|
||||||
|
|
@ -884,6 +888,7 @@ pub enum AnyLayer {
|
||||||
Effect(EffectLayer),
|
Effect(EffectLayer),
|
||||||
Group(GroupLayer),
|
Group(GroupLayer),
|
||||||
Raster(RasterLayer),
|
Raster(RasterLayer),
|
||||||
|
Text(TextLayer),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LayerTrait for AnyLayer {
|
impl LayerTrait for AnyLayer {
|
||||||
|
|
@ -895,6 +900,7 @@ impl LayerTrait for AnyLayer {
|
||||||
AnyLayer::Effect(l) => l.id(),
|
AnyLayer::Effect(l) => l.id(),
|
||||||
AnyLayer::Group(l) => l.id(),
|
AnyLayer::Group(l) => l.id(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.name(),
|
||||||
AnyLayer::Group(l) => l.name(),
|
AnyLayer::Group(l) => l.name(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_name(name),
|
||||||
AnyLayer::Group(l) => l.set_name(name),
|
AnyLayer::Group(l) => l.set_name(name),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.has_custom_name(),
|
||||||
AnyLayer::Group(l) => l.has_custom_name(),
|
AnyLayer::Group(l) => l.has_custom_name(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_has_custom_name(custom),
|
||||||
AnyLayer::Group(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::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::Effect(l) => l.visible(),
|
||||||
AnyLayer::Group(l) => l.visible(),
|
AnyLayer::Group(l) => l.visible(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_visible(visible),
|
||||||
AnyLayer::Group(l) => l.set_visible(visible),
|
AnyLayer::Group(l) => l.set_visible(visible),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.opacity(),
|
||||||
AnyLayer::Group(l) => l.opacity(),
|
AnyLayer::Group(l) => l.opacity(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_opacity(opacity),
|
||||||
AnyLayer::Group(l) => l.set_opacity(opacity),
|
AnyLayer::Group(l) => l.set_opacity(opacity),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.volume(),
|
||||||
AnyLayer::Group(l) => l.volume(),
|
AnyLayer::Group(l) => l.volume(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_volume(volume),
|
||||||
AnyLayer::Group(l) => l.set_volume(volume),
|
AnyLayer::Group(l) => l.set_volume(volume),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.muted(),
|
||||||
AnyLayer::Group(l) => l.muted(),
|
AnyLayer::Group(l) => l.muted(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_muted(muted),
|
||||||
AnyLayer::Group(l) => l.set_muted(muted),
|
AnyLayer::Group(l) => l.set_muted(muted),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.soloed(),
|
||||||
AnyLayer::Group(l) => l.soloed(),
|
AnyLayer::Group(l) => l.soloed(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_soloed(soloed),
|
||||||
AnyLayer::Group(l) => l.set_soloed(soloed),
|
AnyLayer::Group(l) => l.set_soloed(soloed),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.locked(),
|
||||||
AnyLayer::Group(l) => l.locked(),
|
AnyLayer::Group(l) => l.locked(),
|
||||||
AnyLayer::Raster(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::Effect(l) => l.set_locked(locked),
|
||||||
AnyLayer::Group(l) => l.set_locked(locked),
|
AnyLayer::Group(l) => l.set_locked(locked),
|
||||||
AnyLayer::Raster(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::Effect(l) => &l.layer,
|
||||||
AnyLayer::Group(l) => &l.layer,
|
AnyLayer::Group(l) => &l.layer,
|
||||||
AnyLayer::Raster(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::Effect(l) => &mut l.layer,
|
||||||
AnyLayer::Group(l) => &mut l.layer,
|
AnyLayer::Group(l) => &mut l.layer,
|
||||||
AnyLayer::Raster(l) => &mut l.layer,
|
AnyLayer::Raster(l) => &mut l.layer,
|
||||||
|
AnyLayer::Text(l) => &mut l.layer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ pub mod svg_export;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
pub mod webcam;
|
pub mod webcam;
|
||||||
pub mod raster_layer;
|
pub mod raster_layer;
|
||||||
|
pub mod text_layer;
|
||||||
|
pub mod fonts;
|
||||||
pub mod raster_store;
|
pub mod raster_store;
|
||||||
pub mod brush_settings;
|
pub mod brush_settings;
|
||||||
pub mod brush_engine;
|
pub mod brush_engine;
|
||||||
|
|
|
||||||
|
|
@ -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
|
rendered
|
||||||
|
|
@ -757,6 +762,57 @@ fn render_raster_layer_to_scene(
|
||||||
scene.fill(Fill::NonZero, base_transform, &brush, None, &canvas_rect);
|
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)
|
// 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; }
|
if let Some(ex) = extract.as_deref_mut() { ex.drew_other = true; }
|
||||||
render_raster_layer_to_scene(raster_layer, time, scene, base_transform);
|
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);
|
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(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -381,7 +381,9 @@ impl Tool {
|
||||||
use crate::layer::LayerType;
|
use crate::layer::LayerType;
|
||||||
match layer_type {
|
match layer_type {
|
||||||
None | Some(LayerType::Vector) => Tool::all(),
|
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) => &[
|
Some(LayerType::Raster) => &[
|
||||||
// Brush tools
|
// Brush tools
|
||||||
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
|
Tool::Draw, Tool::Pencil, Tool::Pen, Tool::Airbrush,
|
||||||
|
|
@ -397,9 +399,10 @@ impl Tool {
|
||||||
// Transform
|
// Transform
|
||||||
Tool::Transform, Tool::Warp, Tool::Liquify,
|
Tool::Transform, Tool::Warp, Tool::Liquify,
|
||||||
// Utility
|
// Utility
|
||||||
Tool::Eyedropper,
|
Tool::Eyedropper, Tool::Text,
|
||||||
],
|
],
|
||||||
_ => &[Tool::Select],
|
Some(LayerType::Text) => &[Tool::Select, Tool::Text, Tool::Transform],
|
||||||
|
_ => &[Tool::Select, Tool::Text],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2130,7 +2130,7 @@ impl EditorApp {
|
||||||
AnyLayer::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document),
|
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::Video(vl) => find_splittable_clips(&vl.clip_instances, split_time, document),
|
||||||
AnyLayer::Effect(el) => find_splittable_clips(&el.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 {
|
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::Audio(al) => find_splittable_clips(&al.clip_instances, split_time, document),
|
||||||
AnyLayer::Video(vl) => find_splittable_clips(&vl.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::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) {
|
if member_splittable.contains(member_instance_id) {
|
||||||
clips_to_split.push((*member_layer_id, *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::Audio(al) => &al.clip_instances,
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => &[],
|
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
let instances: Vec<_> = clip_slice
|
let instances: Vec<_> = clip_slice
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -2911,7 +2911,7 @@ impl EditorApp {
|
||||||
AnyLayer::Audio(al) => &al.clip_instances,
|
AnyLayer::Audio(al) => &al.clip_instances,
|
||||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||||
AnyLayer::Effect(el) => &el.clip_instances,
|
AnyLayer::Effect(el) => &el.clip_instances,
|
||||||
AnyLayer::Group(_) | AnyLayer::Raster(_) => &[],
|
AnyLayer::Group(_) | AnyLayer::Raster(_) | AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
instances.iter()
|
instances.iter()
|
||||||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
||||||
|
|
@ -3303,7 +3303,7 @@ impl EditorApp {
|
||||||
AnyLayer::Video(_) => hint.has_video = true,
|
AnyLayer::Video(_) => hint.has_video = true,
|
||||||
AnyLayer::Audio(_) => hint.has_audio = true,
|
AnyLayer::Audio(_) => hint.has_audio = true,
|
||||||
AnyLayer::Raster(_) => hint.has_raster = 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),
|
AnyLayer::Group(g) => scan(&g.children, hint),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1292,6 +1292,9 @@ impl AssetLibraryPane {
|
||||||
lightningbeam_core::layer::AnyLayer::Raster(_) => {
|
lightningbeam_core::layer::AnyLayer::Raster(_) => {
|
||||||
// Raster layers don't have their own clip instances
|
// Raster layers don't have their own clip instances
|
||||||
}
|
}
|
||||||
|
lightningbeam_core::layer::AnyLayer::Text(_) => {
|
||||||
|
// Text layers don't have their own clip instances
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,16 @@ pub struct InfopanelPane {
|
||||||
fps_drag_start: Option<f64>,
|
fps_drag_start: Option<f64>,
|
||||||
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
|
/// Resize mode for the active raster layer's "to document size" action (scale vs canvas).
|
||||||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode,
|
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 {
|
impl InfopanelPane {
|
||||||
pub fn new() -> Self {
|
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 presets = bundled_brushes();
|
||||||
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -64,8 +70,26 @@ impl InfopanelPane {
|
||||||
selected_tool_gradient_stop: None,
|
selected_tool_gradient_stop: None,
|
||||||
fps_drag_start: None,
|
fps_drag_start: None,
|
||||||
raster_resize_mode: lightningbeam_core::raster_layer::RasterResizeMode::Scale,
|
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
|
/// Aggregated info about the current DCEL selection
|
||||||
|
|
@ -1167,6 +1191,7 @@ impl InfopanelPane {
|
||||||
AnyLayer::Effect(_) => "Effect",
|
AnyLayer::Effect(_) => "Effect",
|
||||||
AnyLayer::Group(_) => "Group",
|
AnyLayer::Group(_) => "Group",
|
||||||
AnyLayer::Raster(_) => "Raster",
|
AnyLayer::Raster(_) => "Raster",
|
||||||
|
AnyLayer::Text(_) => "Text",
|
||||||
};
|
};
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Type:");
|
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
|
/// Render clip instance info section
|
||||||
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
|
fn render_clip_instance_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, clip_ids: &[Uuid]) {
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
|
|
@ -1270,6 +1475,7 @@ impl InfopanelPane {
|
||||||
AnyLayer::Effect(l) => &l.clip_instances,
|
AnyLayer::Effect(l) => &l.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
};
|
};
|
||||||
if let Some(ci) = instances.iter().find(|c| c.id == ci_id) {
|
if let Some(ci) = instances.iter().find(|c| c.id == ci_id) {
|
||||||
found = true;
|
found = true;
|
||||||
|
|
@ -1601,6 +1807,7 @@ impl PaneRenderer for InfopanelPane {
|
||||||
// active (independent of selection focus, since painting doesn't focus the layer).
|
// active (independent of selection focus, since painting doesn't focus the layer).
|
||||||
if let Some(active_id) = *shared.active_layer_id {
|
if let Some(active_id) = *shared.active_layer_id {
|
||||||
self.render_raster_layer_section(ui, path, shared, active_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.
|
// Onion-skinning view settings — always available, regardless of selection.
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,9 @@ struct VelloRenderContext {
|
||||||
is_playing: bool,
|
is_playing: bool,
|
||||||
/// Active layer for tool operations
|
/// Active layer for tool operations
|
||||||
active_layer_id: Option<uuid::Uuid>,
|
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)
|
/// Delta for drag preview (world space)
|
||||||
drag_delta: Option<vello::kurbo::Vec2>,
|
drag_delta: Option<vello::kurbo::Vec2>,
|
||||||
/// Current selection state
|
/// 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
|
// 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(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) {
|
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)
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: Option<ReplayDragState>,
|
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
|
/// 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 {
|
impl StagePane {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
let instance_id = INSTANCE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
@ -3661,6 +3775,14 @@ impl StagePane {
|
||||||
pending_tool_readback_b: None,
|
pending_tool_readback_b: None,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: None,
|
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(
|
fn handle_ellipse_tool(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
|
@ -9928,29 +10408,17 @@ impl StagePane {
|
||||||
// Check corner handles with correct diagonal cursors
|
// Check corner handles with correct diagonal cursors
|
||||||
for (idx, corner) in world_corners.iter().enumerate() {
|
for (idx, corner) in world_corners.iter().enumerate() {
|
||||||
if point.distance(*corner) < tolerance {
|
if point.distance(*corner) < tolerance {
|
||||||
// Top-left (0) and bottom-right (2): NW-SE diagonal (\)
|
ui.ctx().set_cursor_icon(corner_resize_cursor(idx));
|
||||||
// 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);
|
|
||||||
hovering_handle = true;
|
hovering_handle = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check edge handles
|
// Check edge handles (midpoints are ordered top, right, bottom, left)
|
||||||
if !hovering_handle {
|
if !hovering_handle {
|
||||||
for (idx, edge_pos) in edge_midpoints.iter().enumerate() {
|
for (idx, edge_pos) in edge_midpoints.iter().enumerate() {
|
||||||
if point.distance(*edge_pos) < tolerance {
|
if point.distance(*edge_pos) < tolerance {
|
||||||
let cursor = match idx {
|
ui.ctx().set_cursor_icon(edge_resize_cursor(idx));
|
||||||
0 | 2 => egui::CursorIcon::ResizeVertical, // Top/Bottom
|
|
||||||
1 | 3 => egui::CursorIcon::ResizeHorizontal, // Right/Left
|
|
||||||
_ => egui::CursorIcon::Default,
|
|
||||||
};
|
|
||||||
ui.ctx().set_cursor_icon(cursor);
|
|
||||||
hovering_handle = true;
|
hovering_handle = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -10863,10 +11331,19 @@ impl StagePane {
|
||||||
|
|
||||||
match *shared.selected_tool {
|
match *shared.selected_tool {
|
||||||
Tool::Select => {
|
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)
|
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);
|
self.handle_raster_select_tool(ui, &response, world_pos, shared);
|
||||||
} else {
|
} else {
|
||||||
self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
|
self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
|
||||||
|
|
@ -10932,6 +11409,9 @@ impl StagePane {
|
||||||
Tool::Gradient => {
|
Tool::Gradient => {
|
||||||
self.handle_raster_gradient_tool(ui, &response, world_pos, shared);
|
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
|
// Other tools not implemented yet
|
||||||
}
|
}
|
||||||
|
|
@ -11765,6 +12245,9 @@ impl PaneRenderer for StagePane {
|
||||||
// Handle input for pan/zoom and tool controls
|
// Handle input for pan/zoom and tool controls
|
||||||
self.handle_input(ui, rect, shared);
|
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
|
// Handle asset drag-and-drop from Asset Library
|
||||||
if let Some(dragging) = shared.dragging_asset.clone() {
|
if let Some(dragging) = shared.dragging_asset.clone() {
|
||||||
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||||||
|
|
@ -12143,6 +12626,9 @@ impl PaneRenderer for StagePane {
|
||||||
container_path: shared.container_path.clone(),
|
container_path: shared.container_path.clone(),
|
||||||
is_playing: *shared.is_playing,
|
is_playing: *shared.is_playing,
|
||||||
active_layer_id: *shared.active_layer_id,
|
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,
|
drag_delta,
|
||||||
selection: shared.selection.clone(),
|
selection: shared.selection.clone(),
|
||||||
fill_color: *shared.fill_color,
|
fill_color: *shared.fill_color,
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,7 @@ fn effective_clip_duration(
|
||||||
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
|
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
|
||||||
AnyLayer::Group(_) => None,
|
AnyLayer::Group(_) => None,
|
||||||
AnyLayer::Raster(_) => None,
|
AnyLayer::Raster(_) => None,
|
||||||
|
AnyLayer::Text(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -685,6 +686,7 @@ fn layer_clips<'a>(
|
||||||
AnyLayer::Effect(l) => &l.clip_instances,
|
AnyLayer::Effect(l) => &l.clip_instances,
|
||||||
AnyLayer::Group(_) => &[],
|
AnyLayer::Group(_) => &[],
|
||||||
AnyLayer::Raster(_) => &[],
|
AnyLayer::Raster(_) => &[],
|
||||||
|
AnyLayer::Text(_) => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,6 +720,7 @@ fn collect_clip_instances<'a>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnyLayer::Raster(_) => {}
|
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::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
||||||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
||||||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
||||||
|
AnyLayer::Text(_) => ("Text", egui::Color32::from_rgb(220, 200, 120)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ impl PaneRenderer for ToolbarPane {
|
||||||
AnyLayer::Effect(_) => LayerType::Effect,
|
AnyLayer::Effect(_) => LayerType::Effect,
|
||||||
AnyLayer::Group(_) => LayerType::Group,
|
AnyLayer::Group(_) => LayerType::Group,
|
||||||
AnyLayer::Raster(_) => LayerType::Raster,
|
AnyLayer::Raster(_) => LayerType::Raster,
|
||||||
|
AnyLayer::Text(_) => LayerType::Text,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-switch to Select if the current tool isn't available for this layer type
|
// Auto-switch to Select if the current tool isn't available for this layer type
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue